Compare commits

...

140 commits

Author SHA1 Message Date
le king fu
6e8e15719c state: sync after the Linux update cycle of v0.17.2 and #421
Max ran the real update cycle v0.17.1 -> v0.17.2 on Linux (.deb):
it worked, and a password prompt appeared (its type was not noted).
The trace is in the v0.17.2 release body. #421 was checked on Linux
and closed by hand (comment 25074). Windows and .rpm cycles remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:51:27 -04:00
le king fu
af68d0623f state: pilot ranks of #464 and #465, mark v0.17.2 as published
The #458 pilot table (comment 24819) now has four rows: #459, #450,
#464 and #465, the last two judged right at the high level by Max.
#464 and #465 were counted second and third here without #450.
Entries merged since v0.17.1 now say they shipped in v0.17.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:36:45 -04:00
le king fu
34ca76d4bf state: sync after v0.17.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:14:08 -04:00
le king fu
f42f360dd0 chore: release v0.17.2
All checks were successful
Release / build-and-release (push) Successful in 23m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:49:17 -04:00
le king fu
e0c0f9ee45 state: sync after #421
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:33:44 -04:00
le king fu
ff9656d015 test(dialogs): guard against aliases of confirmAction and other native forms
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m0s
Review of PR #465 (comment 24953), suggestion 1: an alias
(`const c = confirmAction; if (!c(...))`) called confirmAction out of reach
of the "awaited" rule, and window["confirm"](...), confirm?.(...) and
`const { confirm } = window` escaped the native-call rule. Any mention of
confirmAction other than the awaited call, its declaration, a plain named
import or `confirm: confirmAction` is now refused; the three native forms
are flagged. Samples added for each.

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:17 -04:00
le king fu
57ac056bf2 fix(dialogs): wait for the confirmation before deleting or removing the key
On desktop, tauri-plugin-dialog 2.6.0 replaces window.confirm with an async
function: confirm(...) returned a Promise, always truthy, so every guard
`if (!confirm(...)) return;` let the action run before the user answered.
Deleting a secondary profile removed its database even on "Cancel".

- confirmAction() (src/services/confirmService.ts) calls the plugin's ask()
  with a warning icon and buttons labelled in the app language
  (common.confirm / common.cancel); a failed call resolves false and is
  logged (fail closed). @tauri-apps/plugin-dialog 2.6.0 added, the crate's
  version.
- The six sites await it: profile, category, adjustment, budget template and
  Balance category deletions, and both confirmations of the license key
  removal (performRemoveLicense takes an async confirm).
- native-dialog-guard.test.ts fails on any native confirm/alert in src/ and
  on any confirmation not awaited (TypeScript does not flag `!promise`).
- Tests: confirmService, the deletion of a profile in ProfileFormModal, and
  the license removal tests with an async confirm (test: reproduces #421:
  six of them were red on the old code).

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:17 -04:00
le king fu
522232947c docs(release): report edition matrix changes to the site's comparison table
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m3s
The product page of la-compagnie-maximus copies the edition matrix into a
comparison table (la-compagnie-maximus#180). Step 0 of the release skill now
says to prepare the site PR before the tag and deploy it only after the
release is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:18:17 -04:00
le king fu
c667ad0079 state: sync after #419
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:39:21 -04:00
le king fu
c859405deb fix(release): refuse an empty or multi-document latest.json, test the Publish step
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m4s
Follow-ups of the review of PR #418.

- check-latest-json.jq wants exactly one document and release.yml calls it
  with -s: under jq 1.6, the release container's, an empty file passed the
  check (no output, status 0) and two documents were judged on the last one,
  so the job would have deleted the published manifest and uploaded the bad
  one. Two tests cover both cases.
- build-latest-json.test.mjs runs the text of release.yml's "Publish
  latest.json to package registry" step against a fake curl: DELETE then PUT
  on a valid manifest, exit 1 and no request on a refused one.
- check-frontend no longer ignores .claude/** and release.yml (decision of
  Max): the test reads verify-release.sh and runs the Publish step, so "on
  every pull request" holds. REVIEW.md, CLAUDE.md and architecture.md follow.
- release.yml: stale ".deb fallback" comment fixed.
- qa-update-cycle.md: a tauri dev build on Linux shows TargetsNotFound,
  expected since #418.

Refs #461

Resolves #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:00:40 -04:00
le king fu
201a9779fe state: sync after #455 and #457
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:50:31 -04:00
le king fu
c5efc38e96 fix(deps): build the updater without its zip feature
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 4m14s
Clears RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369 on
zip 4.6.1, which turned the daily audit.yml red at run 558 (2026-10-17)
without any lockfile change. zip was compiled only for Windows, through
the default `zip` feature of tauri-plugin-updater 2.10.0. It is fixed
only in zip >= 9.0.0, and tauri-plugin-updater 2.14.0 still requires
zip ^4, so no cargo update can reach a fix.

That feature only unpacks archived update packages (.nsis.zip,
.msi.zip, .AppImage.tar.gz, produced by createUpdaterArtifacts
"v1Compatible"); this app ships createUpdaterArtifacts: true, whose
packages are never archives. The updater is now declared without its
default features, keeping rustls-tls. The lockfile loses zip, arbitrary
and derive_arbitrary (666 -> 663 packages, nothing added), and tar
leaves the Linux graph.

tar was the canary of the suppression guard in check-rust.yml. It is
replaced by webkit2gtk, Tauri's Linux webview, which keeps the property
that made tar useful: a dependency present on x86_64-unknown-linux-gnu
only, so finding it also proves that --target resolves conditional
edges. ADR 0018, architecture.md, qa-update-cycle.md and the release
skill are updated to match.

Verified locally: cargo-audit 0.22.2 exits 1 on the old lock with the
three IDs and 0 on the new one (663 crates, the same 12 allowed
warnings); the suppression guard is green, and red with the canary
swapped for tar or zip; windows-sys guard 9/9; cargo check and cargo
test (267 passed) on Linux; cargo xwin check for x86_64-pc-windows-msvc
is green on main (zip compiled) and on this branch (zip gone).

Refs #455

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:37:48 -04:00
le king fu
54c48f4721 state: sync after #456
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:23:21 -04:00
le king fu
24da72b7f4 docs(review): apply the review of #460, version the calibration script
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
REVIEW.md, from the suggestions of the first review of #460:
- stacked-PR pitfall: fs_commands.rs compiles two CSV files of
  src/__fixtures__/samples/ through include_str!, outside the paths of
  check-rust;
- high level: .claude/settings.json and .mcp.json, a pull_request_target
  trigger, an install step that changes the toolchain or the PATH (the
  check-workflow list is now given as examples of the principle), a lock
  origin whose host or scheme changes even with a new version, and a diff
  that removes or bypasses a consent (price consent lives on the front);
- db.ts and dataExportService.ts stay normal (Max's decision of
  2026-10-18), and the data zone now names the lock, the profile switch
  and the restore;
- release zone: cargo-xwin and the Rust toolchain, installed unpinned at
  each tag, also run with the signing key;
- windows-sys guard: EXPECTED joins CRATES and PIN;
- identifier: changing it orphans the databases and the app_data_dir
  files (license key, activation token, token fallback); the keychain
  service is a constant and does not follow.

scripts/review-levels.py is the calibration script of the PR body, now in
the repo: pages of 50 files read until an empty page (the instance caps
pages at 50), the new rules above, comment lines ignored by the workflow
checks, and --check, which compares its patterns with the Haut table
without network access. Fresh run: 3/28/8 on the 39 reference PRs and
6/33/12 on the 51, unchanged.

Refs #456

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:55:37 -04:00
le king fu
fa1abb115c docs(review): add REVIEW.md with calibrated risk levels and review rules per zone
REVIEW.md sets the review level of a PR from the files its diff touches
(high, normal or low; the highest wins, never the author), lists what
matters in each zone with its source (a CLAUDE.md section or an ADR,
completed by the repo file where the fact lives), the verification
commands of the CI, and the review pitfalls of this repo. The pitfalls
taken from the global pr-review skill are corrected for this repo:
stacked PRs do get CI here since #232.

Calibration on the merged PRs #344 to #452: the 39 reference PRs of the
pilot document come out 3 low / 28 normal / 8 high (20.5 %, target
20 %), the whole range of 51 PRs 6 / 33 / 12 (23.5 %), against 3 / 19 /
17 with the trial patterns. A lockfile-only version bump of a package
already present stays normal, per Max's decision of 2026-10-17.
CLAUDE.md points to the file.

Fixes #456

Note: plan check bypassed after 3 passes, last findings applied (Max's
decision of 2026-10-18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:21:05 -04:00
le king fu
589aee0b38 state: sync after #369
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:46:13 -04:00
le king fu
a8c3fd6997 refactor(gating): one constant for the split's feature key
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Review of PR #452 (comment 24129): "adjustments" was written three
times for the split (the page's gate, the button's edition, the
dialog's locked screen). SPLIT_FEATURE in splitGate.ts is now the one
source, so moving the split to its own key or to Premium is a one-line
change. No behaviour change; setting it to "budget" turns 2 tests red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
le king fu
d5d0e89d2c test(gating): pin that one click never arms the other window
Review of PR #452 (comment 24129): the upgrade to Base checked that the
upsell goes away, not that no split window appears in its place, so a
locked click that also armed the split window (R14) left the suite
green. Both edition changes now assert the other window stays closed,
during the license refresh and after it.

R14 now turns 1 test red, and its mirror (a split click that also arms
the upsell) 1 test red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
le king fu
cb7f8479e4 feat(gating): reserve transaction splits for the Base edition
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m16s
The Répartition (split) button of the Transactions page now belongs to
the `adjustments` feature, as the unlock screen already promised
(upsell.features.adjustments). On the Free edition the button keeps its
place and its icon, carries a lock and opens the locked screen in a
dialog instead of the split window, for every row, split or not. While
the license is not ready it shows no lock and does nothing. Existing
splits stay in the data, counted by the reports and flagged by the
orange icon.

- splitGate.ts: pure splitAccessFor(gate) -> open | locked | pending,
  stricter than profileGate: `allowed` does not short-circuit `ready`.
- SplitButton: stateless button that decides what a click opens.
- UpsellDialog: UpsellGate in a named modal dialog, closed by its X and
  by "I already have a key".
- TransactionTable: required splitAccess prop. The split window is not
  rendered once the edition resolves to Free, and stays mounted through
  a license refresh; the upsell is not rendered once it resolves to Base.
- TransactionsPage reads useEntitlement("adjustments").
- ADR 0017 amendment and matrix, user guide, in-app docs (FR, EN),
  architecture, entitlements header comment, both changelogs.

Tests: the predicate, the button's markup and click, the dialog, the
table's wiring through the shared hook harness (#409), the page under
the three gate states, and the new key in both locales.

Fixes #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:02:47 -04:00
le king fu
065f580c9f state: correct the product position line (v0.17.1, Unreleased not empty)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:28:16 -04:00
le king fu
d8037be11f state: sync after #447
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:21:57 -04:00
le king fu
1763a9fdd3 fix(deps): update rustls to 0.23.46
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m33s
Clears RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and
RUSTSEC-2026-0349, published on 2026-10-14 against rustls 0.23.45. The
daily audit.yml has been red since run 553 (2026-10-15 06:00 UTC) with
the lockfile untouched since v0.17.1: the advisories alone turned it red.

rustls is reached only through tauri-plugin-updater (reqwest 0.13) on
both shipped targets, and none of the four applies to that use: no
custom ClientSessionStore (0346), ECH never enabled (0347), no `brotli`
feature (0348), and the ring provider offers no post-quantum group to
downgrade (0349). Rule 1 of .cargo/audit.toml still asks for a fix
rather than a suppression: the crate is in the graph and a fix exists.

`cargo update -p rustls --precise 0.23.46` changes two lines of
Cargo.lock (version and checksum). No companion bump: 0.23.46 declares
the same dependencies and MSRV. No windows-sys edge moves. The checksum
matches the crates.io index and the downloaded .crate.

The changelog entry scopes "no behaviour change" to the app's own code,
as 9abf474 did for the previous rustls bump: besides the four fixes,
0.23.46 carries about ten other TLS-layer corrections.

Verified locally, cargo-audit 0.22.2 (the CI binary) run from the repo
root: exit 1 with the four advisories before the bump, exit 0 after,
666 crates and the same 12 allowed warnings on both runs. cargo check,
both check-rust guards (2 and 9 checks, exit 0) and cargo test (267
passed, 0 failed) are green.

Refs #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:00:04 -04:00
le king fu
8620049585 state: sync after planned-2026-10-12-retours-onboarding-v0171 (#424, #422, #427, #426, #430, #431, #425, #432)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:39:18 -04:00
le king fu
934bcbf9aa docs(onboarding): cards of the Categories and Reports pages
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Guide and in-app docs name the five card pages, "Got it" on the two
visit cards and the Trends card that also checks the reports step;
architecture lists the new models and targets; the QA list gets the
steps 5 and 8 of the guided walk, the Free padlock variant and the
Trends click.

Resolves #432

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:47 -04:00
le king fu
00be2877f3 feat(onboarding): step cards of the Categories and Reports pages
The two visit steps get their card: /categories explains keywords and
points at "All keywords" (never "Reset"), /reports explains the hub and
points at the Trends card, with a padlock variant in the Free edition.
"Got it" acknowledges the step; opening the highlighted Trends card does
too (D17), since following the ring leaves the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:45:05 -04:00
le king fu
a064043c20 docs(onboarding): eight steps, visit steps and their one-time initialization
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
- Guide and docs.gettingStarted / docs.dashboard keys (FR, EN): eight
  steps, the two visit steps in the quick-start list, "Got it", 3/8 and
  7/7, a finished path finds them done.
- ADR 0022: acknowledgeStep and initializeVisitSteps do not notify (§2),
  ACKNOWLEDGE among the local actions (§4), the one-time initialization
  admitted like rule D9 (§5).
- architecture.md: eight rows, the amended "never by a click" invariant,
  the two new preferences, the provider's new action and initializer.
- QA list: step numbers moved to eight, five cases for the visit steps.
- CHANGELOG: Added entry in both files.

Resolves #425

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:37:30 -04:00
le king fu
eff77e47cc test(onboarding): pin the order of CHECKLIST_ITEM_KEYS to the rows
A mutation that moved "categories" after "manualCategory" in the
constant survived: the rows come from computeChecklist, the constant
only orders the skipped and acknowledged steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:33:37 -04:00
le king fu
64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
Two visit steps join the getting-started path: "Browse categories and
keywords" (5th) and "Browse the reports" (8th). Nothing in the database
counts them: they are done by "Got it" on their card, stored as
onboarding_acknowledged_<key> in user_preferences (no migration).

- computeChecklist: eight rows; the visit rows are locked until the
  profile's visit steps are initialized, so a finished path does not
  flash a "Next step" before they are marked done.
- initializeVisitSteps: once per profile, after the first successful
  load with the Budget entitlement known; acknowledges both steps when
  every other step is done or skipped, then sets the flag. No
  transaction (D16): idempotent writes, the flag last.
- Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps
  the card latch, so the card says "Done." and offers the next step.
- StepCard: "Got it" before "Skip this step" when onAcknowledge is
  given; OnboardingStepCard passes it for a visit step.
- Banner counter, card rank and end of path wait for
  budgetReady && visitStepsInitialized; the list shows the visit rows
  "pending" until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:32:20 -04:00
le king fu
fade01d955 docs(help): fix stale help and guide texts of Budget, Reports and Transactions
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
The page help and the guide (Markdown and in-app) described a Budget split
button removed in v0.2.5, an Annual-column warning that can no longer
appear, report tabs and four report cards (the hub has five, no tabs), a
"Category Zoom" whose displayed title is "Category Analysis", and a "+"
icon on transactions (the keyword button is a tag icon, shown on
categorized rows only; the right-click menu works on every row).

Resolves #431

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:18:03 -04:00
le king fu
6b913709af docs(onboarding): changelog and QA cases of the Transactions and Budget cards
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
The two older QA cases that quoted the previous card sentences now quote
the new ones.

Resolves #430

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:14:23 -04:00
le king fu
63d8ba7589 feat(onboarding): Transactions card suggests Add as keyword, Budget card names leaves and the Annual column
Both Transactions sentences name the context menu's "Add as keyword" and
carry the advice on the word and the boxes (D18); both Budget sentences say
only last-level rows take an amount and what the Annual column does. The
labels go through StepCardText.labels, quoted in the locale files (D11).
The keyword dialog is unchanged; a test pins that it marks the transactions
as categorized by hand, so the step still checks off through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:12:25 -04:00
le king fu
e177a7561f docs(import): changelog, guide, architecture and QA cases of the read-only source name
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Resolves #426

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:08:07 -04:00
le king fu
00b4319c44 test(import): pin the read-only source name of the configuration panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:06:07 -04:00
le king fu
ed3a4bb9a0 fix(import): name a source after its subfolder, read-only
The source name typed during configuration was saved, but the next import
looks the source and its imported files up by the subfolder name, so a
renamed source looked new: format guessed again, every file pre-checked,
and a second source created under the folder name.

The name field becomes a read-only SourceNameDisplay showing the subfolder
name with a hint, and executeImport writes selectedSource.folder_name on
create and update, whatever config.name holds, behind a selectedSource
guard. The import card sentence no longer asks to check the source name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:05:46 -04:00
le king fu
2de4d9d255 docs(import): changelog, guide, architecture and QA cases of the root files warning
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
PR Check — Rust / rust (pull_request) Successful in 3m41s
Resolves #427

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:02:22 -04:00
le king fu
7d3a616312 feat(import): warn about the CSV files placed directly in the import folder
scan_import_folder now returns ImportFolderScan { sources, root_files }:
the CSV/TXT files at the root, which no source reads, are named in a
warning on the Import page (above the source list, or under the "no
source" empty state). One action sets sources and root files, RESET keeps
both. Names stay on screen, never in the Journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:59:20 -04:00
le king fu
af884ca13b feat(import): show the expected folder structure, one subfolder per account, signs at the preview
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
The Import empty state now shows an "Expected structure" box in both of its
variants (no folder, folder without sources): the parent folder to choose,
one subfolder per account, and files at its root ignored, as
scan_import_folder reads them. New ImportFolderStructure component, a nested
list with decorative icons.

The getting-started cards ask for the parent folder and one subfolder per
account instead of one per bank. The preview card says that an expense must
count among the outflows and names "Flip the signs" next to "Check
duplicates": WIZARD_ROWS rows now carry every label their sentence names.
Every Import card sentence quotes the controls it names, in French and in
English, pinned by a test that reads the locale files.

Help tips, docs.import.steps, the user guide, the QA list and architecture.md
follow; CHANGELOG entry under Changed.

Resolves #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:53:05 -04:00
le king fu
22dcad7a29 docs(changelog): budget rows under their parent, subtotals on their own rows
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
Resolves #424

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:43:37 -04:00
le king fu
1c427a1b63 fix(budget): emit the grid rows depth-first and close subtotal blocks
buildBudgetYearRows sorted each top group by depth, so an expanded
subcategory showed at the end of its group instead of under its parent.
It now builds one block per child (lone leaf, or [subtotal, ...children]),
sorts the blocks once ("(direct)" first, then by name, leaves and
sub-groups mixed, as the Budget vs Actual report) and concatenates them;
top groups are sorted by type, sort_order and name.

reorderRows put a leaf that follows a sibling sub-group into that
sub-group's block. A row at the subtotal's depth or shallower now closes
the block, which also fixes "subtotals at the bottom" in the Budget vs
Actual and Compare reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:42:27 -04:00
le king fu
bf02b267b1 state: sync after planned-2026-10-10-fiabilite-tests (#408, #409, #389, #395)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:30:22 -04:00
le king fu
977ef3a1d3 docs: ADR 0024, MCP server (proposed)
Written before the code, from spec-decisions-mcp-server.md and the
reviewed spec-plan-mcp-server.md (local, git-ignored). Status Proposed;
to pass Accepted when the spec-mcp-server milestone merges. Indexed in
docs/architecture.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 20:29:06 -04:00
le king fu
c17bf5dcb4 fix(balance): translate the best-effort badge of the price fetch button
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
PR Check — Rust / rust (pull_request) Successful in 3m58s
The small badge next to "Fetch price" on a stock rendered the literal
"best-effort" without going through t, so French users saw English.
It now reads balance.priceFetching.bestEffortBadge ("non garanti" in
French). Raised by the review of PR #435 (suggestion 4).

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:01:11 -04:00
le king fu
86eff9b7a3 fix(profiles): follow-ups of the review of PR #436
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m8s
PR Check — Rust / rust (pull_request) Successful in 3m54s
- withoutFolders no longer keeps the first word of a folder that holds
  a blank (`C:\Users\Jean Dupont\...\profile_x.db` gave
  `Jean profile_x.db`): a path starts at a recognised prefix (drive,
  `/`, `~/`, `./`, UNC, `<profile folder>`) and runs to its last
  separator, blanks included, until a line break, a double quote, a
  backtick, `;`, `|`, a `,` or `:` before a blank, a closing parenthesis
  not followed by a separator, or a blank before another path. When
  nothing ends it, the text up to the last separator of the line is
  dropped rather than a folder let through. A quote before the path is
  not part of it, so `'C:\Users\Bob\x.db'` gives `'x.db'`. Relative runs
  with no recognised prefix keep the previous fallback. No error line
  contains a `/` nor a `\`, as before.
- ADR 0021 section 7 describes that rule and its limit.
- CHANGELOG.md, CHANGELOG.fr.md: no blank line between the two Fixed /
  Corrigé bullets.

Refs #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:57:29 -04:00
le king fu
ac3a981f64 test(i18n): follow-ups of the review of PR #435
- renderCategoryLabel.test.ts passes markedT itself, its assertions in
  the marked form (`t(balance.category.cash[defaultValue=cash])`): the
  second fake `t` of the repository is gone.
- i18n-mock-guard: a fake `t` is also caught as a binding (`t = (`,
  `t = vi.fn(`) or as a `t: vi.fn(` property, and a file that mocks
  react-i18next must call `i18nMock(`, comments dropped before the
  search. Each new rule has its own case in the guard's tests; the
  binding rule found renderCategoryLabel.test.ts before its migration.
- docs/architecture.md: the "only fake t" sentence is scoped to test
  files and says the guard only refuses the forms it recognises.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:53:18 -04:00
le king fu
a1c31a1da7 test(harness): follow-ups of the review of PR #434
- useOnboardingChecklist.wiring.test.ts: a data event, then a route
  change 100 ms later, still gets its deferred re-read (two LOAD_START).
  Pins the follower's deps: with `pathname` among them, the cleanup drops
  the pending timer and the test goes red (mutation M1 of the review). The
  comment that claimed "one subscription only" now points to this test.
- hookHarness.test.ts: a dependency array that shrinks, or grows by
  `undefined`, recomputes the memo (mutation M8, length branch of
  depsChanged).
- NOT SIMULATED, in hookHarness.ts and docs/architecture.md: the dispatch
  of a useReducer keeps the reducer of the first render, where React
  applies the latest one.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:20 -04:00
le king fu
74fae140b1 fix(db): follow-ups of the review of PR #433
- close() on a getDb() handle runs as a "connection close" section and
  checks its generation: a handle kept from a previous profile rejects
  with DbConnectionClosedError instead of closing the current profile.
- closeUnlocked never rejects: the state is cleared and the generation
  bumped before close(), a failing close of the old pool is logged with
  logWarn, connectToProfile still opens the next profile and closeDb()
  resolves without a connection.
- The blocking alert names a getDb() call in a callback as a possible
  cause only: a long restore also waits 15 s.
- The generation check of the transaction handle is documented as a
  defence, not reachable today.
- Tests: the "behind the queue" alert (pins the reset of lockHolder),
  close() on a stale and on the current handle, a failing close during a
  switch and in closeDb().

Refs #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:12 -04:00
le king fu
fbecfffa6e style(profiles): rewrap the sync comment of backup_to
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
PR Check — Rust / rust (pull_request) Successful in 3m46s
Resolves #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:33 -04:00
le king fu
419b20e769 feat(profiles): name the copy of a stopped catch-up and keep one copy per profile
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
PR Check — Rust / rust (pull_request) Successful in 3m56s
- ProfilePreparation.catch_up_backup carries the file name of the copy as
  soon as it exists, so the Journal error line of a catch-up that stops
  afterwards (history_write_failed, pragma_failed, apply_pending) names it.
- Error lines and the JS fallback message keep only the last segment of a
  path (withoutFolders): no error line contains a slash or a backslash.
- The copy is taken again at every catch-up, written as .tmp, synced and
  renamed over a same-name copy; after a successful catch-up only, the
  other copies of that profile are pruned (remove_backups keep argument).
- Pins: a pre-existing foreign key violation in the profile without
  category 31 (RV4, RV7) and the Journal line that reports it (RT2).
- Ignored test measuring prepare_at on 100 000 transactions at floor 6:
  306 ms in release, so STARTUP_TIMEOUT_MS stays 10 s (ADR 0021).
- Guide, docs.profiles.tips, ADR 0021, architecture and changelogs.

Resolves #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 -04:00
le king fu
3882df772b fix(startup): clear the startup error when the profile opens after the delay
The 10 s startup delay of App.tsx sets the error page while the profile
is still opening; a connection that succeeded afterwards set dbReady but
left the error, so the error page stayed. The startup logic moves to
src/utils/startupConnection.ts (startProfileConnection), which clears the
error on success and is tested with fake timers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 -04:00
le king fu
2bd347b4a0 test(i18n): guard the test sources against a local fake t
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m5s
src/__integration__/i18n-mock-guard.test.ts reads every test file of src
and fails on a useTranslation: or t: ( property (word-bounded) outside
src/test/i18nMock.ts, or on a react-i18next mock that does not go
through i18nMock. docs/architecture.md gets the convention: never a raw
t, always i18nMock.

Resolves #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:58:30 -04:00
le king fu
063f6c3d7d test(i18n): replace the local marked fake t copies with i18nMock
The 12 test files that already marked their output each carried a copy
of the same fake t. They now use the shared i18nMock; no assertion
changes. LicenseCard keeps its per-test language through a getter.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:51 -04:00
le king fu
f5e5897df1 test(i18n): move the raw fake t files to the marked i18nMock
The 15 test files whose fake t returned the bare key (the 12 of the
issue, plus the three category render tests added by #423) now mock
react-i18next with i18nMock. Assertions naming a displayed text are
rewritten in the marked form, t(key[options]), so a text that skipped
t fails them. Components without such an assertion get one:
PriceFetchControl, PriceFetchConsentToggle (rendered through the hook
harness, its loading slot preset), CategoryDetailPanel, SourceList and
SourceCard, and the region label of OnboardingChecklist.

Each component was proved by a mutation removing one t call: every one
turns at least one test red (exit 1).

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:27 -04:00
le king fu
69ef92e65f test(i18n): add the shared marked fake t and use it in useLicenseMachines
src/test/i18nMock.ts holds the one fake t of the suite: markedT renders
t(key) or t(key[a=1,b=2]), and i18nMock({ language?, fixture? }) builds
the react-i18next module for vi.mock. The local echo of
useLicenseMachines.test.ts is replaced by markedT.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:49:44 -04:00
le king fu
79ba1ed2f3 docs(architecture): add the tests section and the hook harness
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Resolves #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:43 -04:00
le king fu
1ba3b8bae9 test: move the React stubs of six test files to the shared hook harness
useOnboardingChecklist.wiring gains three pins the old stubs could not hold:
one loader for the provider's life (refresh and the shared value keep their
identity, no re-read), 'Skip' moving the next step on the next render, and a
change of mode alone reaching the consumers. UsersSettingsPage now commits
its effects, so an effect that read the preference goes red on the
assertion rather than on 'Invalid hook call'. BudgetTable.wiring,
BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local
slot copies; OnboardingStepCard plays StrictMode through replayEffects and
runs its effect only on a changed dependency, which now pins location.key.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:43 -04:00
le king fu
86fd496e77 test: add a shared React hook harness with its own proofs
src/test/hookHarness.ts gives the tests that call hooks and components as
plain functions one fake React with React's semantics: state initialized
once with stable setters, the reducer applied with a dispatch spy, refs kept,
memos and callbacks recomputed only on a changed dependency, effects run by
commit() only when a dependency changed (cleanups first), and every hook it
does not cover throws instead of reaching the real React. Each semantic is
proved by hookHarness.test.ts, which also checks that the module imports
React only as a type.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:36 -04:00
le king fu
b161d54e3a docs(architecture): add the DB access and transactions section
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
Strict lock, transaction handle, single section for a profile switch,
connection generation and blocking alert.

Resolves #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:32:05 -04:00
le king fu
8efe99aa20 test(db): release gated transactions in finally so a red assertion holds no lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:28 -04:00
le king fu
08946e6d08 fix(db): keep the lock-free path for the transaction handle only
The global inTransaction flag let every execute/select bypass the FIFO
lock while a withTransaction callback ran, wherever it came from. The
callback now receives its own handle, which runs directly, refuses
close() and throws once the callback settled; any other call, getDb()
from a callback included, queues behind the transaction.

connectToProfile, initializeNewProfileDb and closeDb each run as one
exclusive section (close the old pool, prepare, load), and every open
or close bumps a connection generation: an operation queued for a
closed connection throws "connection closed by profile switch". An
operation waiting more than 15 s for the lock writes one Journal
warning naming the holder and since when.

findOrCreateSecurity now requires its executor: its getDb() fallback
would deadlock if called from a transaction callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:28:36 -04:00
le king fu
8ceb3de921 state: sync after #423
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:49:04 -04:00
le king fu
8898213e02 refactor(categories): follow-ups of the review of PR #428
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
Refs #423

- parentOptions.ts: say that this list is the only guard today (updateCategory
  checks neither depth nor cycle, #393 will), instead of pointing to a
  service-side validation that does not exist yet. The height walk is also
  bounded by the number of categories, so a missing cycle guard gives a wrong
  height instead of an endless loop; a test edits a member of a cycle.
- AddKeywordDialog.tsx: drop `categoriesLoaded`, which only repeated what the
  null initial category already guarantees.
- Tests: the form and the detail panel are pinned to what they hand to
  buildParentOptions (editingId, currentParentId), through a pass-through spy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:44:54 -04:00
le king fu
04ac2e933e fix(categories): shrink the allowed parent depth by the height of the edited sub-tree
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
Refs #423

The review of PR #428 showed that the rule only looked at direct children:
editing a root that carries grandchildren still offered the other roots as
a parent, and saving created a fourth level, which nothing guards against
in updateCategory or moveCategory. The allowed parent depth is now
MAX_PARENT_DEPTH minus the height of the edited sub-tree (0, 1 or 2), so a
category with grandchildren gets no parent at all, only its current one.

Test written first on the reviewer's probe ([1, 10->1, 100->10, 2],
editing 1: [] expected, [2] obtained before the fix).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:15:52 -04:00
le king fu
4bdb00402e feat(categories): type-ahead combobox for the parent and keyword category fields
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Fixes #423

The "Parent category" field of CategoryForm and the "Category" field of
AddKeywordDialog were native selects with no typing. Both now use
CategoryCombobox, like the category cell of the Transactions page: typed
letters filter the list, which is ordered hierarchically with indentation
and shows seed categories through their i18n_key.

- CategoryCombobox accepts a minimal structural type (CategoryLike), so
  CategoryTreeNode rows pass without conversion; sortHierarchical is
  generic over it. Callers are unchanged.
- buildParentOptions (pure, tested) keeps roots and level-1 categories,
  leaves out the edited category, and keeps only roots when the edited
  category has children. That rule alone also leaves out its descent (a
  descendant is never a root), so no separate subtree walk is needed. The
  parent currently stored is always kept so the field never looks blank.
  CategoryForm gets an editingId prop, passed by CategoryDetailPanel.
- AddKeywordDialog reads the Transactions list (active and inputable,
  decision D2) through loadKeywordCategories, keeps the pre-filled
  category only when it is in that list (reconcileInitialCategory), and
  holds the apply button until the list is in. Its two fields sit above
  the scrolling block of matches, so the open list is not clipped by the
  scrollport.
- Tests: parentOptions, keywordCategory, and static renders of both
  components. Changelogs and the user guide (with its i18n mirror).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 17:22:31 -04:00
le king fu
1954502d10 state: sync after the release-deps milestone (#376, #345, #320, #358)
Milestone planned-2026-10-10-release-deps merged (PRs #414 to #418,
main 9e8c41b): new position paragraph and decision entry, the oldest
decision (#365) rotated out. Blockers: #419 point 1 before the next tag,
stale mentions of #376, #317, #345, #358 and #320 replaced by their
closure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:36:13 -04:00
le king fu
9e8c41b1f3 docs: say what still reports npm advisories after review of #417
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m53s
PR Check — Rust / rust (pull_request) Successful in 3m48s
Review follow-up of PR #417. CLAUDE.md and docs/architecture.md said
nothing re-evaluates `npm audit` in CI. No job fails on it, but the
`npm ci` step of check-frontend audits on every run and prints the count
(run 520: found 0 vulnerabilities; runs 512 and 519: 8), and the
Defenseur reports advisories on its side, which is where #358 came from.

Refs #358

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:05:44 -04:00
le king fu
d1d025df8d docs: fix three comments after review of #414
Review follow-ups of PR #414.
- audit.yml points to the concurrency comment of check-rust.yml, whose
  measurement covers push and pull_request runs only: say this workflow
  runs on schedule and workflow_dispatch, which it does not cover.
- reportService.test.ts said CI runs Node 20, so no node:sqlite; CI runs
  Node 24 since #376, and the harness itself is #392's.
- The release skill said /actions/tasks ignores `limit`. Measured
  2026-10-12: only without `page` (613 rows); with `page` it pages by 30
  or by `limit`.

Refs #376

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:05:44 -04:00
le king fu
08d54fd176 docs(adr): correct ADR 0023 and the guard's comment after review of #415
Review follow-ups of PR #415. The container does have python3 (pulled by
the apt packages of the install step, run 517): awk is kept because mawk
is part of the ubuntu:22.04 base image, which the workflow comment and
the ADR now say. #344 is a PR and the second drift, not the third. The
awk exit-code test is described by what it alone catches (an awk that
fails after printing every fact). The repair procedure gains the two
failures it had no answer for, and the ADR names the canary's limit.

The release commit edits Cargo.lock on main without a PR, so /release
now replays the guard's exact step text right after that edit (step 2),
checked on the stack's lock (exit 0) and on a drifted edge (exit 1).

Refs #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:32:51 -04:00
le king fu
230ecd85ad fix(release): drop the generic linux-x86_64 key from latest.json
Max's decision on PR #416, after its review measured who still read the
key. tauri-plugin-updater looks up "{os}-{arch}-{installer}" first, and
every version that ships the updater (v0.2.1 and later, plugin 2.10.0,
tauri-utils 2.8.2 in each tag's Cargo.lock) carries its bundle-type
marker, so .deb and .rpm installs never reach the generic key. Its last
readers were the AppImage installs v0.4.3 to v0.4.5 (v0.4.2 targets a
route that answers 404, earlier versions read GitHub) and binaries run
outside a package; none of them can install the .deb it served, and the
updater wrote that .deb over the running file. They now get
TargetsNotFound when checking: an error on the Updates card, nothing
installed.

The key leaves the script, scripts/check-latest-json.jq and
verify-release.sh together; the test compares the three lists. Docs fix
the readers named by #416 (v0.4.2 is not one) and the CHANGELOG entry
says what changes for those AppImage installs.

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:30:34 -04:00
le king fu
ba3db7c35d ci(release): check latest.json with one jq filter that the tests also run
Review follow-ups of PR #416. The check that release.yml runs before
deleting the published manifest moves from an inline jq program to
scripts/check-latest-json.jq, which release.yml applies with
`jq -e --arg version ... -f`. scripts/build-latest-json.test.mjs now runs
that same filter on every pull request: on the script's output, on a
wrong version, on nine broken manifests and on the shape published up to
v0.17.1. It also compares the key lists of the script, of the filter and
of verify-release.sh, so a list left behind shows before the tag.

Two new cases pin the version argument: 0.18.0 must come out as 0.18.0
(the other fixtures pass 0.17.1, the version the bundle names carry, so a
script that ignored its argument passed them all), and an empty version
must fail. Both mutations survived the review's pass.

No change to what the manifest holds: still four keys.

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:28:48 -04:00
le king fu
e8306aa260 fix(deps): refresh npm lock to clear 8 advisories
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m53s
npm audit reported 8 vulnerabilities (5 high, 3 moderate) on the lock
of main: react-router and react-router-dom (one advisory,
GHSA-qwww-vcr4-c8h2), vitest and @vitest/mocker (one advisory,
GHSA-82fw-gwwq-j7x9), browserslist, baseline-browser-mapping, nanoid
and source-map-js. Every fix sits within the range package.json
already declares, so only the lock moves; package.json is untouched.

Lock generated with npm 11.21.0 exactly:

  npx --yes npm@11.21.0 update react-router-dom react-router vitest \
    @vitest/mocker browserslist baseline-browser-mapping nanoid \
    source-map-js --package-lock-only --ignore-scripts

259 entries before and after, 20 changed, each resolved from
registry.npmjs.org with a new sha512 integrity. npm ci, under npm 10
and under npm 11.19.0, leaves the lock as is; npm audit: 0.

Only react-router 7.18.1 -> 7.18.4 changes the built app: a build from
a lock where only react-router moved equals the full build byte for
byte. Since 7.18.3, useNavigate() and <Navigate> reject a navigation
to another origin; the app only navigates to internal paths.

CLAUDE.md and docs/architecture.md no longer describe 2 high
advisories accepted for good: npm audit is at 0 on 2026-10-10, a
dated reading that nothing re-runs in CI.

Resolves #358
Resolves #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:14:34 -04:00
le king fu
5ce136f45e docs: name the readers of the linux-x86_64 fallback after self-review
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
The fallback key is also read by the AppImage installs of v0.4.2 to
v0.4.5 (bundled until v0.7.3, published on Forgejo for v0.4.2-v0.4.3
and on the GitHub mirror for v0.4.3-v0.4.5, all reading the registry),
which look up linux-x86_64-appimage, absent, then linux-x86_64. Like a
marker-less binary, they cannot install the .deb it serves: the updater
writes its bytes over the running AppImage. State since v0.4.2, not
changed by this PR; the key stays as decided.

Also quote the error the update card really shows for a refused
package (invalid updater binary format).

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:55:02 -04:00
le king fu
da7129d529 fix: give rpm installs their own key in latest.json
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
latest.json only had linux-x86_64, built from the .deb. An .rpm install
looks up linux-x86_64-rpm first, then fell back on linux-x86_64,
downloaded the .deb and refused it (InvalidUpdaterFormat).

- scripts/build-latest-json.sh writes linux-x86_64-deb,
  linux-x86_64-rpm, linux-x86_64 (the .deb, as a fallback) and
  windows-x86_64 from the release assets. It fails unless it finds
  exactly one .deb, one .rpm and one -setup.exe, each with a non-empty
  .sig. The JSON comes from jq alone, notes read with --rawfile, file
  names encoded with @uri like the upload step.
- scripts/build-latest-json.test.mjs (vitest) runs it under
  bash -e -o pipefail on a temporary assets dir; check-frontend
  installs jq.
- release.yml calls the script, writes the notes output under a random
  delimiter, checks the manifest with jq before the registry DELETE,
  and names the .rpm instead of an AppImage in the release notes.
- verify-release.sh requires the four keys, the extension of each URL
  and the minisign file: of each signature, and exits 1 on any failure.
- Docs: release skill, qa-update-cycle section 3 (rpm procedure, keys,
  reason and removal condition of the fallback), architecture, README,
  CHANGELOG, STATE.

Resolves #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:49:12 -04:00
le king fu
3b8105c253 docs: fix two sentences of ADR 0023 after self-review
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 3m36s
"Ce sous-arbre" lost its referent once the trigger paragraph stopped
naming a subtree: the sentence now says what is measured, that both past
drifts (#310, #344) came with RustSec advisory fixes. Rule 1's sentence on
a missing block is reworded.

Refs #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:17:09 -04:00
le king fu
8d9a3ca9e9 ci: guard the windows-sys 0.61.2 pin in check-rust
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 5m12s
Seven crates in Cargo.lock accept windows-sys through a wide bound
(colored, dirs-sys, errno, rustix, rustls-platform-verifier, tempfile,
winapi-util), so cargo may point them at any windows-sys version already in
the graph. All seven point at 0.61.2, the state every Windows release has
shipped with since v0.14.0. Some updates re-resolve them (cargo update -p
hyper, -p h2, -p rustls-webpki -p tar did) down to 0.48/0.52/0.59 while
cargo only reports the packages it bumped: the Windows build then compiles
five windows-sys versions instead of three, and nothing says so.

A new blocking step, "Verify windows-sys pinning", reads the lockfile with
awk right after the suppression guard and fails if any edge of the seven
crates leaves 0.61.2 (a bare edge only passes when the lockfile holds a
single windows-sys, at 0.61.2), if a guarded crate has no block, or if the
set of windows-sys versions changes. awk's exit code is tested apart from
its output, every check is logged with a summary line, and a canary
(tempfile) must be read and checked by the loop.

The step text was extracted from the YAML and run as is, with the runner's
bash options, on copies of the lockfile: main's and the five release tags'
locks pass (9 checks, exit 0); the hyper-bumped lock, an added
windows-sys 0.53.0, a renamed guarded crate, a missing lockfile and a
guarded list without the canary all exit 1.

ADR 0023 records why 0.61.2, the measured cost (3 versions compiled for
Windows instead of 5), the triggers, the exit condition (windows-sys 0.62,
or convergence on a single version) and the repair when the step fails.
CLAUDE.md and docs/architecture.md cite the guard next to the suppression
check. Cargo.lock and .cargo/audit.toml are untouched.

Resolves #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:14:25 -04:00
le king fu
f19b027c5d ci: fix the release run lookup in the /release skill
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
PR Check — Rust / rust (pull_request) Successful in 3m41s
The /actions/tasks endpoint ignores `limit` and returns the whole list,
newest first, so `?limit=5` promised five rows it does not give. The
skill now selects the release.yml run of the tag directly (python3, as
verify-release.sh does), checked on v0.17.1: run 508, `v20.20.2`.

Also clarify check-frontend.yml's concurrency comment: same setup as
check-rust.yml, not the same group.

Refs #376

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:43:57 -04:00
le king fu
71a4e1e88f ci: move check-frontend, audit and release from Node 20 to Node 24
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m51s
PR Check — Rust / rust (pull_request) Successful in 4m46s
Node 20 reached end of life on 2026-04-30. check-frontend.yml, audit.yml
and release.yml now install Node.js 24 through NodeSource's setup_24.x,
as check-rust.yml has done since #375. Each step still prints
`node --version` in the job log.

The concurrency comment of check-rust.yml claimed the key cancels
obsolete runs on force-push. la-compagnie-maximus docs/forgejo-ops.md
measured the opposite on Forgejo 8.0.3: no pull_request run was
cancelled by a later one, and push runs are cancelled without the key.
The comment now states that measurement; the keys stay (harmless,
meaningful where honoured), and check-frontend.yml and audit.yml point
to it.

release.yml names the trust it places in NodeSource: the script runs as
root in the job container, whose Node.js then runs `npx tauri build`
with TAURI_SIGNING_PRIVATE_KEY. Kept on purpose, also documented in
docs/architecture.md, whose release.yml section described two jobs
(windows-latest, ubuntu-22.04) where there is one containerised job
cross-compiling Windows with cargo-xwin.

release.yml only runs on a tag, so Node 24 there is proven at the next
release: the /release skill now reads `node --version` from the release
run's log (step 9), and STATE.md lists it under the checks due at the
next release.

No CHANGELOG entry: CI only.

Resolves #376

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:40:28 -04:00
le king fu
87920ec02d state: sync after the go-live closure and the last manual checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:59:48 -04:00
le king fu
571104393b state: sync after the manual checks on the published releases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:18:12 -04:00
le king fu
c7febe582e state: sync after v0.17.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:48:37 -04:00
le king fu
e4db715d6c chore: release v0.17.1
All checks were successful
Release / build-and-release (push) Successful in 22m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:21:41 -04:00
le king fu
6de73b3c22 state: sync after the guided getting-started path (#396-#401)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:14:18 -04:00
le king fu
ee17f31352 docs(onboarding): the import counter, D9 and the QA cases from the stack's review
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
- Guide (FR, EN, in-app keys and `.md`) and CHANGELOG: the Import card
  never says "Done." for an import that added no transaction, and the
  step waits for a file that brought one; a Fixed entry, since v0.17.0
  already ticked the step after such an import.
- CHANGELOG: the Free edition shows the Budget step locked, it does not
  lack one.
- architecture.md: the import counter's query, and D9's flag read
  outside any transaction, its log line written after the COMMIT.
- ADR 0022: `applyMigration` and `deleteTransaction` named among the
  writes that do not notify, with their reason.
- QA list (44 cases): the noop import at rest, the focus ring on the
  highlighted control, "Skip" on the folder step without a folder, the
  Transactions and Budget cards on a profile without transactions, the
  focus after "Hide" and "Close", the doubled counter of the unfolded
  list, and a month-agnostic note on the off-screen cell.

Reviews of #402 to #407.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
abc9076d23 fix(budget): drop the grid's target once the grid leaves the page
On a profile without transactions whose import steps were skipped, the
rows can land before the transaction count, both reads queueing behind
the DB lock: the grid mounted, reported "Expand all", then gave way to
the empty state, and the card kept "Click Expand all…" above it with no
grid on the page. The page now passes no target while it shows the
empty state, and a wiring test replays that sequence. Invisible in dev,
where StrictMode reorders the two reads.

The month test sets the clock to 23:00 on December 31, which is still
December in UTC: under the CI runner's zone, a month read in UTC passed.
The zone is pinned west of Greenwich, as in useImportWizard.test.ts.

Review of #400 (PR #406).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
ba71a6421d test(transactions): replay the hook's real sequence and pin the card's sentence
The reducer tests sent SET_PAGE_RESULT right after the initial state,
while the hook sends SET_LOADING then SET_ERROR(null) before each read,
and the mount effect's categories, sources and count before or after
the first page. Had any of those three actions disarmed the guided
fallback, the fallback would never run in the app, and the tests stayed
green: a test now reduces both real orders.

The page tests now also let the list's filters move, as a later
dispatch would: after the fallback the card names every transaction,
and an ordinary visit with "Uncategorized" picked by hand names the
uncategorized ones. The sentence follows the list, not the navigation
state.

Review of #399 (PR #405).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
74a6572475 fix(onboarding): give the focus ring back to the highlighted control
The ring of `[data-onboarding-highlight="true"]` is an author outline:
it wins over the browser's focus ring, so a keyboard user who reached
the highlighted control saw nothing change (WCAG 2.4.7, failure F78).
Measured by the review in headless Chromium, WebView2's engine: the
highlighted button kept `solid 3px` focused or not. `outline: revert`
under `:focus-visible` gives the browser's ring back, kept as is in the
built stylesheet; the contrast test reads the rule from the stylesheet.

Review of #398 (PR #404).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
ec3b5d5185 test(data): pin each restore's notification after the DB lock
ADR 0022 wants a transactional write to notify once `withTransaction`
has returned, never from its callback. Only the categorization test
checked the lock; the restore tests checked that the notification
followed the COMMIT, which a notification sent from inside the callback
also satisfies. Each restore mode now asserts the lock was released.

Review of #396 (PR #402).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00
le king fu
696b83fb10 fix(onboarding): skip the D9 transaction once its flag is set
`initializeGuidedPath` opened BEGIN, read the flag, then COMMIT at every
mount of the provider: start-up, and every profile switch, creation or
deletion. While a `withTransaction` callback runs, `db.ts` lets every
other query bypass its lock, the overlap its serialization exists to
prevent.

The flag is now read first, outside any transaction, through the queued
connection: once D9 ran, an opening costs that one read. Without it, the
transaction re-reads the flag before writing, so two first loads still
cannot apply the rule twice (CWE-362).

Pinned: no transaction and no write with the flag set; the flag counts
only for "1", on both reads; a first load that sets the flag between
the two reads wins; the in-transaction reads still follow BEGIN.

Review of #396 (PR #402).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00
le king fu
a58f8b98bc fix(onboarding): count an import only once the file brought a transaction
The "Import a first file" step counted `imported_files` rows in status
`completed`. `executeImport` writes that row before its first insert, so
an import that added nothing (a file holding only its header, or one
whose rows were all in error in the preview) ticked the step: the Import
card then said "Done." at rest and sent the user to /transactions, which
had nothing to categorize and led back to the import.

The counter now keeps a completed file only when a transaction points at
it (`EXISTS` on `transactions.file_id`, covered by
`idx_transactions_file`). Replayed with sqlite3 on
`consolidated_schema.sql`: 0 after an import without a valid row, 0
before a successful import's first insert and 1 after, 0 once the file
turns to `error`. Duplicates only change nothing: their transactions
came with an earlier completed file, and a restore writes its own.

The tests run without SQLite, so the rule is pinned on the query, and
the Import page is pinned at rest after such an import: step 4 again,
on the first source card, without "Done.".

Review of #398 (PR #404), option A chosen by Max.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00
le king fu
5c18fddfc0 docs: say where the Import card holds back "Done." (self-review)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m6s
The guide and the changelog said the card never says "Done." for an
import that added no transaction. It holds it back on the report only:
on the Import page the card moves from one step to the next, and says
"Done." once the file is imported. The locale test no longer depends on
the order of the sections in the files, which DocsContent ignores.

Resolves #401

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:59:55 -04:00
le king fu
d6f42dbe32 docs: architecture, QA checklist and changelog for the guided path
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
- architecture.md: the Onboarding section describes the guided path as
  delivered (provider and useOnboarding(), the data-change channel of
  ADR 0022, the three onboarding preferences, rule D9, the pure models of
  onboardingPath.ts, the banner, the card latch and CARD_PAGES, the step
  cards, the highlight by props and CSS); the utils count and the
  user_preferences row follow the stack.
- qa-onboarding.html: the phase 1 cases that still hold, without the
  compact bar ones, and four guided-path groups, including the manual
  checks the five links left for a release build (WebKitGTK and WebView2,
  Orca and NVDA, reduced motion, keyboard, Free edition, rule D9).
- CHANGELOG (EN and FR): one entry for what every step card does, one per
  page, the path hidden on active profiles covers the banner too, the
  skipped state, and the guide's new sub-section.
- STATE.md: manual checks due on the next release.

Resolves #401

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:53:58 -04:00
le king fu
7ce5816bb2 test(docs): hold both locales to the shape the user guide reads
DocsContent and DocsSubsections read the guide straight from fr.json and
en.json, but their tests run on fixtures: a string where an array is
expected, a sub-section without its steps or a French list longer than
its English one only showed in the app. DocsContent.locales.test.ts reads
the real files: every section DocsContent renders and no other, the
strings and string arrays it reads, sub-sections that readSubsections
keeps whole, anchors of their own, French and English item for item, and
the path's sub-section in the section "Open the guide" opens.

DocsContent exports SECTIONS for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:53:52 -04:00
le king fu
b0db1d3102 docs(guide): describe the guided getting-started path in the user guide
The "Getting Started" section, which "Open the guide" opens at the end of
the path, gains a "The getting-started path" sub-section in both locales
and in docs/guide-utilisateur.md: the banner and its list, the step cards
and their outline, "Skip this step" and "Resume", the end of the path,
the profiles that find it hidden from the start, and how to show it again
from Settings -> Users. It uses the sub-section shape DocsSubsections
already reads for the Import section.

The Dashboard and Settings sections no longer describe the compact bar
the banner replaced, and the Profiles section says the path belongs to
each profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:53:47 -04:00
le king fu
d76c387fa5 docs: changelog for the Budget step card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m0s
Added: the getting-started card of the Budget page. architecture.md
stays as it is: no line of it is made stale (the budget folder still
holds five components, the pure module is not one).

Resolves #400

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:33 -04:00
le king fu
2386486762 feat(onboarding): the Budget page's step card and its highlight
The budget grid opens collapsed and only its leaf rows take an amount,
so the card first points at "Expand all", then at the current month's
cell of the first editable row shown (D12). `budgetOnboardingTarget`
(pure, beside budgetTableResults.ts) decides it from the rows each
section shows, in the order it shows them; the collapse state stays
BudgetTable's own and the card never unfolds the grid.

BudgetTable rings that one control when the page asks
(`onboardingTarget`) and reports its kind through
`onOnboardingTargetChange`, in an effect, at mount and on change only:
the page needs it for the card's sentence before it asks for the ring.

`budgetCardModel(input, checklist)` gives the "collapsed" sentence for
"Expand all", the "flat" one for a cell, no sentence and no ring before
the grid says, and "Done." once the budget step is checked, which the
card needs to end the path. CARD_PAGES gets /budget. BudgetPage renders
the card first, outside the loading dim the grid gets after each save,
and asks for the ring only while the card shows. The Free edition never
mounts the page and never latches it: its path ends at 5/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:29 -04:00
le king fu
9a695a2504 docs: changelog and architecture for the Transactions step card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m5s
Added: the getting-started card of the Transactions page and the guided
entry its step link opens. architecture.md gets the lines this change
touches: useTransactions' start filters and single fallback, and the
i18next `lowercase` formatter in the Internationalisation section.

Resolves #399

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:56:49 -04:00
le king fu
fd44d8702b feat(onboarding): the Transactions page's step card and its guided entry
The link of the "categorize by hand" step carries `guided`, which the
Transactions page reads through `readOnboardingNavState` and turns into
start filters: `useTransactions(initialFilters?)` puts them over the
defaults through `initTransactionsState`, at mount only, so the card
consuming the navigation state changes nothing. The guided entry lists
the uncategorized transactions whatever their date (the default filter
starts on January 1st, and in January the sample statements fall in the
previous year). When its first page comes back empty, the reducer drops
"uncategorized only" once: a new filters object, so the existing filter
effect reads again, and the loading placeholder meanwhile. The first
page and any filter the user sets disarm the fallback, so it never loops
and never drops a filter behind the user's back.

`transactionsCardModel(filters, checklist)` says which sentence matches
the list, names the filter by its own label, points at the first row's
category, and says "Done." once the step is checked, which the card
needs to offer the Budget step or the end of the path. The label loses
its capital through an i18next `lowercase` formatter used by the
sentence itself, `{{filter, lowercase}}`.

TransactionTable wraps every row's category in the same element and
puts the ring on the first one only, so the ring coming or going never
remounts the combobox; CategoryCombobox is unchanged. CARD_PAGES gets
/transactions, and the page renders the card first and highlights its
target only while the card shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:56:44 -04:00
le king fu
ee26fe0888 docs: changelog and architecture for the Import step card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
Added: the getting-started card of the Import page. Fixed: the "No import
folder" empty state no longer flashes while the folder is read.
architecture.md gets the lines this change made stale: the component
count, the onboarding folder, useImportWizard's `importFolderLoaded` and
the Import row of the empty states table.

Resolves #398

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:34:57 -04:00
le king fu
ed5eeb0265 feat(onboarding): step card with highlight, and the Import page's card
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>
2026-10-08 08:34:45 -04:00
le king fu
d024f5aeb9 fix(import): mark the import folder read once its mount read is over
The wizard starts with `importFolder` null for lack of reading, which the
Import page took for "no folder": the "No import folder" empty state
flashed before the stored folder came back (#384).

`importFolderLoaded` is set in the `finally` of the mount read, after the
first scan, so neither variant of the empty state flashes, and it is kept
by RESET, which rebuilds from the initial state while the mount read
never runs again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:34:37 -04:00
le king fu
46e714234e docs: changelog and architecture for the getting-started banner
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
CHANGELOG (EN and FR): the banner under Added; the compact bar replaced
by the banner, the states read out by the list, and the re-show button
of Settings → Users under Changed. docs/architecture.md: the lines this
change made false (component count, the onboarding/ folder, the compact
mode and the re-show sentences). The user guide is rewritten by the
last issue of the milestone.

Resolves #397

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:02:47 -04:00
le king fu
2388ad62fa feat(settings): read the getting-started path from its provider
Settings → Users no longer reads the hidden preference at mount: it
takes hidden and show from useOnboarding(), the provider being the only
writer of the path's preferences. Hiding the path from the banner of
this very page now offers "Show the getting-started checklist" at once,
and a local flag keeps the confirmation in place of the button until
the path is hidden again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:02:43 -04:00
le king fu
75c1708bbe feat(onboarding): show the getting-started banner on top of every page
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>
2026-10-08 08:02:39 -04:00
le king fu
21c46b6380 test(onboarding): pin the provider hook's React wiring
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
renderToStaticMarkup runs no effect, so the hook's own wiring was guarded by
no test: the route re-read (pathname in the deps), the data-channel
subscription and its cleanup, the card latch stored during render, and the
order "write the preference, then update the state" of hide, show, skip and
resume. React's hooks are stubbed so the hook runs as a plain function on a
prepared state, its effects captured and run by hand.

Resolves #396

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:39:00 -04:00
le king fu
68b90f5f16 docs: ADR 0022 and changelog for the data-change channel
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
ADR 0022 records the channel: its scope, the writes that notify and when,
the 150 ms wait on the subscriber side, stale answers ignored, and who may
subscribe. architecture.md gets its row in the ADR table, plus the rows this
change made wrong (onboardingService, useOnboardingChecklist, the new
dataEvents service, the contexts folder). The changelogs record rule D9: a
profile that already had transactions hides the getting-started checklist
the first time it is opened with this version.

Resolves #396

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:34:02 -04:00
le king fu
dcf3b013dc feat(onboarding): share the getting-started path through a provider
The checklist state moves from DashboardPage to OnboardingProvider, mounted
in AppShell around the routed page; pages read it through useOnboarding().
The reducer stays in useOnboardingChecklist:

- loads are numbered and a stale answer is ignored;
- HIDE, SHOW, SKIP and RESUME win over the snapshot of a load started
  before them (#384);
- a refresh keeps the counters and the ready status, and a failed re-read
  after a first success keeps the last state; only the start of a failure
  streak is logged;
- re-read at mount, on every route change and 150 ms after a burst of
  data events.

onboardingService: getOnboardingSnapshot replaces getOnboardingCounters and
is a pure read (skipped steps included, one preference per step, filtered on
the six keys and the value "1"); initializeGuidedPath applies rule D9 once
per profile in one transaction (hidden first, then the flag, ROLLBACK on
failure), awaited once by the first load through a promise shared by
StrictMode's double mount; skipStep / resumeStep are idempotent.

computeChecklist counts skipped rows; the new pure onboardingPath module
holds nextStep, CARD_PAGES (empty for now), stepsOfPage, the card latch,
bannerMode and readOnboardingNavState. The dashboard reads the context and
keeps its compact bar for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:33:57 -04:00
le king fu
4febed9eab feat(data): announce counter-moving writes on a data-change channel
Add src/services/dataEvents.ts (ADR 0022): notifyDataChanged() and
subscribeDataChanged(), without React, on the model of logService.subscribe.
A throwing subscriber is logged and does not stop the others.

Notify after every successful write of the tables the getting-started
counters read, plus the import folder: setImportFolder, createSource,
deleteSource, createImportedFile, updateFileStatus, both import deletions,
insertBatch, updateTransactionCategory, applyKeywordWithReassignment (after
COMMIT), the four budget writes and runRestore (after COMMIT, one point for
the three restore modes). executeImport notifies once more when it ends,
succeeded or not. A failed write notifies nothing; transactional writes
notify once withTransaction has returned. applyTemplate notifies once for
the whole template through a private writeBudgetEntry helper.

Each service suite proves the notification after a success and its absence
after a failure; executeImport is driven as a plain function with React's
hooks stubbed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:33:47 -04:00
le king fu
4a82116614 state: sync after #390 (PR #394)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:01:24 -04:00
le king fu
5910387eb7 test(profiles): pin the review's quick suggestions on the catch-up (#390)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m39s
From the /pr-review of PR #394:
- the profile database opens only once prepare_profile_db has resolved
  (deferred preparation; a load started early used to pass, RT1);
- a real VACUUM INTO failure, through a dangling symlink, names the full
  path and comes out scrubbed (RV6); the test first checks the raw message
  does name the folder;
- deleting a profile removes the -journal side file, the only one this
  app produces (RV5);
- test folders are removed when each test ends, a failing one included
  (TestDir guard), instead of 42 folders left in /tmp per run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:46:26 -04:00
le king fu
71147c5083 test(profiles): cover a v0.15-v0.17 secondary profile without history (#390)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
PR Check — Rust / rust (pull_request) Successful in 5m9s
The released-states loop stops at v0.10-v0.14 (state E). State F, the
consolidated schema of v0.15.0 to v0.17.0 without a history, only had
its floor checked; this test runs the whole preparation on it: history
recorded up to v17, nothing applied, no copy, flag repaired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:54:38 -04:00
le king fu
02fca2ad3c docs: ADR 0021, guide and changelog for the profile catch-up (#390)
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>
2026-10-06 20:35:00 -04:00
le king fu
b0206ae56b fix(profiles): catch secondary profiles up on missed migrations (#390)
tauri-plugin-sql migrates only the database registered with add_migrations,
the default profile's. A secondary profile kept the consolidated schema of
the version that created it and never received a migration afterwards.

prepare_profile_db runs before every Database.load:
- secondary profiles catch up with the same migrations() list (pinned
  SHA-384), history kept in _sqlx_migrations; a database without history
  gets a floor inferred from its schema (the seven released creation
  states are test fixtures), so nothing at or below it is ever replayed;
- a VACUUM INTO copy is taken before the first write, synced, then renamed;
- each version runs in its own transaction with foreign keys on; one that
  fails on a foreign key is retried once without them and its orphans are
  set to NULL where the key declares ON DELETE SET NULL;
- every profile gets its TEXT manual-categorization flags repaired;
- failures are logged with file names only and the profile still opens.

New profiles are born with their full history (get_new_profile_init_sql).

The plugin keeps databases under app_config_dir(), not app_data_dir():
the same folder on Windows and macOS, not on Linux. repair_migrations,
delete_profile_db and compute_account_return looked in the wrong folder
there. profile_db_path resolves the plugin's folder and validates the
file name; deleting a profile now also removes its SQLite side files and
catch-up copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:00 -04:00
le king fu
f758282c2e fix(transactions): bind the manual-categorization flag as an integer (#390)
updateTransactionCategory bound the JS boolean `true`. tauri-plugin-sql
passes a bool to sqlx as a JSON value, so the column received the TEXT
'true', which the getting-started checklist's `is_manually_categorized = 1`
never matches: step 5 never ticked.

toSqlFlag turns a boolean, a number or a backup's string into 0/1. It is
used when a category is set by hand and when a JSON or CSV backup is
restored, since a backup exported while the bug stood carries "true"
(the CSV path read it as 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:00 -04:00
le king fu
8907eef5df state: sync after the v0.17.0 release
v0.17.0 is tagged on 07255e1 and published by release run 491: seven
artifacts and latest.json checked, the updater endpoint serves 0.17.0.
The update-cycle check was not due (nothing on the updater path changed
since v0.16.0) and the reason is written on the release. #234 is closed
on its fourth proof (actcache at 999 MB, one entry) with its milestone,
and the two suites-licence texts are posted on la-compagnie-maximus#184.
Rotate out the 2026-09-27 #270 decision to keep ten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:09:40 -04:00
le king fu
07255e1f30 chore: release v0.17.0
All checks were successful
Release / build-and-release (push) Successful in 21m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:36:51 -04:00
le king fu
8f964e096b state: sync after the suites-licence stack merge (#373, #370, #367, PRs #386-#388)
The autopilot stack of 2026-10-04 is merged fast-forward through the API,
main at 2cbc886, milestone planned-2026-10-03-suites-licence closed 3/3.
The seven review suggestions were applied on the stack head (2cbc886),
each proven by a red mutation. Record the decision, the manual checks due
with v0.17.0 (ordered so the activation token is destroyed last), #234
points 2 and 3 done, and the #389 follow-up. Rotate out the 2026-09-24
decision to keep ten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:25:31 -04:00
le king fu
2cbc886653 fix(license): apply the review follow-ups on the license stack head
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m47s
PR Check — Rust / rust (pull_request) Successful in 3m40s
Follow-ups of the three /pr-review passes on #386, #387 and #388, applied
on the stack head before the fast-forward merge:

- #386: pin the relay of the revoked state by revocationCheckLauncher
  (a launcher passing false instead of wasRevoked passed the whole suite),
  and say in the rate budget comment that two checks in one minute bring
  the machine-limit path to 6 requests.
- #387 S1: the fake `t` of the removal tests and of the License card tests
  marks what it translates, so a string that skipped `t` fails its test.
- #387 S2: pin that storing another key stops on Storage while the
  previous token cannot be dropped, the previous key staying.
- #387 S3: reload the license context after a failed removal too. Rust
  deletes the key first, so a token that resisted left the key gone while
  the card kept showing it under "the key was not removed".
- #388: pin that an unreadable token stays (the directory case could not
  tell, its deletion fails anyway).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:13:36 -04:00
le king fu
fd90de5424 docs(license): align the English tip and the price test notes with #367
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 3m22s
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>
2026-10-04 18:02:07 -04:00
le king fu
ed35570121 fix(license): drop an activation token that no longer stands for this machine
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m21s
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>
2026-10-04 17:58:25 -04:00
le king fu
e2f0fb86da feat(license): remove the stored license key from the License card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m26s
"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>
2026-10-04 17:14:36 -04:00
le king fu
0b94ccf006 test(license): poll the one-check-at-a-time tests in a fixed order
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 3m19s
The three tests of #373 run two branches with tokio::join! and rely on
the first branch being polled first: the check that starts first, or the
check that waits for a held turn before the time moves. tokio's join!
already starts with the first branch on its first poll, but rotates on
the next ones; `biased;` keeps the declared order on every poll and says
so in the code. The three mutations (no turn, time read before the turn,
guard dropped at once) were run again: same red tests.

Resolves #373

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:33:02 -04:00
le king fu
ab78aa9ddc fix(license): check a revoked key as soon as it is entered
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
PR Check — Rust / rust (pull_request) Successful in 3m25s
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>
2026-10-04 16:26:10 -04:00
le king fu
f25291eabb state: sync after #350 closed and #346 verified on the published v0.16.0
#350: the #365 check had stored the revoked key on the Linux machine,
which deleted the v0.15.0 activation token; a read-only local script
confirmed it. Re-entering the tester key and activating produced a token
carrying the derived identifier, so step 3 holds in production. Steps 1-2
(raw token still accepted) can no longer be checked there; Max closed the
issue on step 3 and the two Rust tests that pin raw-token acceptance.

#346: checked in the Free edition given by the revoked key ("I already
have a key" closes the profile modal on the License card), so #370's PR
now uses Fixes and no longer waits on it.

Rotate out the 2026-09-21 decision to keep ten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:48:03 -04:00
le king fu
c56f09a49b state: resync blockers after warmup (onboarding closed, suites-licence ready, #350 check at risk)
The 8c55e26 sync updated the header and decisions but left the Blockers
section at 2026-10-03. Bring it in line with Forgejo: onboarding phase 1
closed 7/7, #365 closed, #367/#370 ready in planned-2026-10-03-suites-licence,
maximus-api#62 and la-compagnie-maximus#150 closed, #317 absorbed by #358,
[Unreleased] carrying the onboarding entries.

Record that the #350 manual check is probably compromised on the Linux
machine: verifying #365 stored another key there, which deletes the
activation token, so the raw-ID token from v0.15.0 is likely gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:08:11 -04:00
le king fu
8c55e2670b state: sync after onboarding phase 1 merge (planned-2026-10-03-onboarding-phase1, PRs #377-#383)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 08:54:14 -04:00
le king fu
667e058a77 fix(onboarding): apply the three review follow-ups on the stack head
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m47s
- 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>
2026-10-04 08:47:30 -04:00
le king fu
d11337ccf6 docs(onboarding): changelog, architecture, user guide and interactive QA checklist (#357)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
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>
2026-10-03 20:25:55 -04:00
le king fu
624eef7ba8 feat(onboarding): Import empty states, sample folder button and guide links (#356)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
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>
2026-10-03 20:10:25 -04:00
le king fu
e0ee09bca7 feat(docs): sub-sections in the user guide and bank CSV export guides (#355)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m53s
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>
2026-10-03 20:00:09 -04:00
le king fu
e4a0f1042e feat(onboarding): create_sample_import_folders command, sample CSV templates and detection test (#354)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 3m38s
Add a Tauri command that writes two synthetic bank statements (Desjardins
chequing account, Tangerine credit card) into the import folder, one
sub-folder each, so a new user has sources to import on the first run.

The command takes the import folder and the webview's local date only
(chrono is compiled without `clock`), renders the `{{M1}}` / `{{M2}}`
tokens as the two complete months before that date, names each file
`releve-YYYY-MM.csv` after the most recent one, and refuses to run when
the folder is missing or when either sample folder already exists -
checked before anything is written. It returns the two folder names.

The templates live under `src/__fixtures__/samples/` so the Rust command
(`include_str!`) and vitest read the same bytes. The integration test
proves detection recognises each file as its bank with a signed single
amount and reads 40/40 rows, that the production `mapRow` parses every
row and sums to +740.00 / -368.46, and that the 180 seed keywords
categorise exactly 33 / 36 lines while leaving the hand-classified ones
untouched. The frozen detection corpus (`src/__fixtures__/csv`) is not
modified.

Five Rust tests cover the missing folder, the existing folder, the
success path, an unparseable date and the year boundary, each in its own
sandbox so they run in parallel.

CHANGELOG and architecture docs are centralised in #357.

Resolves #354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:50:41 -04:00
le king fu
8eac47f69b feat(onboarding): empty states on the Transactions and Budget pages (#353)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m51s
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>
2026-10-03 19:40:45 -04:00
le king fu
3f3531392e refactor(onboarding): name the checklist preference states after what they hold (#352)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:34:24 -04:00
le king fu
a060f0e0e0 feat(onboarding): dashboard empty state, getting-started checklist and re-show button (#352)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
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>
2026-10-03 19:32:43 -04:00
le king fu
76fd8d376a refactor(onboarding): compute the load error message once (#351)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
Self-review tidy: `errorMessage(e)` was evaluated twice in `refresh`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:23:43 -04:00
le king fu
f77ce94505 feat(onboarding): add transaction count, onboarding counters and checklist reducer (#351)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
Lay the data foundation of the getting-started checklist, without UI:

- transactionService.getTransactionCount(): unfiltered COUNT(*) of transactions
- onboardingService: getOnboardingCounters() reads the five aggregated counts,
  the import folder and the onboarding_checklist_hidden preference in one
  Promise.all; hideChecklist()/showChecklist() persist the preference and log
- utils/onboardingChecklist.computeChecklist(): pure computation of the six
  rows (profile, importFolder, source, import, manualCategory, budget), with
  the budget row locked and excluded from the total when the edition has no
  Budget
- hooks/useOnboardingChecklist: exported reducer + initial state, actions
  refresh/hide/show, composed with useEntitlement("budget"); a load error
  sets status "error" and keeps the hidden flag
- types OnboardingCounters, ChecklistItem, ChecklistItemKey

Tests cover every row in isolation, the budget gating (total 5 vs 6, complete
at 5/5 locked and 6/6 open), the aggregated query and preference reads, and
every reducer action. No DB migration, no CHANGELOG entry (centralised in #357).

Resolves #351

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:22:18 -04:00
le king fu
7a7fdf90e2 state: sync after #234 (PR #375 merged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:28:11 -04:00
le king fu
f821492274 ci: document the cache evictions and key inputs found in review (#234)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m45s
PR Check — Rust / rust (pull_request) Successful in 3m21s
Applies the three suggestions of the review of #375:

- The runner's cleanup keeps one entry per key, whatever its scope: a PR
  run that saves under the shared entry's key evicts it for every PR until
  the next Monday, and two PRs on the same non-main lockfile evict each
  other. Added to the list of cold runs.
- The cache key also hashes the job's CARGO*/RUST*/CC*/CFLAGS*/CXX*/CMAKE*
  variables, and ignores the app's own version: changing env: means a cold
  run until Monday, a release does not.
- A Monday restore that fails with a 404 after a quiet week is a known
  runner race (forgejo/runner#1741), not #234 coming back.

Refs #234

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:04:50 -04:00
le king fu
20db766f52 docs: describe the Rust cache, its weekly writer and the absent npm cache (#234)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
PR Check — Rust / rust (pull_request) Successful in 3m22s
CLAUDE.md and docs/architecture.md still said the Actions cache was
removed until #234 was fixed. They now describe rust-cache, the per-PR
isolation of what a pull_request run saves, the weekly run on main that
writes the shared cache, the expected cold runs, and why check-frontend
has no npm cache. The audit.yml paragraph no longer quotes the outdated
"1 PR in 40" figure.

Refs #234

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:22:08 -04:00
le king fu
cf1da2bea0 ci: restore the Rust cache with rust-cache and a weekly run on main (#234)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 9m37s
The runner's cache server is reachable again since the VPS fix of
2026-10-03 (cache proxy pinned to port 28100, ufw rule scoped to the
Docker job networks; diagnosed in #374). Bring caching back to check-rust
through Swatinem/rust-cache, as #232 deferred.

forgejo-runner v12.7.0 keeps what a pull_request run saves private to
that PR, so PR runs alone would only warm later pushes to the same PR. A
weekly scheduled run on main (Mondays 06:30 UTC) and workflow_dispatch
write the shared entries that every PR falls back to.

rust-cache v2.9+ declares node24 and the runner starts JavaScript actions
with the container's node, so the job now installs Node.js 24. zstd is
installed for faster, smaller cache archives.

No npm cache in check-frontend, by decision: with per-PR isolation it
would only serve later pushes of a PR, and npm ci takes 9s there. audit.yml
loses two comments this change makes false.

No CHANGELOG entry: CI configuration only.

Refs #234

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:09:00 -04:00
le king fu
0e4fef5ad7 state: record the v0.16.0 tests (#365 closed, #370, #372, #373)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:49 -04:00
le king fu
1923f8f70b state: sync after the v0.16.0 release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:22:30 -04:00
220 changed files with 31198 additions and 1281 deletions

View file

@ -1,7 +1,16 @@
---
paths: ["**/migrations/**", "**/*.sql", "**/lib.rs"]
paths: ["**/migrations/**", "**/*.sql", "**/lib.rs", "**/profile_db.rs"]
---
Ne JAMAIS modifier une migration SQL existante. Toujours creer une nouvelle migration.
Les checksums sont verifies au demarrage (SHA-384 dans `_sqlx_migrations`).
Les migrations sont inline dans `lib.rs` via `tauri_plugin_sql::Migration`.
Les checksums sont verifies au demarrage (SHA-384 dans `_sqlx_migrations`) et epingles par le test `migration_checksums_match_the_released_ones`.
Les migrations sont inline dans `lib.rs`, rendues par `migrations()` via `tauri_plugin_sql::Migration`.
Le schema consolide (`consolidated_schema.sql`) sert uniquement pour les nouveaux profils.
Depuis #390 (ADR 0021), chaque nouvelle migration atteint deux chemins :
- le profil par defaut, migre par tauri-plugin-sql (sqlx) ;
- les profils secondaires, rattrapes a l'ouverture par `database::profile_db` (rusqlite), cles etrangeres actives comme sous sqlx.
Une migration doit donc rester correcte sur les deux, et :
- tout changement de schema se reporte dans `consolidated_schema.sql` (le test des etats publies compare chaque profil rattrape a un profil neuf) ;
- toute correction de donnees (seed, mots-cles, categories) se reporte AUSSI dans `consolidated_schema.sql` : un nouveau profil nait avec l'historique complet et ne rejoue jamais les migrations ;
- ne pas ajouter de marqueur a `infer_floor` : il ne sert qu'aux bases creees avant #390.

View file

@ -2,7 +2,7 @@
name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true
updated: 2026-09-13
updated: 2026-10-11
---
# /release — Release Simpl-Resultat
@ -17,6 +17,7 @@ updated: 2026-09-13
0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
**Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag.
**Tableau des éditions du site.** Si `[Unreleased]` change la matrice des éditions (`ENTITLEMENTS` de `src/shared/entitlements.ts`, `FEATURE_TIERS` de `src-tauri/src/commands/entitlements.rs`, ou un module Free qui passe sous verrou, comme le fractionnement de #369) : la page produit du site en a une copie (`la-compagnie-maximus/src/data/editions.ts`, tableau comparatif et phrase `simplresultat_detail_desc`). Préparer la PR du site avant le tag en suivant la section « Matrice des éditions — Simpl-Résultat » de son `CLAUDE.md`, et ne la déployer qu'**après** la publication de la release : avant, le tableau promettrait un verrou absent des binaires.
1. Determiner la nouvelle version (argument utilisateur ou demander)
2. Bump version dans les 5 fichiers :
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
@ -25,6 +26,12 @@ updated: 2026-09-13
- `package.json` (champ `"version"`)
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
- Puis rejouer le garde `windows-sys` de `check-rust.yml` sur le lock édité : le commit de release touche `Cargo.lock` directement sur `main`, sans PR, et `check-rust` ne le verrait qu'au lundi suivant, après le build du tag ([ADR 0023](../../../docs/adr/0023-windows-sys-pinning.md)). Depuis la racine du dépôt, le texte exact de l'étape avec les options du runner :
```bash
python3 -c 'import sys, yaml; steps = yaml.safe_load(open(".forgejo/workflows/check-rust.yml"))["jobs"]["rust"]["steps"]; sys.stdout.write(next(s["run"] for s in steps if s.get("name") == "Verify windows-sys pinning"))' \
| bash --noprofile --norc -e -o pipefail
```
Attendu : code de sortie 0 et dernière ligne `windows-sys pinning: N checks, exit 0`. Une ligne `FAIL:` arrête la release : suivre la procédure de l'ADR 0023 avant de tagger.
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
- `CHANGELOG.md` (EN) : header `## [Unreleased]`
- `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide)
@ -43,9 +50,21 @@ updated: 2026-09-13
```bash
bash .claude/skills/release/scripts/verify-release.sh vX.Y.Z
```
Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie : `version`, `pub_date`, signature non vide et URL contenant la version pour `linux-x86_64` et `windows-x86_64`, notes non vides. Toute ligne `MANQUANT`, `VIDE` ou `MAUVAISE VERSION` bloque l'étape 10.
Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie `version` (il affiche `pub_date` et la longueur des notes), puis exige **exactement trois clés** (#320) : `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`, sans clé générique `linux-x86_64` depuis le 2026-10-11 (`docs/qa-update-cycle.md`). Pour chacune : l'extension de l'URL (`.deb`, `.rpm`, `.exe`), une signature non vide dont le `file:` (commentaire de confiance minisign, signature décodée du base64) est le nom de fichier de l'URL, décodé, et la version dans l'URL. **Il sort en 1 sur tout écart** (`MANQUANT`, `VIDE`, `MAUVAISE VERSION`, `MAUVAISE EXTENSION`, `DIFFÉRENT`, `INATTENDUE`) et finit par `VERDICT : OK` ou `VERDICT : ÉCHEC` : pas d'annonce de la release ni d'étape 10 avant un verdict OK. Un second argument (`verify-release.sh vX.Y.Z fichier.json`) contrôle un `latest.json` local au lieu de l'asset, pour éprouver les contrôles sur un fichier fabriqué.
**Lire aussi l'étape « Generate latest.json » du journal du run** (même route web que ci-dessous) : `scripts/build-latest-json.sh` y nomme le paquet retenu pour chaque type (`build-latest-json: .rpm <- …`), puis le `latest.json` généré est affiché. Le script, le filtre `scripts/check-latest-json.jq` et le texte de l'étape « Publish » (avec un `curl` factice) passent par les tests de `check-frontend` à chaque PR ; l'appel du script par l'étape « Generate latest.json », le délimiteur aléatoire des notes et les vrais appels au registre ne s'exercent qu'au tag. Un manifeste refusé par le filtre arrête le job sur `ERROR: release-assets/latest.json failed scripts/check-latest-json.jq`, avant le `DELETE` : registre intact, et la release Forgejo reste créée sans mise à jour servie.
Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose.
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
**Lire aussi le Node.js du run.** `release.yml` n'est exercé qu'au tag : une montée de Node (Node 20 → 24 par #376) ne se prouve que dans ce journal. Il passe par la route web (l'API v1 des journaux répond 404). Le `run_number` vient de `/actions/tasks` : sans paramètre `page`, la route rend toute la liste, la plus récente en tête, et ignore `limit` (avec `page`, elle pagine par 30 ou par `limit`, mesuré le 2026-10-12) :
```bash
TAG=vX.Y.Z
RUN=$(curl -s -H "Authorization: token $(cat ~/.forgejo-token)" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat/actions/tasks" \
| python3 -c "import json,sys; print(next(t['run_number'] for t in json.load(sys.stdin)['workflow_runs'] if t['workflow_id']=='release.yml' and t['head_branch']==sys.argv[1]))" "$TAG")
curl -sL -H "Authorization: token $(cat ~/.forgejo-token)" \
"https://git.lacompagniemaximus.com/maximus/Simpl-Resultat/actions/runs/$RUN/jobs/0/logs" \
| grep -m1 -E 'Z v[0-9]+\.[0-9]+\.[0-9]+\s*$'
```
Attendu : `v24.x`. C'est la sortie de `node --version` dans l'étape « Install base tools and runtimes », seule ligne de ce format dans le journal (la v0.17.1, run 508, affichait `v20.20.2`). Toute autre majeure est à expliquer avant l'étape 10.
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
Inspecter `latest.json` prouve qu'il est **bien formé**, pas qu'il **installe**. Le reste de la chaîne — téléchargement TLS, vérification de signature, exécution de l'installeur, redémarrage — n'est exercé par aucun test : `cargo check`/`cargo test` prouvent que ce code compile, rien de plus. Une régression s'y voit chez l'utilisateur, au moment de la mise à jour.
@ -69,3 +88,9 @@ updated: 2026-09-13
- 2026-07-27 — Étape 10 (post-CI) : dérouler un vrai cycle de mise à jour via `docs/qa-update-cycle.md` quand la release touche l'updater. Inspecter `latest.json` prouve qu'il est bien formé, pas qu'il installe : le téléchargement TLS, la vérification de signature, l'exécution de l'installeur et le redémarrage ne sont exercés par aucun test. Deux pièges qui font cocher sans vérifier — tester depuis la machine déjà à jour (`check()` répond « à jour ») et tester sans clé Base+ (auto-update verrouillé par édition, `notEntitled` avant toute requête). Source : session 50ac88d9 (#315, découvert en traitant les advisories `tar`/`rustls-webpki` de #310).
- 2026-09-13 — Étape 0 (pré-vol) : cross-référence vers `/issue-license` quand la release modifie `FEATURE_TIERS` ou verrouille une fonctionnalité Free — émettre et distribuer les clés testeurs avant de tagger, sinon ils perdent l'auto-update. Source : session 690a6129 via /skills-review-inbox (draft 2026-09-06).
- 2026-09-13 — Étape 0 : prérequis « clés testeurs saisies avant le tag » précisé (déclencheurs `FEATURE_TIERS`, gating, `auto-update` ; cas réel v0.15.0, deux clés Premium émises le jour même). Étape 9 : script `scripts/verify-release.sh` (7 artefacts par suffixe + contenu de `latest.json`) au lieu d'un script réécrit à chaque release ; durée de run observée 22-30 min. Source : session 690a6129 (release v0.15.0, run 402) via /skills-review-inbox (draft 2026-09-12).
- 2026-10-10 — Étape 9 (post-CI) : lire `node --version` dans le journal du run de release (route web, seule ligne `vX.Y.Z` du journal). `release.yml` n'est exercé qu'au tag, donc la montée Node 20 → 24 de #376 ne se prouve qu'à la release suivante. Source : caveat de planification de #376 (milestone `planned-2026-10-10-release-deps`).
- 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`).
- 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416.
- 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415.
- 2026-10-16 — Étape 0 : report d'un changement de matrice des éditions vers le tableau comparatif du site (`la-compagnie-maximus/src/data/editions.ts`), PR du site préparée avant le tag et déployée après la publication. Source : la-compagnie-maximus#180.
- 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418).

View file

@ -1,17 +1,24 @@
#!/usr/bin/env bash
# Step 9 of /release: verify the published Forgejo release for a tag —
# the 7 expected assets and the content of latest.json (drives auto-update).
# Exits 1 when any check fails (#320), with a VERDICT as last line.
#
# Usage: verify-release.sh vX.Y.Z [latest.json]
# The optional second argument checks that local file instead of the
# release's latest.json asset, to prove the checks on crafted files.
set -uo pipefail
TAG="${1:?usage: verify-release.sh vX.Y.Z}"
TAG="${1:?usage: verify-release.sh vX.Y.Z [latest.json]}"
LOCAL_LATEST="${2:-}"
VERSION="${TAG#v}"
TOKEN=$(cat ~/.forgejo-token)
API="https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
WORK="${TMPDIR:-/tmp}/verify-release-${TAG}"; mkdir -p "$WORK"
OUT="$WORK/latest.json"
STATUS=0
echo "=== release ${TAG} ==="
timeout 20 curl -s -H "Authorization: token $TOKEN" "${API}/releases/tags/${TAG}" > "$WORK/release.json"
python3 - "$WORK/release.json" "$VERSION" <<'EOF'
python3 - "$WORK/release.json" "$VERSION" <<'EOF' || STATUS=1
import sys, json
r = json.load(open(sys.argv[1])); version = sys.argv[2]
if "id" not in r:
@ -34,19 +41,73 @@ for n in names:
missing = [k for k, v in kinds.items() if v == 0]
print("couverture:", kinds, "| MANQUANT:" if missing else "| complet", missing if missing else "")
open(sys.argv[1] + ".latest-url", "w").write(names.get("latest.json") or "")
sys.exit(1 if missing else 0)
EOF
URL=$(cat "$WORK/release.json.latest-url" 2>/dev/null)
if [ -z "$URL" ]; then echo "latest.json absent — arrêt"; exit 1; fi
echo; echo "=== latest.json ($URL) ==="
timeout 20 curl -sL "$URL" > "$OUT"
python3 - "$OUT" "$VERSION" <<'EOF'
import sys, json
if [ -n "$LOCAL_LATEST" ]; then
OUT="$LOCAL_LATEST"
echo; echo "=== latest.json (fichier local $OUT, pas l'asset de la release) ==="
else
URL=$(cat "$WORK/release.json.latest-url" 2>/dev/null)
if [ -z "$URL" ]; then echo "latest.json absent — arrêt"; echo "VERDICT : ÉCHEC"; exit 1; fi
echo; echo "=== latest.json ($URL) ==="
timeout 20 curl -sL "$URL" > "$OUT"
fi
python3 - "$OUT" "$VERSION" <<'EOF' || STATUS=1
import sys, json, base64, binascii
from urllib.parse import unquote, urlsplit
j = json.load(open(sys.argv[1])); version = sys.argv[2]
ok = True
v = j.get("version"); pub = j.get("pub_date"); notes = j.get("notes", "")
print(f"version={v} {'OK' if v == version else 'MAUVAISE VERSION'} | pub_date={pub}")
ok &= v == version
print(f"notes: {len(notes)} caractères — début: {notes[:120]!r}")
for plat, p in j.get("platforms", {}).items():
sig = p.get("signature", ""); url = p.get("url", "")
print(f" {plat}: url={url.rsplit('/', 1)[-1]} | signature={'OK (' + str(len(sig)) + ' car.)' if sig else 'VIDE'} | url contient version={'oui' if version in url else 'NON'}")
# The three keys (#320): tauri-plugin-updater looks up "{os}-{arch}-{installer}",
# then "{os}-{arch}". Each with the extension its URL must end with. No generic
# linux-x86_64 since 2026-10-11, so a manifest that still has it fails here as
# INATTENDUE. Same set as scripts/build-latest-json.sh writes and
# scripts/check-latest-json.jq checks: scripts/build-latest-json.test.mjs reads
# this line and compares.
EXPECTED = {"linux-x86_64-deb": ".deb", "linux-x86_64-rpm": ".rpm",
"windows-x86_64": ".exe"}
def signed_file(sig):
"""The file: field of the trusted comment, in the base64 minisign signature."""
try:
text = base64.b64decode(sig, validate=True).decode("utf-8")
except (binascii.Error, ValueError):
return None
for line in text.splitlines():
if line.startswith("trusted comment: ") and "\tfile:" in line:
return line.split("\tfile:", 1)[1]
return None
platforms = j.get("platforms", {})
for plat in sorted(set(platforms) | set(EXPECTED)):
if plat not in platforms:
print(f" {plat}: MANQUANT"); ok = False; continue
if plat not in EXPECTED:
print(f" {plat}: clé INATTENDUE"); ok = False; continue
p = platforms[plat]
sig = p.get("signature") or ""; url = p.get("url") or ""
name = unquote(urlsplit(url).path.rsplit("/", 1)[-1]) # file name, decoded
ext = EXPECTED[plat]; signed = signed_file(sig) if sig else None
checks = {
"extension": name.endswith(ext),
"signature": bool(sig),
"file:": signed == name,
"version": version in url,
}
ok &= all(checks.values())
print(f" {plat}: url={name}"
f" | extension {ext} {'OK' if checks['extension'] else 'MAUVAISE EXTENSION'}"
f" | signature {'OK (' + str(len(sig)) + ' car.)' if sig else 'VIDE'}"
f" | file: {'OK' if checks['file:'] else 'DIFFÉRENT (' + repr(signed) + ')'}"
f" | url contient version={'oui' if checks['version'] else 'NON'}")
sys.exit(0 if ok else 1)
EOF
echo
if [ "$STATUS" -eq 0 ]; then echo "VERDICT : OK"; else echo "VERDICT : ÉCHEC"; fi
exit "$STATUS"

View file

@ -2,14 +2,14 @@ name: Security audit
# Daily RustSec coverage (#232).
#
# check-rust.yml only runs when src-tauri/ changes, which is roughly 1 PR in
# 40 — without this workflow a new advisory published against an unchanged
# dependency would go unnoticed for weeks.
# check-rust.yml runs on PRs that change src-tauri/ and once a week on main,
# and its cargo audit step never fails the job — without this workflow a new
# advisory published against an unchanged dependency would go unnoticed.
#
# Runs without a Rust toolchain: cargo-audit only reads Cargo.lock, so the
# binary is invoked directly instead of as a `cargo` subcommand. That keeps
# this to ~1-2 min rather than the ~22 min a scheduled check-rust would cost
# every day on a capacity-1 runner.
# this to ~1 min, where a daily check-rust would hold the capacity-1 runner
# for several minutes.
#
# Note: PATH is deliberately NOT overridden at job level (unlike check-rust,
# which needs /root/.cargo/bin for the toolchain) so that the install dir
@ -27,6 +27,10 @@ on:
- cron: '0 6 * * *'
workflow_dispatch:
# What this key does on Forgejo 8.0.3: see the comment on `concurrency` in
# check-rust.yml (#376). That measurement covers push and pull_request runs;
# this workflow only runs on schedule and workflow_dispatch, which it does
# not cover.
concurrency:
group: ci-audit-${{ github.ref }}
cancel-in-progress: true
@ -39,14 +43,15 @@ jobs:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
- name: Install Node.js 24
run: |
apt-get update
apt-get install -y --no-install-recommends \
curl ca-certificates git tar gzip
# Node.js is required by actions/checkout and taiki-e/install-action
# (JavaScript actions need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
# Node.js is required by actions/checkout, a JavaScript action that
# needs `node` in the container PATH (taiki-e/install-action is a
# composite action).
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
node --version

View file

@ -7,6 +7,11 @@ name: PR Check — Frontend
# running it is not. A root-level config file added later would drop out of an
# allowlist without anyone noticing; here it fails safe.
#
# .claude/** and release.yml are not ignored (#419): scripts/build-latest-json.test.mjs
# reads .claude/skills/release/scripts/verify-release.sh and runs the text of
# release.yml's "Publish latest.json to package registry" step, so a PR that
# touches only one of them must still run it.
#
# No `branches:` filter — see check-rust.yml.
on:
@ -18,14 +23,14 @@ on:
- 'reports/**'
- 'tasks/**'
- '.github/**'
- '.claude/**'
- '.forgejo/workflows/check-rust.yml'
- '.forgejo/workflows/audit.yml'
- '.forgejo/workflows/release.yml'
- '*.md'
- 'LICENSE'
# Distinct group from the rust workflow — see check-rust.yml.
# Same concurrency setup as check-rust.yml (own group, cancel-in-progress),
# whose comment says what it does on Forgejo 8.0.3 (#376) and why each
# workflow has its own group.
concurrency:
group: ci-frontend-${{ github.ref }}
cancel-in-progress: true
@ -38,22 +43,26 @@ jobs:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
- name: Install Node.js 24
run: |
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates git
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
# jq: scripts/build-latest-json.test.mjs runs the script that writes
# the release's latest.json, and that script needs it (#320).
apt-get install -y --no-install-recommends curl ca-certificates git jq
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
node --version
npm --version
jq --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
# No npm cache step here, deliberately — same reason as the cargo caches
# in check-rust.yml: the restore misses and the save times out against
# the runner's cache server (#234), which cost ~23s per run for nothing.
# Restore it together with rust-cache once #234 is fixed.
# No npm cache step, by decision (#234). The runner's cache works since
# 2026-10-03, but it keeps what a pull_request run saves private to that
# PR, and this workflow runs on PRs only: a cache would serve later
# pushes to the same PR, while `npm ci` takes 9s here (run 456) and
# each PR's first run would pay for the save.
- name: Install dependencies
run: npm ci

View file

@ -2,9 +2,10 @@ name: PR Check — Rust
# Rust half of the former check.yml (split in #232).
#
# Only runs when Rust actually changes. On this repo roughly 1 PR in 40 touches
# src-tauri/, and the runner has capacity 1 — jobs queue instead of running in
# parallel, so every minute spent here is a minute the next PR waits.
# Runs on PRs that change Rust, plus once a week on main to refresh the shared
# cache (see the note on the triggers). The runner has capacity 1 — jobs queue
# instead of running in parallel, so every minute spent here is a minute the
# next PR waits.
#
# No `branches:` filter on purpose. `branches: [main]` never matched a PR
# stacked on top of another feature branch, which is what /autopilot produces:
@ -19,10 +20,53 @@ on:
# (rustflags, linker, target dir) would change the Rust build, and an
# exact-path entry would let it skip Rust CI unnoticed.
- '.cargo/**'
# The weekly run is what feeds the cache to pull requests (#234). The runner
# (forgejo-runner v12.7.0) isolates what a pull_request run writes to that
# PR's ref: later pushes to the same PR read it back, other PRs never see it.
# A PR run falls back to the shared, non-isolated entries only when nothing
# matches in its own scope. Scheduled and dispatched runs write to the shared
# scope, which makes them this workflow's only writers of it (a merged PR's
# `closed` event would be another, but this workflow does not subscribe to
# it).
#
# The runner deletes an entry unused for 7 days or created more than 30 days
# ago, and keeps only the newest entry per key, whatever its scope; the next
# Monday run saves a fresh one. Cold runs that do not mean #234 is back (a
# timeout would):
# - a new stable Rust (the toolchain floats on `stable`) or a change to this
# job's `env:`: the key hashes rustc and the CARGO*, RUST*, CC*, CFLAGS*,
# CXX* and CMAKE* variables. Cold until the next Monday;
# - a PR run that saves under the shared entry's key evicts it for every PR
# until the next Monday. It saves when its own scope holds an older entry
# for another Cargo.lock, served before the shared one: a PR that bumped a
# dependency, then went back to main's lockfile. Two open PRs on the same
# lockfile, different from main's, evict each other the same way;
# - PR runs once an entry that only got exact hits, hence never re-saved,
# reaches 30 days;
# - after a week without any cache request, the Monday restore can fail with
# a 404: the lookup does not refresh the entry, and the cleanup it triggers
# may delete it (forgejo/runner#1741). The run then saves a fresh entry.
# workflow_dispatch re-seeds by hand.
schedule:
# Mondays 06:30 UTC, after audit.yml (06:00 UTC).
- cron: '30 6 * * 1'
workflow_dispatch:
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
# Distinct from the frontend group: a shared group would make the two
# workflows cancel each other on a PR that touches both.
# What this key does on Forgejo 8.0.3 / runner v12.7.0, as measured on
# 2026-10-02 (la-compagnie-maximus docs/forgejo-ops.md, sections "Le schéma du
# workflow est validé par le runner" and "Un push annule le run du push
# précédent"):
# - it passes the runner's schema validation, but what it adds has not been
# isolated;
# - without it, Forgejo cancels a `push` run when a newer one of the same
# workflow is created on the same ref, whether the older run's job was still
# queued or already running;
# - no pull_request (or pull_request_sync) job has been cancelled by a later
# run: on a PR, a force-push does not cancel the obsolete run.
# Kept anyway (#376): harmless here, and it keeps its GitHub Actions meaning
# should these workflows run where it is honoured (GitHub, a later Forgejo).
# There, a group shared with the frontend workflow would make the two cancel
# each other on a PR that touches both, hence the distinct group.
concurrency:
group: ci-rust-${{ github.ref }}
cancel-in-progress: true
@ -37,22 +81,27 @@ jobs:
env:
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CARGO_TERM_COLOR: always
# Nothing persists between runs (see the caching note below), so
# incremental artifacts get written and never reused — pure overhead.
# Test debug info is dead weight here for the same reason.
# rust-cache keeps the dependencies and drops this crate's own artifacts
# before saving, so incremental data would never be reused — pure
# overhead — and test debug info would only make the cached target/
# bigger.
CARGO_INCREMENTAL: 0
CARGO_PROFILE_TEST_DEBUG: 0
steps:
- name: Install system dependencies, Node.js and Rust
run: |
apt-get update
# zstd: the cache archives are compressed with it when present,
# faster and smaller than the gzip fallback.
apt-get install -y --no-install-recommends \
curl wget git ca-certificates build-essential pkg-config \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \
libdbus-1-dev
# Node.js is required by actions/checkout and taiki-e/install-action
# (they are JavaScript actions and need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
libdbus-1-dev zstd
# Node.js runs the JavaScript actions: actions/checkout (node20) and
# Swatinem/rust-cache (node24 since v2.9). The runner starts them
# with whatever `node` the container has, so install the newest
# runtime they declare. taiki-e/install-action is a composite action.
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
@ -63,11 +112,19 @@ jobs:
- name: Checkout
uses: https://github.com/actions/checkout@v4
# No actions/cache step here, deliberately. The job container cannot
# reach the runner's cache server (#234): the restore times out into a
# miss AND the save times out, so the cache cost ~7 min per run and
# returned nothing. Bring caching back through Swatinem/rust-cache once
# #234 is fixed — not before, it shares the same backend.
# Caches ~/.cargo (registry, git, bin) and src-tauri/target, keyed on the
# rustc version, the job's CARGO*/RUST* environment, the dependencies
# declared in the Cargo manifests and lockfile (not the app's own
# version), and this job. Saved only when the job succeeds and the key
# was not an exact hit. Served by
# the runner's cache proxy on port 28100, which ufw opens to the Docker
# job networks only; until 2026-10-03 every restore and save timed out
# there (#234, la-compagnie-maximus docs/forgejo-ops.md). What a PR run
# saves stays private to that PR: see the note on the triggers.
- name: Rust cache
uses: https://github.com/Swatinem/rust-cache@v2
with:
workspaces: "src-tauri -> target"
- name: cargo check
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
@ -120,19 +177,159 @@ jobs:
checks=$((checks + 1))
done
done
# Canary: a crate known to be present. If this stops being found, the
# loop above is broken and its silence means nothing.
# Canary: a crate known to be present on the Linux target, and only
# there. If this stops being found, the loop above is broken and its
# silence means nothing. webkit2gtk is Tauri's Linux webview (wry): a
# Tauri v2 Linux build cannot drop it, and as a Linux-only dependency
# it also proves that --target resolves target-conditional edges, which
# the per-target check above relies on. It replaced tar, a Linux-only
# dependency of the updater until #455 built it without its zip
# feature.
canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \
-i tar --target x86_64-unknown-linux-gnu 2>/dev/null) || true
-i webkit2gtk --target x86_64-unknown-linux-gnu 2>/dev/null) || true
if [ -z "$canary" ]; then
echo "FAIL: canary 'tar' was not found although it is a known dependency. The guard is not proving anything."
echo "FAIL: canary 'webkit2gtk' was not found although it is a known dependency. The guard is not proving anything."
rc=1
else
echo "ok: canary 'tar' found for x86_64-unknown-linux-gnu"
echo "ok: canary 'webkit2gtk' found for x86_64-unknown-linux-gnu"
fi
echo "Suppression guard: $checks checks, exit $rc"
exit $rc
# Pin guard for windows-sys (#345). Seven crates in the lockfile accept
# windows-sys through a wide bound, so cargo may point each of them at
# any version the graph already holds. All seven point at 0.61.2, the
# state every Windows release has shipped with since v0.14.0. An update
# such as `cargo update -p hyper` re-resolves the seven edges down to
# 0.48/0.52/0.59, and cargo reports only the packages it bumped, never
# the edges: the Windows build then compiles five windows-sys versions
# instead of three, and nothing says so. Which updates do it cannot be
# told from their names (#348), so the step guards the resolved version.
# docs/adr/0023-windows-sys-pinning.md holds the measurements, the exit
# condition and what to do when this step fails.
#
# awk reads Cargo.lock: mawk is part of the ubuntu:22.04 base image,
# while python3 is only here as a transitive dependency of the apt
# packages installed above (run 517), which nothing guarantees. Its exit
# code is tested apart from its output: an empty output already fails the
# checks below (version set, missing blocks, canary), but an awk that
# fails after printing every fact would pass without that test. As in the
# step above, every check echoes its result, including the passing ones,
# and a canary must be read and checked, or the loop's silence proves
# nothing.
#
# No cargo call, so neither the warm index nor --locked matters here: the
# step reads the lockfile as cargo check left it, which is the committed
# one unless cargo had to update it.
- name: Verify windows-sys pinning
run: |
set -u
# The guarded list. Why 0.61.2, why these versions, and when either
# may change: docs/adr/0023-windows-sys-pinning.md. Edit CRATES, PIN
# or EXPECTED only on the proof that ADR asks for, never to turn this
# step green.
CRATES="colored dirs-sys errno rustix rustls-platform-verifier tempfile winapi-util"
PIN="0.61.2"
# Every windows-sys version in the lockfile, all targets included,
# as of 2026-10-10.
EXPECTED="0.45.0 0.48.0 0.52.0 0.59.0 0.60.2 0.61.2"
CANARY="tempfile"
LOCK="src-tauri/Cargo.lock"
ADR="docs/adr/0023-windows-sys-pinning.md"
rc=0
checks=0
canary_checked=0
# One fact per line: "wsys <version>" for each windows-sys package,
# and "block <crate> <version> [<target>...]" for each [[package]]
# block of a crate in CRATES, with one <target> per windows-sys
# edge: the version the edge names, or "-" for a bare edge (cargo
# drops the version when the lockfile holds a single windows-sys).
# A " (registry+...)" source suffix on an edge is dropped.
facts=$(awk -v crates="$CRATES" '
function flush( i, line) {
if (inpkg && name == "windows-sys") print "wsys", version
if (inpkg && (name in wanted)) {
line = "block " name " " version
for (i = 1; i <= n; i++) line = line " " target[i]
print line
}
inpkg = 0; name = ""; version = ""; n = 0
}
BEGIN { k = split(crates, list, " "); for (i = 1; i <= k; i++) wanted[list[i]] = 1 }
/^\[\[package\]\]$/ { flush(); inpkg = 1; next }
/^\[/ { flush(); next }
inpkg && /^name = "/ { name = $3; gsub(/"/, "", name) }
inpkg && /^version = "/ { version = $3; gsub(/"/, "", version) }
inpkg && /^[ \t]*"windows-sys[ "]/ {
s = $0; sub(/^[ \t]*"/, "", s); sub(/".*$/, "", s)
m = split(s, f, " "); target[++n] = (m >= 2 ? f[2] : "-")
}
END { flush() }
' "$LOCK") && awk_rc=0 || awk_rc=$?
if [ "$awk_rc" -ne 0 ]; then
echo "FAIL: awk exited $awk_rc reading $LOCK, so nothing was checked."
echo "windows-sys pinning: $checks checks, exit 1"
exit 1
fi
# The exact set of windows-sys versions, duplicates kept: a version
# that appears or disappears is a decision, not a side effect.
found=$(printf '%s\n' "$facts" | awk '$1 == "wsys" { print $2 }' | sort | paste -sd ' ' -)
want=$(printf '%s\n' $EXPECTED | sort | paste -sd ' ' -)
checks=$((checks + 1))
if [ "$found" = "$want" ]; then
echo "ok: windows-sys versions in the lockfile: $found"
else
echo "FAIL: windows-sys versions in the lockfile are \"$found\", expected exactly \"$want\". A version was added or removed: follow $ADR before editing EXPECTED."
rc=1
fi
# Every edge of every block of each guarded crate must name PIN. A
# bare edge passes only when the lockfile's one windows-sys is PIN.
for crate in $CRATES; do
blocks=$(printf '%s\n' "$facts" | awk -v c="$crate" '$1 == "block" && $2 == c')
if [ -z "$blocks" ]; then
checks=$((checks + 1))
echo "FAIL: no [[package]] block named $crate in $LOCK (renamed, or gone from the graph?). See $ADR."
rc=1
continue
fi
while read -r kind name version targets; do
if [ -z "$targets" ]; then
checks=$((checks + 1))
echo "FAIL: $crate $version has no windows-sys edge left. See $ADR."
rc=1
continue
fi
for target in $targets; do
checks=$((checks + 1))
if [ "$target" = "$PIN" ]; then
echo "ok: $crate $version -> windows-sys $PIN"
elif [ "$target" != "-" ]; then
echo "FAIL: $crate $version -> windows-sys $target, pinned at $PIN. See $ADR."
rc=1
elif [ "$found" = "$PIN" ]; then
echo "ok: $crate $version -> windows-sys (bare edge: the lockfile's only windows-sys is $PIN)"
else
echo "FAIL: $crate $version -> windows-sys with no version, while the lockfile holds \"$found\". See $ADR."
rc=1
fi
if [ "$crate" = "$CANARY" ]; then
canary_checked=1
fi
done
done <<<"$blocks"
done
# Canary: the loop must have read and checked an edge of a crate
# known to carry one. Otherwise its silence proves nothing.
checks=$((checks + 1))
if [ "$canary_checked" -eq 1 ]; then
echo "ok: canary '$CANARY' found, its windows-sys edge read and checked above"
else
echo "FAIL: canary '$CANARY' was not read and checked. The guard is not proving anything."
rc=1
fi
echo "windows-sys pinning: $checks checks, exit $rc"
exit $rc
- name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets

View file

@ -16,8 +16,14 @@ jobs:
run: |
apt-get update
apt-get install -y curl wget git sudo ca-certificates gnupg
# Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
# Install Node.js 24 (#376). Trust note, kept on purpose without a
# pinned tarball: NodeSource's script runs as root in this
# container, and the Node.js it installs then runs `npx tauri build`
# with TAURI_SIGNING_PRIVATE_KEY in its environment. A compromised
# script or package would reach the updater signing key, and the
# publishing tokens of the later steps. See docs/architecture.md
# (CI/CD, release.yml).
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
@ -81,59 +87,31 @@ jobs:
NOTES=$(sed -n "/^## ${TAG}/,/^## /{/^## ${TAG}/d;/^## /d;p}" CHANGELOG.md)
fi
NOTES=$(echo "$NOTES" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba}')
# Random delimiter (#320): a changelog line equal to a fixed one
# would end the value there and turn the lines after it into step
# outputs.
DELIM="CHANGELOG_EOF_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
{
echo "notes<<CHANGELOG_EOF"
echo "notes<<${DELIM}"
echo "$NOTES"
echo "CHANGELOG_EOF"
echo "${DELIM}"
} >> "$GITHUB_OUTPUT"
- name: Generate latest.json
env:
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
run: |
TAG="${GITHUB_REF_NAME}"
VERSION="${GITHUB_REF_NAME#v}"
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"
LINUX_SIG=""
LINUX_DEB=""
WINDOWS_SIG=""
WINDOWS_EXE=""
for f in release-assets/*.deb.sig; do
[ -f "$f" ] && LINUX_SIG=$(cat "$f")
done
for f in release-assets/*.deb; do
[ -f "$f" ] && LINUX_DEB=$(basename "$f")
done
for f in release-assets/*-setup.exe.sig; do
[ -f "$f" ] && WINDOWS_SIG=$(cat "$f")
done
for f in release-assets/*-setup.exe; do
[ -f "$f" ] && WINDOWS_EXE=$(basename "$f")
done
PLATFORMS="{}"
if [ -n "$LINUX_SIG" ] && [ -n "$LINUX_DEB" ]; then
PLATFORMS=$(echo "$PLATFORMS" | jq \
--arg sig "$LINUX_SIG" \
--arg url "${BASE_URL}/${LINUX_DEB}" \
'. + {"linux-x86_64": {"signature": $sig, "url": $url}}')
fi
if [ -n "$WINDOWS_SIG" ] && [ -n "$WINDOWS_EXE" ]; then
PLATFORMS=$(echo "$PLATFORMS" | jq \
--arg sig "$WINDOWS_SIG" \
--arg url "${BASE_URL}/${WINDOWS_EXE}" \
'. + {"windows-x86_64": {"signature": $sig, "url": $url}}')
fi
jq -n \
--arg version "$VERSION" \
--arg notes "$CHANGELOG_NOTES" \
--arg pub_date "$PUB_DATE" \
--argjson platforms "$PLATFORMS" \
'{version: $version, notes: $notes, pub_date: $pub_date, platforms: $platforms}' \
# Three keys: one per Linux package (.deb, .rpm) and the NSIS
# installer, no generic linux-x86_64 key (#320); fails unless it finds
# exactly one .deb, .rpm and -setup.exe, each with a non-empty .sig.
# Notes reach jq as a file, as data.
# Called as scripts/build-latest-json.test.mjs calls it.
printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md
bash -e -o pipefail scripts/build-latest-json.sh \
release-assets \
"${GITHUB_REF_NAME#v}" \
/tmp/release-notes.md \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}" \
> release-assets/latest.json
echo "Generated latest.json:"
@ -155,7 +133,7 @@ jobs:
## Installation
**Windows** : Téléchargez le fichier \`.exe\` ci-dessous.
**Linux** : Téléchargez le fichier \`.deb\` ou \`.AppImage\` ci-dessous."
**Linux** : Téléchargez le fichier \`.deb\` (Debian, Ubuntu) ou \`.rpm\` (Fedora, openSUSE) ci-dessous."
# Delete existing release for this tag (if any) to allow re-creation
EXISTING=$(curl -s "${API_URL}/repos/${REPO}/releases/tags/${TAG}" \
@ -214,6 +192,22 @@ jobs:
env:
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
# This text is run by scripts/build-latest-json.test.mjs, with a fake
# curl: keep it self-contained, no workflow expressions (dollar,
# double brace) in run:, pass values through env: (#419).
#
# Check the manifest before the DELETE below (#320): once the old
# version is deleted, installed apps find no update until a valid
# one is uploaded. A failed check stops the job, registry untouched.
# The filter lives in scripts/check-latest-json.jq; -s makes it
# refuse an empty file or one with two documents (#419).
if ! jq -e -s --arg version "${GITHUB_REF_NAME#v}" \
-f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then
echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:"
cat release-assets/latest.json
exit 1
fi
# DELETE uses API v1, PUT uses the package upload API
DELETE_URL="${GITHUB_SERVER_URL}/api/v1/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
UPLOAD_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"

View file

@ -2,6 +2,82 @@
## [Non publié]
## [0.17.2] - 2026-10-18
### Ajouté
- Import : un avertissement nomme les fichiers CSV posés directement dans le dossier d'import, qui sont ignorés (#427).
- Parcours de démarrage : deux étapes de visite, « Parcourir les catégories et les mots-clés » (avant le classement) et « Parcourir les rapports » (en dernier), cochées par « J'ai compris » ; un profil qui avait terminé le parcours les trouve déjà faites ; leurs cartes, sur les pages Catégories et Rapports, expliquent les mots-clés et le hub des rapports (#425, #432).
### Modifié
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
- Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422).
- Parcours de démarrage : la carte Transactions explique les mots-clés et propose « Ajouter comme mot-clé » ; la carte Budget précise que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel (#430).
- La répartition d'une transaction sur plusieurs catégories (bouton Répartition de la page Transactions) fait désormais partie de l'édition Base, comme l'écran de déverrouillage le promettait déjà. En édition Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran de déverrouillage au lieu de la fenêtre de répartition, pour toute ligne. Les répartitions déjà faites restent dans vos données : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste (#369).
### Corrigé
- La page d'erreur du démarrage ne reste plus affichée quand le profil finit de s'ouvrir après le délai de 10 secondes : l'application s'affiche dès que le profil est ouvert (#395).
- Le petit badge à côté du bouton « Récupérer le prix » d'un titre boursier, dans le Bilan, affiche désormais « non garanti » au lieu de l'anglais « best-effort » (#389).
- Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320).
- Budget : une sous-catégorie dépliée s'affiche sous sa catégorie parente, et non plus en fin de groupe. Avec les sous-totaux en bas, chaque sous-total suit ses propres lignes, dans la grille Budget comme dans les rapports Budget vs réel et Comparables (#424).
- Import : le nom d'une source est celui de son sous-dossier, en lecture seule ; une source renommée à la configuration n'est plus prise pour une nouvelle source au prochain import (#426).
- Aide et guide : le Budget décrit la saisie dans la colonne Annuel (plus de bouton de répartition) et la saisie limitée aux catégories du dernier niveau ; les Rapports ont cinq cartes et plus d'onglets ; l'ajout d'un mot-clé depuis une transaction passe par le clic droit (#431).
- La suppression d’un profil, d’une catégorie, d’un ajustement, d’un gabarit de budget ou d’une catégorie du Bilan, et le retrait de la clé de licence, attendent maintenant votre réponse à la confirmation. Jusqu’ici, l’action partait dès l’affichage de la confirmation et « Annuler » n’empêchait rien : un profil supprimé perdait ses données. Les boutons de la confirmation suivent désormais la langue de l’app (#421).
### Sécurité
- Mise à jour de dépendances npm pour corriger les huit vulnérabilités signalées par `npm audit` (cinq élevées, trois modérées), par le seul fichier de verrouillage : chaque nouvelle version reste dans la plage que `package.json` déclarait déjà. `react-router-dom` et `react-router` 7.18.1 → 7.18.4 corrigent GHSA-qwww-vcr4-c8h2 (CSRF en mode React Server Components), l'advisory laissée en l'état jusqu'ici parce qu'en sortir semblait demander une migration vers react-router v8 ; la 7.18.2 l'a corrigée. Le reste est de l'outillage de build et de test, jamais livré dans l'application : `vitest` et `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 et `source-map-js` 1.2.1 → 1.2.2. Aucune de ces advisories n'était atteignable dans l'application livrée, qui exécute react-router dans sa propre fenêtre, sans serveur. De toutes ces mises à jour, seule celle de react-router modifie l'application construite : elle refuse désormais une navigation vers une autre origine, ce que l'application ne fait jamais, donc la navigation se comporte comme avant (#358).
- Mise à jour de la dépendance Rust `rustls` (0.23.45 → 0.23.46), corrigeant RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 et RUSTSEC-2026-0349. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` ; les appels de licence, d'authentification, de cours et de rétroaction de l'application passent par un autre moteur TLS. Aucune des quatre ne s'applique à l'usage qu'en fait l'application : aucun magasin de sessions maison ne peut journaliser de secret de session (0346), l'Encrypted Client Hello n'est jamais activé (0347), la décompression Brotli des certificats, optionnelle, n'est pas compilée (0348), et le fournisseur cryptographique `ring` ne propose aucun échange de clés post-quantique qui pourrait être rabattu (0349). Mise à jour quand même, plutôt que contournée par un raisonnement, puisqu'un correctif existe. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Ce qui change, c'est le comportement de la couche TLS elle-même : outre ces quatre correctifs, la 0.23.46 apporte une dizaine d'autres corrections (conformité au protocole, durcissement, entrées-sorties), comme le refus des enregistrements `change_cipher_spec` protégés et la mise à jour des clés TLS 1.3 au moment voulu (#447).
- Retrait de l'extraction d'archive de la mise à jour automatique (sa dépendance `zip` sous Windows et `tar` sous Linux), corrigeant RUSTSEC-2026-0367, RUSTSEC-2026-0368 et RUSTSEC-2026-0369. La mise à jour n'extrayait un paquet que s'il arrivait sous forme d'archive (un zip sous Windows, une AppImage en `.tar.gz` sous Linux), après vérification de sa signature, et les mises à jour de cette application ne sont jamais des archives : l'installeur Windows (`.exe`) est lancé tel quel et les paquets Linux (`.deb`, `.rpm`) sont installés par le gestionnaire de paquets du système. Aucun des trois avis, dont deux portent sur des permissions Unix alors que `zip` n'était compilé que pour Windows, n'était atteignable ; la dépendance est retirée plutôt que contournée par un raisonnement, faute de version corrigée compatible avec la mise à jour. Les mises à jour de l'installeur Windows et des paquets Linux fonctionnent comme avant (#455).
## [0.17.1] - 2026-10-08
### Ajouté
- Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites ou passées, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397).
- Des cartes de démarrage sur les pages Import, Transactions et Budget. Tant que l'étape suivante du parcours se fait sur l'une de ces pages, une carte y prend la place du bandeau : elle indique « Étape n sur N », dit quoi faire et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Quand l'étape est faite, la carte reste, annonce « Fait. » et propose « Étape suivante », qui mène à la page de l'étape d'après, ou la fin du parcours. Arriver sur une page par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398, #399, #400).
- Sur la page Import, la carte de démarrage suit l'assistant d'import écran par écran, à travers ses trois étapes (choisir un dossier d'import, configurer une source d'import, importer un premier fichier), et annonce « Fait. » une fois le fichier importé, jamais pour un import qui n'a ajouté aucune transaction (#398).
- Sur la page Transactions, le lien de l'étape « Classer une transaction à la main » ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer, et la carte désigne la catégorie de la première transaction affichée. En édition Gratuite, où l'étape Budget est verrouillée, classer une transaction termine le parcours (#399).
- Sur la page Budget, dont la grille s'ouvre catégories repliées, la carte de démarrage désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir, et ne déplie jamais la grille à votre place. Elle rappelle qu'Entrée enregistre, et que Tab enregistre et passe au mois suivant. Enregistrer un montant termine le parcours (#400).
- Une sous-section « Le parcours de démarrage » dans la section « Premiers pas » du guide d'utilisation, celle qu'ouvre « Ouvrir le guide » à la fin du parcours : le bandeau et sa liste, les cartes d'étape et leur contour, « Passer cette étape » et « Reprendre », la fin du parcours, les profils qui le trouvent masqué d'office, et son réaffichage depuis Paramètres → Utilisateurs. Les sections Tableau de bord, Profils et Paramètres du guide décrivent le parcours tel qu'il fonctionne désormais (#401).
### Modifié
- Le parcours de démarrage ne s'affiche plus dans un profil qui avait déjà des transactions avant cette version : à la première ouverture d'un tel profil, le bandeau et sa liste sont masqués, comme après un clic sur « Masquer », et « Afficher la liste de démarrage », dans Paramètres → Utilisateurs, les fait revenir. Un profil sans transaction les garde (#396).
- Le tableau de bord n'affiche plus la barre compacte de démarrage une fois que le profil a des transactions : le bandeau la remplace sur chaque page. Dans la liste détaillée, les lecteurs d'écran annoncent désormais si chaque étape est faite, passée, verrouillée ou en attente (#397).
- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » dès que le parcours est masqué, y compris depuis le bandeau de cette même page, et confirme désormais que le parcours réapparaît en haut des pages (#397).
### Corrigé
- La page Import n'affiche plus un bref instant l'état vide « Aucun dossier d'import » à son ouverture, pendant la lecture du dossier d'import enregistré (#398).
- L'étape de démarrage « Importer un premier fichier » ne se coche plus après un import qui n'a ajouté aucune transaction, par exemple un fichier réduit à sa ligne d'en-tête, ou dont toutes les lignes étaient en erreur dans l'aperçu : l'étape attend désormais un fichier qui a amené au moins une transaction (#398).
- Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390).
- Les profils autres que le profil par défaut reçoivent désormais les mises à jour de leur base de données. Jusqu'ici, chacun gardait la structure de base de la version qui l'avait créé, et des fonctions ajoutées depuis pouvaient y échouer : dans un profil créé avant la 0.15.0, par exemple, le format d'import enregistré sur une source. À l'ouverture d'un tel profil, l'application garde d'abord une copie de sa base dans le dossier `migration-backups`, à côté des bases, puis applique les mises à jour manquantes. La copie est supprimée avec le profil, et chaque mise à jour est inscrite dans les journaux (Paramètres → Système) (#390).
- Sous Linux, supprimer un profil supprime désormais sa base de données, qui restait sur le disque dans `~/.config/com.simpl.resultat/` ; le fichier d'un profil supprimé avec la version 0.17.0 ou antérieure peut être supprimé à la main. Les colonnes de rendement des comptes du Bilan affichent désormais les rendements sous Linux, où chaque cellule affichait un tiret (#390).
## [0.17.0] - 2026-10-05
### Ajouté
- États vides guidés sur le Tableau de bord, Import, Transactions et Budget : un profil sans transaction explique désormais ce que la page montrera et propose l'étape suivante (« Choisir un dossier d'import », « Importer des transactions ») au lieu d'un graphique ou d'un tableau vides (#352, #353, #356).
- Une liste de démarrage sur le Tableau de bord, calculée depuis vos données : six étapes (créer un profil, choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget) se cochent d'elles-mêmes au fil de votre progression, et l'étape Budget est marquée « Base et plus » en édition Gratuite. La liste occupe le tableau de bord vide, se réduit à une barre compacte dès qu'il y a des transactions, disparaît quand toutes les étapes sont faites, et « Masquer » la retire pour le profil courant seulement (#351, #352).
- « Créer un dossier d'exemple » sur la page Import quand le dossier d'import n'a aucun sous-dossier de banque : le bouton dépose deux sources fictives (« Exemple Desjardins », « Exemple Tangerine »), un relevé chacune couvrant les deux mois précédents, pour essayer l'assistant avant que vos vrais relevés soient en place. Il refuse d'écraser un dossier existant du même nom (#354, #356).
- Une sous-section « Exporter un CSV depuis votre banque » dans le guide d'utilisation, avec une procédure par banque (Desjardins, Tangerine, RBC, Banque Nationale, BMO) et une ligne de statut indiquant si chacune a été confirmée sur un compte réel (#355).
- « Retirer la clé » dans la carte Licence (Paramètres → Utilisateurs) retire la clé de licence enregistrée sur cet ordinateur, après une confirmation qui dit ce que cela change. Une clé valide : les modules de son édition se verrouillent et l'application repasse en édition Gratuite, vos données étant conservées. Une clé révoquée : l'application cesse de l'envoyer au serveur de licences, ce qu'elle faisait au plus une fois par 24 heures tant que la clé restait enregistrée. Quand cette machine est activée, sa place est d'abord libérée sur le serveur de licences ; si le serveur ne répond pas, l'application demande s'il faut retirer la clé quand même, et la place reste alors occupée jusqu'à ce que vous la libériez depuis un autre ordinateur où la clé est enregistrée. Une clé révoquée retirée puis entrée de nouveau reste révoquée. Chaque retrait est inscrit dans les journaux (Paramètres → Système) (#370).
### Modifié
- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » une fois la liste masquée, et confirme qu'elle réapparaîtra sur le tableau de bord (#352).
- Le popover d'aide « ? » de la page Import renvoie désormais à la sous-section « Exporter un CSV depuis votre banque » du guide, comme l'état vide d'Import (#356).
### Corrigé
- Une clé de licence révoquée par le serveur de licences et saisie en cours de session fait désormais repasser l'application en édition Gratuite sans attendre : la clé est vérifiée dès son enregistrement, en arrière-plan, au lieu du démarrage suivant. Elle gardait auparavant son édition jusqu'au redémarrage de l'application, ou jusqu'à une tentative d'activation de la machine. La saisie d'une clé rend toujours la main tout de suite, en ligne comme hors ligne, et une clé révoquée affiche son édition une à deux secondes avant de basculer. La vérification part toujours au plus une fois par 24 heures pour une même clé, et chacune est inscrite dans les journaux (Paramètres → Système) (#373).
- Une machine dont l'activation ne sert plus retrouve « Activer » dans la carte Licence (Paramètres → Utilisateurs), sans manipulation de fichier. C'était le cas d'un ordinateur où une version jusqu'à la 0.15.0 avait laissé l'activation d'une licence précédente à côté d'une autre clé : la carte affichait « Activée », les cours du marché étaient refusés parce que cette licence était révoquée, et « Activer » n'était jamais proposé. L'activation est désormais retirée quand le serveur des cours la refuse parce qu'elle appartient à une licence révoquée (le message du refus invite désormais à activer de nouveau la machine), et quand la liste des machines, une fois ouverte, ne compte pas cette machine, ce qui couvre aussi une machine désactivée depuis un autre ordinateur. La clé enregistrée et son édition ne changent pas, et chaque retrait est inscrit dans les journaux (Paramètres → Système) (#367).
## [0.16.0] - 2026-10-03
### Ajouté

View file

@ -2,6 +2,82 @@
## [Unreleased]
## [0.17.2] - 2026-10-18
### Added
- Import: a warning names the CSV files placed directly in the import folder, which are ignored (#427).
- Getting started: two visit steps, “Browse categories and keywords” (before categorizing) and “Browse the reports” (last), checked with “Got it”; a profile that had finished the path finds them already done; their cards, on the Categories and Reports pages, explain keywords and the reports hub (#425, #432).
### Changed
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
- Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422).
- Getting started: the Transactions card explains keywords and suggests “Add as keyword”; the Budget card states that only last-level categories take an amount and what the Annual column does (#430).
- Splitting a transaction across categories (the Split button of the Transactions page) is now part of the Base edition, as the unlock screen already promised. On the Free edition the button keeps its place, shows a lock and opens the unlock screen instead of the split window, for every row. Splits made earlier stay in your data: they are still counted in the reports and the dashboard, and still flagged in the list (#369).
### Fixed
- The startup error page no longer stays on screen when the profile finishes opening after the 10-second delay: the app now shows as soon as the profile is open (#395).
- In French, the small badge next to the "Fetch price" button of a stock in the Balance sheet now reads "non garanti" instead of the English "best-effort" (#389).
- Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320).
- Budget: an expanded subcategory now shows under its parent category instead of at the end of the group. With subtotals at the bottom, each subtotal follows its own rows, in the Budget grid as in the Budget vs Actual and Compare reports (#424).
- Import: a source is named after its subfolder, read-only; a source renamed during configuration is no longer taken for a new source at the next import (#426).
- Help and guide: Budget describes typing in the Annual column (no more split button) and that only last-level categories take an amount; Reports have five cards and no tabs; adding a keyword from a transaction goes through the right-click menu (#431).
- Deleting a profile, a category, an adjustment, a budget template or a Balance sheet category, and removing the license key, now wait for your answer to the confirmation. Until now the action ran as soon as the confirmation appeared, and “Cancel” stopped nothing: a deleted profile lost its data. The confirmation buttons now follow the app language (#421).
### Security
- Updated npm dependencies to clear the eight vulnerabilities reported by `npm audit` (five high, three moderate), through the lockfile alone: every new version stays within the range `package.json` already declares. `react-router-dom` and `react-router` 7.18.1 → 7.18.4 clear GHSA-qwww-vcr4-c8h2 (CSRF in React Server Components mode), the advisory left as is until now because leaving its range seemed to require migrating to react-router v8; 7.18.2 fixed it. The rest is build and test tooling, never shipped in the app: `vitest` and `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 and `source-map-js` 1.2.1 → 1.2.2. None of these advisories was reachable in the shipped app, which runs react-router in its own window, with no server. Of all these updates, only react-router's changes the built app: it now refuses a navigation to another origin, which the app never makes, so navigation behaves as before (#358).
- Updated the Rust dependency `rustls` (0.23.45 → 0.23.46), clearing RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and RUSTSEC-2026-0349. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater`; the application's own licence, authentication, market-price and feedback calls use a different TLS backend. None of the four applies to how the application uses it: no custom session store exists that could log session secrets (0346), Encrypted Client Hello is never enabled (0347), the optional Brotli certificate decompression is not compiled in (0348), and the `ring` cryptography provider offers no post-quantum key exchange that could be downgraded (0349). Updated anyway rather than reasoned around, since a fix exists. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. What changes is the TLS layer's own behaviour: besides these four fixes, 0.23.46 carries about ten other corrections (protocol conformance, hardening, I/O), such as rejecting protected `change_cipher_spec` records and performing TLS 1.3 key updates on time (#447).
- Removed the archive extraction of the automatic updater (its `zip` dependency on Windows and `tar` on Linux), clearing RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369. The updater only extracted an update that arrived as an archive (a zip on Windows, a `.tar.gz` AppImage on Linux), after checking its signature, and this application's updates are never archives: the Windows installer (`.exe`) is run as it is and the Linux packages (`.deb`, `.rpm`) are installed by the system's package manager. None of the three advisories, two of which concern Unix permissions while `zip` was only compiled for Windows, was reachable; the dependency is removed rather than reasoned around, since no fixed version fits the updater. Updates of the Windows installer and of the Linux packages work as before (#455).
## [0.17.1] - 2026-10-08
### Added
- A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done or skipped, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397).
- Getting-started cards on the Import, Transactions and Budget pages. While the next step of the path is done on one of these pages, a card takes the place of the banner there: it shows "Step n of N", says what to do and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. When the step gets done, the card stays, says "Done." and offers "Next step", which leads to the page of the following step, or the end of the path. Arriving on a page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398, #399, #400).
- On the Import page, the getting-started card follows the import wizard screen by screen through its three steps (choose an import folder, set up an import source, import a first file) and says "Done." once the file is imported, never for an import that added no transaction (#398).
- On the Transactions page, the link of the "Categorize a transaction by hand" step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized, and the card points at the category of the first transaction shown. In the Free edition, where the Budget step is locked, categorizing a transaction ends the path (#399).
- On the Budget page, whose grid opens with its categories folded, the getting-started card first points at "Expand all", then at the current month's cell of the first category you can fill in, and never unfolds the grid for you. It reminds you that Enter saves, and that Tab saves and moves to the next month. Saving an amount ends the path (#400).
- A sub-section, "The getting-started path", in the "Getting Started" section of the user guide, the one "Open the guide" opens at the end of the path: the banner and its list, the step cards and their outline, "Skip this step" and "Resume", the end of the path, the profiles that find it hidden from the start, and how to show it again from Settings → Users. The Dashboard, Profiles and Settings sections of the guide describe the path as it now works (#401).
### Changed
- The getting-started path no longer shows in a profile that already had transactions before this version: the first time such a profile is opened, the banner and its checklist are hidden, as if you had clicked "Hide", and "Show the getting-started checklist" in Settings → Users brings them back. A profile without transactions keeps them (#396).
- The dashboard no longer shows the compact getting-started bar once the profile has transactions: the banner replaces it on every page. In the detailed list, screen readers now hear whether each step is done, skipped, locked or pending (#397).
- Settings → Users offers "Show the getting-started checklist" as soon as the path is hidden, also when it was hidden from the banner of that same page, and now confirms that the path reappears at the top of the pages (#397).
### Fixed
- Opening the Import page no longer flashes the "No import folder" empty state while the saved import folder is being read (#398).
- The "Import a first file" getting-started step is no longer checked by an import that added no transaction, such as a file holding only its header line, or one whose rows were all in error in the preview: the step now waits for a file that brought at least one transaction (#398).
- Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390).
- Profiles other than the default one now receive database updates. Until now each one kept the database layout of the version that created it, so features added since could fail in it: in a profile created before 0.15.0, for instance, the import format saved on a source. When such a profile is opened, the app first keeps a copy of its database in the `migration-backups` folder next to the databases, then applies the missing updates. The copy is deleted with the profile, and each update is written to the logs (Settings → System) (#390).
- On Linux, deleting a profile now deletes its database, which used to stay on disk in `~/.config/com.simpl.resultat/`; the file of a profile deleted with version 0.17.0 or earlier can be deleted by hand. The return columns of the Balance sheet accounts now show the returns on Linux, where every cell showed a dash (#390).
## [0.17.0] - 2026-10-05
### Added
- Guided empty states on the Dashboard, Import, Transactions and Budget pages: a profile without transactions now explains what the page will show and offers the next step ("Choose an import folder", "Import transactions") instead of an empty chart or table (#352, #353, #356).
- A getting-started checklist on the Dashboard, computed from your data: six steps (create a profile, choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget) tick themselves as you go, and the Budget step is marked "Base and up" in the Free edition. The list fills the empty dashboard, shrinks to a compact bar once transactions exist, disappears when every step is done, and "Hide" dismisses it for the current profile only (#351, #352).
- "Create a sample folder" on the Import page when the import folder has no bank sub-folder: it writes two fictitious sources ("Exemple Desjardins", "Exemple Tangerine"), one statement each covering the two previous months, so you can try the wizard before your real statements are in place. It refuses to overwrite an existing folder of the same name (#354, #356).
- An "Export a CSV from your bank" sub-section in the user guide, with a procedure per bank (Desjardins, Tangerine, RBC, National Bank, BMO) and a status line saying whether each one was confirmed on a real account (#355).
- "Remove the key" on the License card (Settings → Users) removes the stored license key from this computer, after a confirmation that says what it changes. A valid key: the modules of its edition lock and the app goes back to the Free edition, with your data kept. A revoked key: the app stops sending it to the license server, which it did at most once every 24 hours for as long as the key stayed stored. When this machine is activated, its slot is freed on the license server first; if the server cannot be reached, the app asks whether to remove the key anyway, and the slot then stays occupied until you free it from another computer that has the key. A revoked key removed then entered again stays revoked. Each removal is written to the logs (Settings → System) (#370).
### Changed
- Settings → Users offers "Show the getting-started checklist" once the checklist was hidden, and confirms that it will reappear on the dashboard (#352).
- The "?" help popover of the Import page now links to the guide's "Export a CSV from your bank" sub-section, as does the Import empty state (#356).
### Fixed
- A license key revoked by the license server and entered during a session now puts the app back in the Free edition right away: the key is checked as soon as it is stored, in the background, instead of at the next start. It used to keep its edition until the app restarted, or until you tried to activate the machine. Entering a key still answers at once, online or offline, and a revoked key shows its edition for a second or two before the switch. The check still goes out at most once every 24 hours for the same key, and each check is written to the logs (Settings → System) (#373).
- A machine whose activation no longer serves gets "Activate" back on the License card (Settings → Users), without touching any file. This happened to a computer where a version up to 0.15.0 had left the activation of a previous license next to another key: the card read "Activated", market prices were refused because that license was revoked, and "Activate" was never offered. The activation is now removed when the price server refuses it as belonging to a revoked license (the refusal message now invites you to activate the machine again), and when the machine list, once opened, does not count this machine, which also covers a machine deactivated from another computer. The stored key and its edition are untouched, and each removal is written to the logs (Settings → System) (#367).
## [0.16.0] - 2026-10-03
### Added

View file

@ -11,7 +11,7 @@
**Stockage :** SQLite local (tauri-plugin-sql)
**Langues supportées :** Français (FR) et Anglais (EN)
**Plateformes :** Windows, Linux
**Version actuelle :** 0.16.0
**Version actuelle :** 0.17.2
**Licence :** GPL-3.0-only
---
@ -65,11 +65,12 @@ src-tauri/
│ ├── commands/ # 3 modules, 17 commandes Tauri
│ │ ├── fs_commands.rs # Système de fichiers (6 commandes)
│ │ ├── export_import_commands.rs # Export/import chiffré (5 commandes)
│ │ └── profile_commands.rs # Gestion des profils (6 commandes)
│ │ └── profile_commands.rs # Gestion des profils (8 commandes)
│ ├── database/ # Schémas SQL et migrations
│ │ ├── schema.sql # Schéma initial (v1)
│ │ ├── seed_categories.sql # Seed catégories (v2)
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
│ │ ├── consolidated_schema.sql # Schéma complet (nouveaux profils)
│ │ └── profile_db.rs # Rattrapage des profils secondaires (ADR 0021)
│ ├── lib.rs # Point d'entrée, 17 migrations inline, plugins
│ └── main.rs
└── Cargo.toml
@ -120,7 +121,7 @@ src-tauri/
- **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index. Ces deux comptes sont inchangés depuis v13 : v14-v16 ajoutent 2 tables + 2 index (déjà comptés), v17 est un `ALTER TABLE` pur
- **17 migrations inline** dans `lib.rs` (v1→v17, via `tauri_plugin_sql::Migration`). Étape 2 du Bilan (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position) — voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md). Import : v17 (`import_sources.amount_mode` + `sign_convention`, les deux avec `CHECK`, + `header_signature` + `template_id`) — voir [ADR 0019](docs/adr/0019-format-import-persiste.md)
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils, suivi de l'historique complet des migrations. tauri-plugin-sql ne migre que le profil par défaut : les profils secondaires rattrapent leurs migrations à chaque ouverture (`prepare_profile_db`, copie préalable, clés étrangères actives) — voir [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md). Les bases vivent sous `app_config_dir()` (`profile_db_path`), qui n'est pas `app_data_dir()` sous Linux
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
---
@ -133,6 +134,8 @@ La documentation technique est centralisée dans `docs/` :
- `docs/guide-utilisateur.md` — Guide utilisateur
- `docs/archive/` — Anciennes spécifications archivées
Les règles de revue des PR (niveaux de risque, ce qui compte par zone, commandes de vérification, pièges) sont dans [`REVIEW.md`](REVIEW.md), à la racine ; un changement qui rend faux un fait qu'il cite le met à jour dans la même PR.
**Règle : quand un changement touche l'architecture, mettre à jour la documentation :**
- Nouveau service, hook, commande Tauri, page/route, ou table SQL → mettre à jour `docs/architecture.md`
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
@ -161,12 +164,12 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle.
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, `cargo test` et un `cargo audit` informatif. Doit être vert avant tout merge.
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown. Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` remonte 2 high en permanence — une seule advisory `react-router` (mode RSC, inatteignable dans une app de bureau sans serveur), acceptée et suivie en [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317).
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer.
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` ; `.claude/**` et `release.yml` le déclenchent, car `scripts/build-latest-json.test.mjs` lit `verify-release.sh` et exécute l'étape « Publish » de `release.yml` (#419). Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient).
- **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier.
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques.
Aucun workflow `check-*` ne filtre sur `branches:` : une PR stackée sur une autre branche de feature déclenche donc bien la CI. Le cache Actions est retiré partout tant que [#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234) (connectivité du serveur de cache) n'est pas réglé — restore et save échouent tous les deux. Le miroir `.github/workflows/` est dormant (aucune PR côté GitHub).
Aucun workflow `check-*` ne filtre sur `branches:` : une PR stackée sur une autre branche de feature déclenche donc bien la CI. Le cache Rust passe par `Swatinem/rust-cache` ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)) : le runner garde ce qu'écrit un run de PR pour cette seule PR, donc c'est le run du lundi (ou un `workflow_dispatch`) qui alimente le cache partagé que lisent les autres PR. Un run à froid reste normal après une nouvelle version stable de Rust ou une modification de l'`env:` du job, quand une entrée expire (7 jours sans usage, 30 jours d'âge), ou quand une PR sauvegarde sous la clé de l'entrée partagée et l'évince, le runner ne gardant qu'une entrée par clé. La liste complète est en tête de `check-rust.yml` ; seul un délai dépassé signale un retour de #234. Pas de cache npm : `check-frontend` ne tourne que sur des PR, et `npm ci` y prend 9 s. Le miroir `.github/workflows/` est dormant (aucune PR côté GitHub).
---

View file

@ -38,11 +38,10 @@ Application de bureau 100 % locale pour importer, catégoriser et analyser vos t
### Linux
1. Rendez-vous sur la page [**Releases**](https://github.com/Le-King-Fu/simpl-resultat/releases/latest)
2. Téléchargez le format adapté à votre distribution :
2. Téléchargez le paquet adapté à votre distribution :
- `.deb` pour Debian / Ubuntu
- `.rpm` pour Fedora / openSUSE
- `.AppImage` pour toute distribution (exécutable universel)
3. Installez le paquet ou lancez l'AppImage directement
3. Installez le paquet
## Démarrage rapide

190
REVIEW.md Normal file
View file

@ -0,0 +1,190 @@
# REVIEW.md — Simpl'Résultat
Règles de revue des PR de ce dépôt : niveau de risque, ce qui compte dans chaque zone, commandes de vérification et pièges connus. Ce fichier complète `CLAUDE.md` et `.claude/rules/` sans les remplacer : une zone en reprend ce qui sert à la revue, avec sa source.
À partir de #457, `/pr-review` lit ce fichier sur `main`, y compris pour une PR empilée : une PR qui le modifie est jugée par la version de `main`, et ses nouvelles règles valent pour les PR suivantes.
## Niveaux
Le niveau fixe la profondeur de la revue, que `/pr-review` règle pour chaque niveau à partir de #457. Il se calcule sur les fichiers que touche le diff, jamais sur l'auteur (Max, `/fix-issue`, `/autopilot`) :
- un fichier désigné par une ligne du tableau **Haut** dont la condition est remplie est de niveau haut ; sinon, un fichier désigné par **Bas** est de niveau bas ; sinon il est normal ;
- la PR prend le niveau le plus haut de ses fichiers, et la revue l'annonce avec le fichier qui le fixe ;
- un fichier renommé, déplacé ou supprimé se juge sur son ancien et sur son nouveau chemin : un fichier de niveau haut déplacé reste haut, et la PR qui le déplace met ce tableau à jour ;
- une condition qu'on ne sait pas trancher à la lecture du diff compte comme remplie.
Motifs : chemins depuis la racine. `*` ne traverse jamais un `/` ; `**/` vaut zéro, un ou plusieurs dossiers (`**/*.md` couvre `CLAUDE.md`, à la racine) ; `{a,b}` liste des variantes.
### Haut
| Motif | Condition | Zone |
|---|---|---|
| `src-tauri/src/database/**` | toujours | Migrations, profils et données |
| `src-tauri/src/lib.rs` | sauf si le diff se limite à des entrées de `generate_handler![…]` ou au module `tests` | Migrations (`migrations()`), plugins, deep link OAuth |
| `src-tauri/src/commands/profile_commands.rs` | toujours | Migrations, profils et données |
| `src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs`, `src/services/{licenseService,authService}.ts`, `.claude/skills/issue-license/**` | toujours | Jetons, licence et édition |
| `src-tauri/src/commands/balance_commands.rs` | le diff touche `fetch_price` ou la lecture du jeton d'activation | Jetons, licence et édition |
| `src-tauri/src/commands/export_import_commands.rs` | toujours | Jetons, licence et édition (export chiffré) |
| `src-tauri/src/commands/feedback_commands.rs`, `src/services/feedbackService.ts` | toujours | Vie privée |
| code livré : `src/**`, `src-tauri/src/**`, `index.html`, `public/**` | le diff ajoute un appel réseau, change ce qu'un appel existant envoie, ouvre une URL qui porte une donnée de l'utilisateur (donnée financière, identifiant, contenu d'un profil, ligne du Journal), ou retire ou contourne un consentement (celui des cours vit côté front : `src/components/balance/PriceFetchControl.tsx`, `src/components/settings/PriceFetchConsentToggle.tsx`) | Vie privée |
| `src-tauri/capabilities/**` | toujours | Vie privée |
| `src-tauri/{tauri*.conf.json*,Tauri*.toml}` | sauf si le diff se limite au champ `"version"` ; un fichier propre à une plateforme est fusionné avec `tauri.conf.json` | Vie privée (CSP), Release (updater, `beforeBuildCommand`) |
| `.forgejo/workflows/release.yml`, `scripts/build-latest-json*`, `scripts/check-latest-json.jq`, `.claude/skills/release/**` | toujours | Release et mise à jour |
| `src-tauri/build.rs`, `{vite,vitest,postcss}.config.*`, `.postcssrc*`, `.npmrc`, `**/rust-toolchain*`, `Cargo.{toml,lock}` à la racine | toujours | Release et mise à jour, CI et gardes (lu ou exécuté par le build de la release ou par `npm test`) |
| fichier à la racine du dépôt ou directement dans `src-tauri/`, hors `*.md` | ajouté, renommé ou supprimé, comme tout fichier d'un dossier caché nouveau : une configuration nouvelle peut changer le build ou la CI sans motif qui la nomme | Release et mise à jour, CI et gardes |
| `src-tauri/Cargo.toml`, `package.json` | sauf si le diff se limite au champ `version` de l'app (dépendances, features dont `dev-override`, `overrides`, scripts) | Dépendances ; Release pour les `scripts` de `package.json` |
| `src-tauri/Cargo.lock`, `package-lock.json` | le diff ajoute ou retire un paquet (un bloc `[[package]]` dont la ligne `name =` apparaît ou disparaît, une seconde version d'un crate comprise ; une clé `"node_modules/<nom>"` qui apparaît ou disparaît), ou change l'origine d'un paquet : hôte ou schéma de `source` (Cargo) ou de `resolved` (npm), avec ou sans nouvelle version, ou `checksum`, `integrity` changés sans nouvelle version | Dépendances |
| `**/.cargo/**` | toujours | Dépendances |
| `.forgejo/workflows/**` | fichier ajouté, ou diff qui ajoute une référence à `secrets` ou un déclencheur `pull_request_target`, qui exécute la définition de la base avec ses secrets pour toute PR, forks compris | CI et gardes |
| `.forgejo/workflows/audit.yml` | le diff change une ligne active : sans déclencheur `pull_request`, il tourne pour la première fois sur `main` | CI et gardes |
| `.forgejo/workflows/{check-rust,check-frontend}.yml` | le diff peut faire passer au vert ce qui échouait, par exemple : déclencheur retiré ou restreint (`paths`, `paths-ignore`, `branches`, `schedule`) ; étape de verdict modifiée ou retirée (`npm ci`, `npm run build`, `npm test`, `cargo check`, `cargo test`, `Verify …`, `cargo audit`) ; `continue-on-error` ou `\|\| true` ajouté ; étape `run:` ajoutée qui écrit `$GITHUB_ENV`, `$GITHUB_PATH` ou un fichier du dépôt ; action `uses:` ajoutée, hors cache ; étape d'installation modifiée (toolchain, binaire placé dans le `PATH`) ; `if:`, `defaults:`, `permissions:`, `env:` ou `container:` modifiés | CI et gardes |
| `.claude/settings.json`, `.mcp.json` | toujours : hooks, permissions et serveurs MCP décident de ce que les agents exécutent sur le poste, sans CI pour le voir | CI et gardes |
| `REVIEW.md` | le diff touche la section « Niveaux » | Règles de revue |
Restent au moins normaux, et leurs règles de zone s'appliquent quand même :
- une montée de version d'un paquet déjà présent dans un lock, correctif d'advisory compris (décision de Max du 2026-10-17, consignée sur #456), et une arête re-résolue vers une version déjà présente : seul le garde `windows-sys` de la CI en surveille, pour ses sept crates ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)) ;
- dans `check-rust.yml` et `check-frontend.yml` : un déclencheur ajouté, sauf `pull_request_target`, une étape `run:` ajoutée qui ne fait que lire, un cache (`Swatinem/rust-cache`, `actions/cache`), un commentaire, la version de Node ;
- `src/services/db.ts` et `src/services/dataExportService.ts`, même quand le diff touche le verrou, le changement de profil ou les suppressions de la restauration (décision de Max du 2026-10-18, revue de #460) : contre leurs défauts, la mutation du niveau normal est le bon outil ;
- `src-tauri/src/commands/{fs_commands,backup_commands}.rs`, et le reste de `balance_commands.rs` ;
- `src/shared/entitlements.ts` : la matrice ne verrouille que l'interface ([ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2) ;
- `CLAUDE.md`, `REVIEW.md` hors de la section « Niveaux », `docs/adr/**`, `docs/architecture.md`, `docs/qa-update-cycle.md` et `.claude/**` : sources de ce fichier et instructions des agents, jamais de niveau bas.
### Bas
Une PR est de niveau bas quand tous ses fichiers sont désignés par `**/*.md`, `docs/**`, `LICENSE`, `tasks/**` ou `reports/**`, hors des exceptions de la liste précédente. `STATE.md` reste bas malgré son import par `CLAUDE.md` : il résume l'état du projet, il ne fixe pas de règle.
### Normal
Tout le reste : code applicatif et tests, traductions (`src/i18n/locales/*.json`), `public/`, configuration TypeScript, `.github/**` (miroir dormant).
### Calibration
Rejouée le 2026-10-17 (#456) sur les PR mergées de #344 à #452 : les 39 PR de référence du document de départ du pilote, dont la liste est figée dans la PR qui ferme #456, et la plage complète, soit 51 PR.
| Ensemble | Bas | Normal | Haut |
|---|---|---|---|
| 39 PR de référence, motifs d'essai | 3 | 19 | 17 (44 %) |
| 39 PR de référence, ces motifs | 3 | 28 | 8 (21 %, pour une cible de 20 %) |
| 51 PR de la plage, ces motifs | 6 | 33 | 12 (24 %) |
Niveau haut sur les 51 : licence (#359, #364, #366, #368, #386, #387, #388), migrations et profils (#394, #436), release (#414, #416, #418) ; parmi les 39 de référence, #386, #387, #388, #394, #414, #416, #418 et #436. L'écart à la cible est justifié dans la PR qui ferme #456, qui consigne aussi le niveau de chaque PR. Le script qui les calcule est `scripts/review-levels.py` (API Forgejo, lecture seule) ; `python3 scripts/review-levels.py --check` vérifie, sans réseau, que ses motifs sont ceux du tableau Haut.
Une PR qui modifie cette section dit, pour chaque motif retiré ou assoupli, ce qu'il protégeait, met `scripts/review-levels.py` à jour dans le même diff et le rejoue sur les mêmes PR ; le pilote #458 mesure si les niveaux tiennent.
## Ce qui compte par zone
Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR normale qui touche une zone les respecte aussi.
### Vie privée
- Aucune donnée financière ne quitte le poste : ni télémétrie, ni analytics, et le CSV comme la base restent locaux. Un appel réseau ajouté se justifie dans la PR : il n'envoie rien de financier ni aucun identifiant brut, et il passe derrière un consentement ou l'édition. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), [ADR 0020](docs/adr/0020-identifiant-machine-derive.md)
- Les appels sortants partent tous du Rust (`reqwest`) : `auth_commands.rs` (compte), `license_commands.rs` (licence et activation), `balance_commands.rs` (`fetch_price`, qui porte le jeton d'activation) et `feedback_commands.rs`, plus l'endpoint du plugin d'updater. Le `connect-src` de `app.security.csp` (`api.`, `auth.` et `feedback.lacompagniemaximus.com`) ne borne que la webview : lire chaque URL, chaque en-tête et chaque corps envoyé. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src-tauri/tauri.conf.json`, `src-tauri/src/commands/`
- Les cours passent par `maximus-api`, après un consentement explicite par profil, avec les seuls en-têtes `Authorization`, `Accept` et `User-Agent` : le fournisseur tiers ne voit jamais le poste. Ce consentement n'est vérifié que côté front (`PriceFetchControl.tsx`, `PriceFetchConsentToggle.tsx`) : `fetch_price` ne le revérifie pas. — [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), `src-tauri/src/commands/balance_commands.rs`
- Seule l'activation envoie un identifiant de la machine, et seulement le dérivé (`HMAC-SHA256` de l'identifiant du système) ; un jeton émis avant la dérivation porte encore l'identifiant brut jusqu'à la réactivation. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 2 et 5
- Le Feedback Hub reçoit le texte saisi, suivi au choix des dernières erreurs du Journal, un `userId` facultatif et un contexte borné par `FeedbackContext` (page, langue, thème, fenêtre, user-agent, horodatage) : un champ ajouté au contexte élargit ce qui part. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src/services/feedbackService.ts`, `src/components/settings/FeedbackDialog.tsx`
- La webview n'a que les permissions de `src-tauri/capabilities/default.json` : l'accès aux fichiers passe par les commandes Rust, jamais par une permission `fs`, et une permission ajoutée se justifie. — `CLAUDE.md` « Règles d'architecture »
- Un message du Journal nomme un fichier, jamais son dossier : le chemin contient le nom d'utilisateur, et une ligne d'erreur du Journal peut partir avec une rétroaction (règle posée pour le rattrapage des profils). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
### Précision financière et import
- Montants : virgule ou point décimal, espaces, symboles monétaires, négatifs, cellules vides et formats inattendus ont chacun leur test. — `CLAUDE.md` « Précision financière » et « Conventions de code » (Général)
- Encodages CSV : UTF-8, Windows-1252, ISO-8859-15. — `CLAUDE.md` « Précision financière »
- Le format d'import est persisté en entier sur la source et lu tel quel, jamais re-deviné ; l'aperçu reste obligatoire avant tout import. — [ADR 0019](docs/adr/0019-format-import-persiste.md), `CLAUDE.md` « Fonctionnalités principales »
### Migrations, profils et données
- Jamais de modification d'une migration existante : son checksum est vérifié au démarrage et épinglé par le test `migration_checksums_match_the_released_ones`. On ajoute une version. — `CLAUDE.md` « Base de données », [ADR 0003](docs/adr/0003-sqlx-migrations.md), `.claude/rules/sql-migrations.md`
- Une migration atteint deux chemins et reste correcte sur les deux : le profil par défaut, migré par tauri-plugin-sql (sqlx), et chaque profil secondaire, rattrapé à son ouverture par `prepare_profile_db` (rusqlite, clés étrangères actives). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
- Tout changement de schéma et toute correction de données (seed, mots-clés, catégories) se reportent dans `consolidated_schema.sql` : un profil neuf naît avec l'historique complet et ne rejoue aucune migration. Aucun marqueur ajouté à `infer_floor`, réservé aux bases créées avant #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
- Les comptes de tables, d'index et de migrations de `CLAUDE.md` et de `docs/architecture.md` suivent. — `CLAUDE.md` « Base de données » et « Documentation technique »
- Les bases vivent sous `app_config_dir()`, qui n'est pas `app_data_dir()` sous Linux. Les deux dossiers dérivent de l'`identifier` de `tauri.conf.json` : le changer orpheline les bases des profils et, sous `app_data_dir()`, la clé de licence, le jeton d'activation et le fichier de repli des jetons OAuth. Le service du trousseau (`KEYCHAIN_SERVICE` de `token_store.rs`) est une constante alignée sur l'`identifier` par convention : il ne suit pas. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `CLAUDE.md` « Base de données », `src-tauri/src/commands/license_commands.rs`
- Toute requête passe par le verrou unique de `db.ts`, et une transaction par `withTransaction` et son handle ; un changement de profil ferme puis rouvre la connexion. La restauration d'une sauvegarde (`runRestore` de `dataExportService.ts`) efface puis réinsère dans une seule transaction et ne notifie qu'après le `COMMIT`, une fois le verrou relâché. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md), [ADR 0005](docs/adr/0005-multi-profile-db.md), `docs/architecture.md` « Accès DB et transactions »
- Un booléen se lie en `1`/`0` (`toSqlFlag`), jamais en `true`/`false`. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- Une base par profil, PIN haché par Argon2. — [ADR 0005](docs/adr/0005-multi-profile-db.md)
- Une nouvelle écriture d'une table que lit `COUNTERS_SQL` appelle `notifyDataChanged()` après succès, avec un test qui prouve la notification. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md)
### Jetons, licence et édition
- Jetons OAuth dans le trousseau du système ; repli sur un fichier `0600` seulement sans trousseau, et plus de retour au fichier une fois le trousseau utilisé. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md)
- Le callback `simpl-resultat://auth/callback` n'est traité que par l'instance qui détient le vérificateur PKCE : `tauri-plugin-single-instance` reste le premier plugin enregistré, et un callback sans `code` remonte une erreur au front. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `docs/architecture.md` « OAuth2 et deep-link (Compte Maximus) »
- Export chiffré SREF : AES-256-GCM, clé dérivée du mot de passe par Argon2id, sel et nonce aléatoires. — [ADR 0004](docs/adr/0004-aes-256-gcm-encryption.md)
- La dérivation de l'identifiant machine est figée (`derive_machine_id`), et un jeton émis avant elle reste accepté. Une montée de `machine-uid` se vérifie : une forme renvoyée différente changerait l'identifiant dérivé de chaque machine. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 1, 3 et « À surveiller »
- `isEntitled` est fail-closed en Free : le court-circuit `free` passe avant l'override signé `features[]` (CWE-863). `useEntitlement` rend `{ allowed, ready }`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 1 et 3
- Une fonctionnalité verrouillée reste visible, jamais masquée ; le gating bloque l'accès, jamais les données. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 4 et 5
- Le gating ne s'applique qu'à l'interface (soft-paywall assumé, l'app est GPL), et `SR_DEV_EDITION` n'existe que dans un build compilé avec la feature `dev-override`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2 et 6
### Dépendances
- `.cargo/audit.toml` n'admet une advisory que si son crate est absent de toutes les cibles livrées (`cargo tree -i <crate> --target <triple>`, Linux et Windows, jamais `--target all`) ou si aucun correctif n'existe. Une entrée porte un ID, jamais un crate ; elle ajoute son crate à la liste du garde de `check-rust.yml` et a son issue de suivi, qui porte la condition de retrait. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), en-tête de `.cargo/audit.toml`, `CLAUDE.md` « CI/CD »
- Garde `windows-sys` : s'il rougit après un `cargo update`, remettre les arêtes à la main selon l'ADR ; ne jamais modifier la liste gardée (`CRATES`), la version épinglée (`PIN`), l'ensemble attendu (`EXPECTED`) ni le canari pour le faire passer. Un `cargo update` ciblé peut déplacer d'autres arêtes sans le dire : chaque entrée qui bouge dans le lock doit s'expliquer. — [ADR 0023](docs/adr/0023-windows-sys-pinning.md), `CLAUDE.md` « CI/CD »
- Une version montée exécute son nouveau code au build de la release, clé de signature dans l'environnement : scripts de build et macros des crates, code des paquets npm que lancent `npm run build` et `npx tauri build`. Lire ce que change la version, pas seulement son numéro. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- Une advisory corrigée se prouve par `cargo audit` lancé depuis la racine, là où `.cargo/audit.toml` s'applique. `npm audit` n'est pas un gate de CI. — `CLAUDE.md` « CI/CD »
### Release et mise à jour
- `release.yml` met la clé de signature de l'updater (`TAURI_SIGNING_PRIVATE_KEY`) dans l'environnement des deux étapes `npx tauri build`. S'exécutent avec elle : `beforeBuildCommand` (`npm run build`, donc `tsc`, `vite.config.ts` et les `scripts` de `package.json`), `@tauri-apps/cli` et son binaire natif, qui lit la clé, `cargo-xwin` (`--runner cargo-xwin` pour Windows) et la toolchain Rust, installés à chaque tag sans version épinglée, `src-tauri/build.rs`, les scripts de build et les macros des crates, et tout module JS qu'une directive CSS `@plugin` ou `@config` fait charger. `npm ci` tourne avant, sans elle, mais ce qu'il installe s'exécute ensuite avec elle. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `latest.json` porte une clé par paquet (`linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), contrôlée par `scripts/check-latest-json.jq` avant le `DELETE` du registre ; `scripts/build-latest-json.test.mjs` tourne dans `npm test`. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `release.yml` signe les binaires et publie le JSON d'updater que lisent toutes les installations : une clé publique de `plugins.updater` changée sans bascule prévue coupe leurs mises à jour, comme un endpoint déplacé. — `CLAUDE.md` « CI/CD » et « Fonctionnalités principales »
- Une release qui touche l'updater, ses dépendances, `release.yml` ou la config `updater` exige un cycle réel de mise à jour (étape 10 de `/release`, `docs/qa-update-cycle.md`) : la PR le signale pour la release suivante. `verify-release.sh` contrôle la release publiée. — `CLAUDE.md` « CI/CD », `.claude/skills/release/SKILL.md`
### CI et gardes
- Aucun workflow `check-*` ne filtre sur `branches:` : `branches: [main]` avait laissé sans CI quatre des cinq PR empilées de la milestone feature-gating. `check-frontend.yml` exclut par `paths-ignore` pour qu'un fichier nouveau déclenche la CI au lieu de la sauter. — `CLAUDE.md` « CI/CD », en-têtes de `check-rust.yml` et `check-frontend.yml`
- `audit.yml` est le seul canal des avis RustSec : quotidien et bloquant. L'étape `cargo audit` de `check-rust` est non bloquante par choix. — `CLAUDE.md` « CI/CD »
- Une garde affiche chacune de ses vérifications, y compris celles qui passent, et lit un canari : une garde muette ne prouve rien. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md) (règle 3), [ADR 0023](docs/adr/0023-windows-sys-pinning.md)
- Une PR qui ne touche que `audit.yml` n'a aucune CI : les `paths` de `check-rust.yml` ne le listent pas, et le `paths-ignore` de `check-frontend.yml` l'exclut. Une PR qui ne touche que `release.yml` lance `check-frontend`, dont un test exécute le texte de l'étape « Publish latest.json to package registry » avec un `curl` factice (#419) ; le reste de `release.yml` ne s'exerce qu'au tag. La revue rejoue elle-même ce qui peut l'être. — `CLAUDE.md` « CI/CD », `check-rust.yml`, `check-frontend.yml`
- Les secrets du dépôt (clé de signature et son mot de passe, jeton du registre de paquets qui remplace `latest.json`) sont lisibles par tout workflow : un workflow ajouté, une référence à `secrets` ajoutée ou un déclencheur `pull_request_target` se lit comme du code de release. — `CLAUDE.md` « CI/CD », `release.yml`
### i18n et documentation
- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`
- Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG)
- Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique »
### Règles de revue
- Un changement qui rend faux un fait cité ici (sites d'appel réseau, hôtes de `connect-src`, commandes et déclencheurs de la CI, chemins du tableau Haut) met ce fichier à jour dans la même PR. — `CLAUDE.md` « Documentation technique »
## Commandes de vérification
Les workflows font foi ; ce tableau les résume au 2026-10-18. Le runner a une capacité de 1 : les jobs se suivent, et chaque maillon d'une pile a sa CI. — `CLAUDE.md` « CI/CD »
| Workflow | Déclencheur | Commandes |
|---|---|---|
| `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `*.md` à la racine, `LICENSE`, `check-rust.yml` et `audit.yml` | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) |
| `check-rust.yml` | PR qui touche `src-tauri/**`, `.cargo/**` ou ce workflow ; lundi 06:30 UTC sur `main` ; `workflow_dispatch` | `cargo check --manifest-path src-tauri/Cargo.toml --all-targets`, garde des advisories supprimées, garde `windows-sys`, `cargo test --manifest-path src-tauri/Cargo.toml --all-targets`, `cargo audit --file src-tauri/Cargo.lock` (non bloquant) |
| `audit.yml` | chaque jour à 06:00 UTC ; `workflow_dispatch` | `cargo-audit audit --file src-tauri/Cargo.lock` (bloquant) |
| `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin), signature, `latest.json` |
`cargo check` et `cargo test` tournent sans `--locked` dans la CI ; #449 l'ajoute, et sa PR met ce tableau à jour.
En local, les mêmes commandes, depuis la racine :
```bash
npm ci && npm run build && npm test
cargo check --manifest-path src-tauri/Cargo.toml --all-targets
cargo test --manifest-path src-tauri/Cargo.toml --all-targets
cargo audit --file src-tauri/Cargo.lock # depuis la racine : .cargo/audit.toml s'applique
```
- `npm test` lancé à la racine parcourt aussi `.claude/worktrees/` (`vite.config.ts` ne l'exclut pas) : vider ce dossier avant, ou lancer la suite depuis le worktree. — `.claude/skills/release/SKILL.md` (étape 0)
- Les gardes de `check-rust.yml` se rejouent en exécutant le bloc `run:` de leur étape depuis la racine, sous `bash --noprofile --norc -e -o pipefail`, le shell du runner dans un conteneur `ubuntu:22.04`.
- Windows, pour une dépendance, une feature ou du code sous `cfg(windows)`, sur un poste où cargo-xwin est déjà installé : `cargo xwin check --manifest-path src-tauri/Cargo.toml --locked --target x86_64-pc-windows-msvc`, précédé d'un témoin sur `main` (un témoin rouge met l'outil en cause, pas la PR). Installer cargo-xwin vaut acceptation de la licence du CRT et du SDK Windows de Microsoft : une revue ne l'installe jamais. (#455)
- Un `npm test` rejoué hors CI ne compte qu'après une sonde : un test volontairement rouge doit le faire sortir en code non nul.
## Pièges de revue
Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dépôt.
- **CI d'une pile.** Chaque maillon a sa CI depuis #232 : la section « CI coverage gap » du skill (CI réservée aux PR qui ciblent `main`) ne vaut pas ici. `check-rust` ne tourne que si le diff du maillon touche ses chemins : l'état Rust d'une pile est prouvé par le dernier maillon qui en a touché. Exception : `fs_commands.rs` compile par `include_str!` les deux CSV de `src/__fixtures__/samples/`, hors de ces chemins ; une PR qui les renomme, les déplace ou les supprime casse `cargo check` sans que `check-rust` tourne. — `CLAUDE.md` « CI/CD », `src-tauri/src/commands/fs_commands.rs`
- **Un filtre `paths:` n'est pas prouvé par une PR qui touche le chemin exact.** Le glob (`src-tauri/**`) l'est par une PR qui touche un autre fichier du dossier, comme la PR #344 pour `check-rust`. — skill `pr-review` (section CI), #232
- **CI Rust et revue en fork.** Un fork qui attend la CI pour poster son verdict peut s'arrêter sans rien publier (PR #303) : poster les constats, surveiller le statut depuis la session principale, relancer la revue au vert. `check-rust` prend environ 3 min 30 avec le cache et 10 min à froid, sans compter la file du runner. — skill `pr-review` (délégation), #234
- **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447
- **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455
- **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) »
- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Jusqu'à #453, aucune garde ne détecte une clé présente dans une seule langue : comparer `fr.json` et `en.json`. — `docs/architecture.md` « Faux `t` marqué », #389
- **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452
- **Booléen lié en texte.** tauri-plugin-sql stocke un booléen JS en texte `'true'`, qu'une condition `= 1` ne trouve pas : défaut resté invisible de la v0.1.0 à #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- **Transaction hors verrou.** Un `BEGIN` et son `COMMIT` envoyés par deux `db.execute` peuvent partir sur deux connexions du pool et laisser un verrou zombie (« database is locked », v0.10.0) : toujours `withTransaction` et son handle. — `docs/architecture.md` « Accès DB et transactions »
- **Dépôt public.** Aucune coordonnée d'un tiers (courriel, adresse, téléphone), aucune clé de licence ni aucun jeton dans un fichier commité, `STATE.md`, `docs/` et `.claude/skills/` compris ; un testeur se désigne par son identifiant Logto. — `.claude/skills/issue-license/SKILL.md`

View file

@ -1,8 +1,8 @@
# STATE — Simpl'Résultat
> Derniere MAJ : 2026-10-03 (**#365 mergée — PR #368, `main` `b107cf4`, merge API en `fast-forward-only`.** Une clé révoquée par le serveur de licences retombe en Gratuite, mises à jour automatiques comprises, et y reste d'une session à l'autre : l'app la vérifie au démarrage, au plus une fois par 24 h, première requête qu'elle envoie d'elle-même. **#365 reste ouverte, en `status:blocked`** : la PR portait `Refs`, et une vérification manuelle avec la clé réellement révoquée du 2026-09-30 est due avant le tag. `/pr-review` APPROVE, ses suggestions appliquées avec preuve du rouge ; Max a tranché les deux autres. Suite ouverte : #367. Rust **187**, vitest **1348**.)
> Derniere MAJ : 2026-10-18 (**Étape 10 faite sous Linux et #421 fermée — cycle réel v0.17.1 → v0.17.2 réussi (`.deb`, invite de mot de passe vue, type non consigné), trace dans le corps de la release ; #421 vérifiée sous Linux par Max, fermée à la main (commentaire 25074).** Restent les cycles Windows et `.rpm`, et les vérifications dans l'app de #369 et des retours de la v0.17.1, non confirmées à ce jour. Aucune migration (v1→v17).)
>
> Précédente MAJ : 2026-10-01 (**#350 mergée — PR #366, `main` `0a9c9f7`, merge API en `fast-forward-only`.** L'activation envoie un identifiant dérivé de celui du système (`HMAC-SHA256` sous une clé figée, ADR 0020), et une machine déjà activée garde son activation. **#350 reste ouverte, en `status:blocked`** : la PR portait `Refs`, et une vérification manuelle est due avant le tag, sur un poste activé par la v0.15.0. `/pr-review` APPROVE, cinq suggestions appliquées avec preuve du rouge, dont une condition de retrait fausse dans l'ADR. Deux suites ouvertes : maximus-api#82 et la-compagnie-maximus#184. Rust **168**, vitest **1314**.)
> Précédente MAJ : 2026-10-18 (**Relevé du pilote #458 à 4 PR sur 20 : #464 et #465 ajoutées au tableau (commentaire 24819), niveau haut jugé juste par Max pour les deux ; rangs corrigés ici (#450 était la deuxième, #464 est la troisième, #465 la quatrième).** Les PR mergées depuis la v0.17.1 sont marquées publiées dans la v0.17.2. Vérifications dans l'app et étape 10 en cours chez Max, concluantes à date. Aucune migration (v1→v17).)
## Position actuelle
@ -10,7 +10,7 @@ v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. **v0.10.0 shippée** (2026-06-29 : Étapes 1+2 du bilan, migrations v12→v16) **puis hotfix v0.10.1** (2026-06-30, PR #230, déployé) — corrige un « database is locked » introduit en 0.10.0 (apparaissait après l'abandon d'un snapshot en cours) : tout l'accès DB est désormais sérialisé via `withTransaction` dans `db.ts` (tauri-plugin-sql = pool sqlx multi-connexions sans primitive de transaction JS → `BEGIN`/`COMMIT` en `db.execute` séparés pouvaient strander une transaction d'écriture = verrou zombie), appliqué aux 5 sites transactionnels ; + console de log live-update + API `logInfo/logWarn/logError`. 20 tables / 24 index, 16 migrations (v1→v16).
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). **Ce « 0 » n'est plus l'état courant** : des advisories publiées depuis l'ont ramené à 3, traitées en #311 le 2026-07-27 → **2 restantes, acceptées** (`react-router`, cf. entrée du jour). Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). **Ce « 0 » n'a pas tenu** : des advisories publiées depuis l'ont ramené à 3 (#311, 2026-07-27, 2 acceptées sur `react-router`), puis à 8 ; **0 de nouveau au 2026-10-10** par le rafraîchissement du lock de #358 (mergé le 2026-10-12), qui ferme aussi #317. Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
**v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe).
@ -18,24 +18,67 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
**v0.15.0 shippée** (2026-09-12, tag `v0.15.0` → CI release run 402, 7 artefacts + `latest.json` vérifiés) — première version où le gating par édition est actif : sans clé, Budget, Ajustements, rapports avancés, profils multiples, Bilan et mises à jour automatiques sont verrouillés (données intactes, ADR 0017). Les deux testeurs (Max, Michel) ont une clé Premium émise avant le tag. `[Unreleased]` vide. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` **consignée le 2026-09-13** : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs, trace rédigée pour le corps de la release Forgejo (invite polkit Linux non consignée, `.rpm` hors test → #320).
**v0.16.0 shippée** (2026-10-03, tag `v0.16.0` → CI release run 451, 7 artefacts + `latest.json` vérifiés, registre de l'updater en 0.16.0) — première version vendable de bout en bout : « Obtenir Base » ouvre la page produit (#346), le client de licence parle `/v1` et nomme chaque refus (#270), l'édition ne dépend que de la clé (#347), l'activation envoie un identifiant dérivé (#350), une clé révoquée retombe en Gratuite après un contrôle au démarrage (#365), et `rustls` 0.23.45 (#348). `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Sur la release publiée : #365 vérifiée et fermée le 2026-10-03, étape 10 de `/release` faite sous Linux le même jour (trace dans le corps de la release) ; étape 10 sous Windows faite par Michel (confirmée le 2026-10-10) ; #350 fermée et #346 vérifiée le 2026-10-04.
**Onboarding phase 1 mergée sur `main`** (2026-10-04, `667e058`, publiée dans la v0.17.0) — milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 (#351-#357, PRs #377-#383, produites par `/autopilot` le 2026-10-03 puis relues une à une par `/pr-review`) : états vides guidés (dashboard, Import, Transactions, Budget), liste de démarrage à 6 cases calculée depuis la base, bouton « Créer un dossier d'exemple », guide « Exporter un CSV depuis votre banque » (Desjardins, Tangerine, RBC, BNC, BMO). Aucune migration DB (v1→v17). Suivis : #384 (non bloquants) et #371 (BMO sur compte réel, saisie de Max).
**Suites de licence mergées sur `main`** (2026-10-05, `2cbc886`, publiées dans la v0.17.0) — milestone `planned-2026-10-03-suites-licence` fermée 3/3 (#373, #370, #367 ; PRs #386 → #388, produites par `/autopilot` le 2026-10-04, relues par trois `/pr-review` APPROVE, suivis appliqués en tête de pile) : contrôle d'une clé révoquée dès sa saisie (au plus une fois par 24 h et par clé), « Retirer la clé » dans la carte Licence (place libérée d'abord sur une machine activée, contrôle quotidien arrêté), jeton d'une ancienne licence retiré sur un refus des cours ou à l'ouverture de la liste des machines. Aucune migration DB (v1→v17). Suivi : #389 (huit autres fichiers de tests au faux `t` brut).
**v0.17.0 shippée** (2026-10-05, tag `v0.17.0` sur `07255e1` → CI release run 491 en 22 min, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.0) — onboarding phase 1 et suites de licence. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` non exigée (chaîne de mise à jour inchangée), raison écrite sur la release. Vérifications manuelles des suites de licence faites sur la release publiée (confirmées le 2026-10-10).
**Parcours de démarrage guidé mergé sur `main`** (2026-10-08, `ee17f31`, publié dans la v0.17.1) — milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 (#396-#401, PRs #402 → #407, produites par `/autopilot` le 2026-10-08, relues par six `/pr-review` APPROVE, suivis appliqués en tête de pile) : bandeau « Démarrage n/N · Étape suivante » sur chaque page, cartes d'étape avec mise en évidence du contrôle sur Import, Transactions et Budget, état partagé par un fournisseur et canal d'événements de données (ADR 0022), règle D9 (un profil déjà actif trouve le parcours masqué). Aucune migration DB (v1→v17). Suivis : #408 (`db.ts` hors verrou pendant une transaction) et #409 (harnais de test).
**v0.17.1 shippée** (2026-10-08, tag `v0.17.1` sur `e4db715` → CI release run 508, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.1) — parcours de démarrage guidé et #390 (étape 5 de la liste, rattrapage des migrations des profils secondaires, dossier des bases sous Linux). Numéro de correctif choisi par Max malgré les entrées Added. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` non exigée (chaîne de mise à jour inchangée), raison écrite sur la release. Vérifications manuelles faites sur la release publiée (parcours guidé, #390), complétion confirmée le 2026-10-10.
**Chaîne de release et dépendances mergée sur `main`** (2026-10-12, `9e8c41b`, publiée dans la v0.17.2) — milestone `planned-2026-10-10-release-deps` fermée 4/4 (#376, #345, #320, #358, plus #317 ; PRs #414 → #417 produites par `/autopilot` le 2026-10-10 et relues par quatre `/pr-review` APPROVE, puis PR #418 de suivis des revues, relue à son tour) : Node 24 dans les trois workflows encore en Node 20 ; garde `Verify windows-sys pinning` dans `check-rust` (ADR 0023) ; `latest.json` avec une clé par paquet Linux (`linux-x86_64-deb`, `linux-x86_64-rpm`) et sans clé générique, contrôlé avant le `DELETE` du registre par `scripts/check-latest-json.jq` ; lock npm rafraîchi, `npm audit` à 0. Aucune migration DB (v1→v17). `[Unreleased]` porte un Corrigé (#320) et un Sécurité (#358). Suivi : #419 (point 1 avant le prochain tag).
**#423 mergée sur `main`** (2026-10-12, `8898213`, publiée dans la v0.17.2) — les champs « Catégorie parente » (formulaire de catégorie) et « Catégorie » (dialogue « Nouveau mot-clé ») utilisent `CategoryCombobox` : saisie qui filtre, ordre hiérarchique, noms du seed traduits. Liste des parents : racines et niveau 1, moins la catégorie éditée, profondeur réduite de la hauteur de son sous-arbre (pas de 4ᵉ niveau), parent courant toujours gardé ; seule garde aujourd'hui, `updateCategory` ne vérifie rien (#393). Le dialogue mot-clé prend la liste des Transactions (actives et saisissables, D2) et ne garde la catégorie pré-remplie que si elle en fait partie. `[Unreleased]` porte un Modifié. Suivi : #429. Aucune migration (v1→v17).
**Fiabilité des tests et de `db.ts` mergée sur `main`** (2026-10-13, `c17bf5d`, publiée dans la v0.17.2) — milestone `planned-2026-10-10-fiabilite-tests` fermée 4/4 (#408, #409, #389, #395 ; PRs #433 → #436 produites par `/autopilot` le 2026-10-12, relues par quatre `/pr-review` APPROVE, cinq commits de suivi en tête de pile) : `withTransaction` remet au rappel son propre handle, révoqué à la fin, toute autre requête attend derrière le verrou ; changement de profil en une seule section exclusive avec numéro de génération ; alerte Journal après 15 s d'attente ; harnais `src/test/hookHarness.ts` et faux `t` marqué `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts` ; le Journal nomme la copie d'un rattrapage arrêté et ne contient plus de chemin, une seule copie gardée par profil, la page d'erreur du démarrage disparaît quand le profil s'ouvre après le délai ; badge « non garanti » traduit. `[Unreleased]` porte un Modifié et deux Corrigé de plus. Suivi : #437. Aucune migration (v1→v17).
**Retours d'onboarding de la v0.17.1 mergés sur `main`** (2026-10-15, `934bcbf`, publiés dans la v0.17.2) — milestone `planned-2026-10-12-retours-onboarding-v0171` fermée 8/8 (#424, #422, #427, #426, #430, #431, #425, #432 ; PRs #438 → #445 produites par `/autopilot` le 2026-10-13, relues par huit `/pr-review` APPROVE sans bloquant) : grille Budget émise en profondeur et sous-totaux en bas rattachés à leur bloc (`reorderRows`, corrige aussi Budget vs réel et Comparables) ; encadré « Structure attendue » (un sous-dossier par compte), avertissement des CSV posés à la racine (`scan_import_folder` → `ImportFolderScan`), nom de source en lecture seule = sous-dossier ; cartes Transactions (« Ajouter comme mot-clé ») et Budget (feuilles, colonne Annuel) ; aide et guide périmés corrigés ; parcours à huit lignes avec deux étapes de visite (Catégories, Rapports) cochées par « J'ai compris » ou un clic sur Tendances, initialisées une fois par profil sans transaction (ADR 0022 amendé). `[Unreleased]` porte des Added, Changed et Fixed de plus. Suivi : #446. Aucune migration (v1→v17).
**#447 mergée sur `main`** (2026-10-16, `1763a9f`, publiée dans la v0.17.2) — `rustls` 0.23.45 → 0.23.46 (PR #448 : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée) corrige RUSTSEC-2026-0346 à -0349, publiées le 2026-10-14, qui rougissaient `audit.yml` depuis le 2026-10-15 sans que le lock ait bougé depuis la v0.17.1. Aucune des quatre n'est atteignable (`rustls` n'est atteint que par `tauri-plugin-updater` → `reqwest` 0.13), mais la règle 1 de `.cargo/audit.toml` demande de corriger plutôt que de supprimer ; `audit.yml` est de nouveau vert sur `main` (run 556). Pas de release corrective : le bump part avec le prochain tag, qui devra dérouler le cycle réel de mise à jour. `[Unreleased]` porte un second Sécurité. Suivis : #449 et agent-defenseurs#186. Aucune migration (v1→v17).
**#369 mergée sur `main`** (2026-10-17, `a8c3fd6`, publiée dans la v0.17.2) — la Répartition (bouton de la page Transactions) est réservée à l'édition Base, comme l'écran de déverrouillage le promettait (`upsell.features.adjustments`) : en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne ; tant que la licence n'est pas `ready`, bouton inactif, ni cadenas ni fenêtre ; les répartitions existantes restent comptées et gardent l'icône orange. Pièces : `splitAccessFor` (`src/shared/splitGate.ts`, `open`/`locked`/`pending`, plus strict que `profileGate` : `allowed` ne court-circuite pas `ready`) et la constante `SPLIT_FEATURE`, `SplitButton`, `UpsellDialog`, prop `splitAccess` obligatoire de `TransactionTable`, calculée par `TransactionsPage` depuis `useEntitlement(SPLIT_FEATURE)` ; ADR 0017 amendé, guide, docs intégrées FR/EN et `architecture.md` à jour, `[Unreleased]` porte un Modifié de plus. Suivis : #451 (fenêtre d'incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (reclasser une transaction répartie laisse sa ligne de compensation dans l'ancienne catégorie, irréparable en Gratuite depuis #369). Aucune migration (v1→v17).
**`REVIEW.md` mergé sur `main`** (2026-10-18, `24da72b`) — niveau d'une PR (haut, normal, bas) fixé par les fichiers que touche son diff, jamais par son auteur ; tableau Haut de 21 lignes (licence et jetons, migrations et bases de profils, release et code exécuté avec la clé de signature, dépendances qui changent la confiance, CI qui pourrait passer au vert à tort, `.claude/settings.json` et `.mcp.json`, et une règle qui échoue fermée pour tout fichier ajouté, renommé ou supprimé à la racine ou directement dans `src-tauri/`) ; neuf zones, chaque règle sourcée par une section du `CLAUDE.md` ou un ADR ; commandes de vérification ; onze pièges, dont les trois du skill global `pr-review`, corrigés pour ce dépôt ; script de calibration versionné `scripts/review-levels.py` (`--check` compare ses motifs au tableau). `CLAUDE.md` y renvoie. Depuis #457 (fermée le 2026-10-18), `/pr-review` le lit sur `main` et annonce le niveau en tête de chaque verdict ; le pilote #458 mesure les 20 PR suivantes, la première étant #459 ; relevé au 2026-10-18 (commentaire 24819) : 4 sur 20, #459, #450, #464 et #465, toutes de niveau haut, jugé juste par Max sauf pour #450 (trop haut, #462). Suivis : #461 et #462. Aucune migration (v1→v17).
**#455 mergée sur `main`** (2026-10-18, `c5efc38`, publiée dans la v0.17.2) — `tauri-plugin-updater` compilé sans sa feature `zip` (`default-features = false`, `rustls-tls` gardé) : `zip`, `arbitrary` et `derive_arbitrary` sortent du lock (666 → 663 paquets, aucun ajouté), et avec eux RUSTSEC-2026-0367 à -0369, retirés et non supprimés dans `.cargo/audit.toml` ; `tar` quitte le graphe Linux, d'où le canari de la garde des suppressions de `check-rust` passé de `tar` à `webkit2gtk` (ADR 0018 amendé ; `architecture.md`, `docs/qa-update-cycle.md` et skill `/release` à jour). `[Unreleased]` porte un troisième Sécurité. `audit.yml` vert sur `main` (run 564). Suivi : #463. Aucune migration (v1→v17).
**#419 mergée sur `main`** (2026-10-18, `c859405`, publiée dans la v0.17.2) — `scripts/check-latest-json.jq` exige exactement un document (`length == 1 and (.[0] | …)`) et l'étape « Publish » de `release.yml` l'appelle avec `-s` : sous jq 1.6, la version du conteneur de release, un `latest.json` vide passait (rc 0) et un fichier à deux documents était jugé sur le dernier. `scripts/build-latest-json.test.mjs` (28 → 36 tests) exécute le texte exact de l'étape « Publish » avec un `curl` factice : `DELETE` puis `PUT` sur un manifeste valide, sortie 1 sans aucune requête sur un refus. `check-frontend` ne saute plus `.claude/**` ni `release.yml` (`REVIEW.md`, `CLAUDE.md` et `architecture.md` à jour) ; commentaire périmé de `release.yml:104` corrigé (point 3 de #461) ; `docs/qa-update-cycle.md` note qu'un build `tauri dev` sous Linux affiche `TargetsNotFound`, attendu depuis #418. Aucune entrée de CHANGELOG (rien de visible pour l'utilisateur). Aucune migration (v1→v17).
**#421 mergée sur `main`** (2026-10-18, `ff9656d`, publiée dans la v0.17.2, vérifiée sous Linux et fermée le 2026-10-18) — sur desktop, le script d'initialisation de `tauri-plugin-dialog` 2.6.0 remplace `window.confirm` par une fonction asynchrone : `confirm(...)` rendait une promesse, toujours vraie, et six confirmations laissaient l'action partir avant la réponse (profil et sa base supprimés même sur « Annuler » ; suppressions de catégorie, d'ajustement, de modèle de budget et de catégorie du Bilan ; les deux confirmations du retrait de la clé de licence), dans toutes les versions publiées. `confirmAction()` (`src/services/confirmService.ts`) appelle `ask` de `@tauri-apps/plugin-dialog` 2.6.0 (ajouté à `package.json` à la version du crate, une seule clé de lock) avec une icône d'avertissement et les boutons `common.confirm` et `common.cancel` dans la langue de l'app ; un échec vaut refus et passe par `logWarn`. Les six sites l'attendent avec `await`, et `performRemoveLicense` prend une confirmation asynchrone. Garde `src/__integration__/native-dialog-guard.test.ts` (34 tests) : aucun `confirm` ni `alert` natif dans `src/` (nu, par `window`, `globalThis` ou `self`, en `?.(`, par index, déstructuré), toute confirmation attendue, aucune autre mention de `confirmAction` (alias). `architecture.md` à jour (service, ligne du plugin) ; `[Unreleased]` porte un Corrigé de plus. Suivi : #467. Aucune migration (v1→v17).
**v0.17.2 shippée** (2026-10-18, tag `v0.17.2` sur `f42f360` → CI release run 570, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.2 avec les trois clés `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`) — tout ce qui s'était accumulé depuis la v0.17.1 : retours d'onboarding (#424-#432), Répartition en Base (#369), clé `.rpm` de `latest.json` (#320), Node 24 dans les workflows (#376), garde `windows-sys` (#345), fiabilité des tests et de `db.ts` (#408, #409, #389, #395), menus de catégorie (#423), `rustls` 0.23.46 (#447), updater sans `zip` (#455), garde de `latest.json` sous jq 1.6 (#419), confirmations attendues (#421). Numéro de correctif choisi par Max malgré les entrées Added. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` obligatoire (`release.yml` et `rustls` changent) : cycle Linux `.deb` v0.17.1 → v0.17.2 réussi le 2026-10-18 (Max, trace dans le corps de la release), Windows et `.rpm` pas encore déroulés.
## Decisions recentes
- 2026-10-03 : **#365 mergée (PR #368, `main` `b107cf4`) — une clé révoquée retombe en Gratuite, et l'app envoie pour cela sa première requête non sollicitée.** Constat du go-live (09-30) : une licence remboursée, révoquée côté serveur, restait pleinement fonctionnelle, parce que l'édition se résout hors ligne depuis la clé et que le drapeau de #347 (D13) ne vivait que la session. `/analyze` (10-01) : **D1** contrôle au démarrage, au plus une tentative par 24 h ; **D2** révocation nommée là où le verrou apparaît, sans bandeau global ; **D3** fail-open ; **D4** un seul état. **Livré** : `license.check` à côté de la clé (`{key_sha256, attempted_at, revoked}`, condensé SHA-256, jamais la clé) ; `entitlements_from_dir` rend `("free", [])` pour une clé révoquée, `auto-update` compris ; `check_license_revocation` (`POST /v1/licenses/verify`, produit et clé seulement, rien sans clé valide, tentative écrite avant l'appel, seul `valid:true` lève et seul « révoquée » pose) ; l'activation, la liste et la désactivation enregistrent un `license_revoked` sous la clé envoyée ; `LicenseContext` lit `info.revoked` (fin de `markRevoked`), lance le contrôle une fois par démarrage, écrit son issue dans le Journal et ne recharge que si l'état change ; une clé révoquée ressaisie reste en Gratuite ; carte Licence, `UpsellGate` et carte Mises à jour nomment la révocation ; amendement #365 de l'ADR 0017. **Écart tranché par Max le 10-02**, après trois échecs du plan checker : la route des cours **nomme** le 403 `license_revoked` sans l'enregistrer, parce que `/v1/prices` juge la licence du jeton d'activation, que le client ne relie pas à la clé, et que la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle (`git show v0.15.0`) : l'enregistrer aurait rétrogradé une clé valide. Ce jeton périmé est suivi en **#367**. **Preuves** : 33 mutations Rust et 27 front de l'auteur, toutes rouges ; la première passe en avait laissé 5 vertes pour 4 manques, corrigés dans `4225dad`, dont la tentative écrite après la requête (un test lit maintenant le fichier d'état depuis le serveur mockito, pendant que la requête est en vol) et l'intervalle de 24 h que suivaient les lignes construites depuis la constante (mesuré sur `e24c30f` : 184 verts avec l'intervalle à 1 h). Contrat sondé en production : `verify` d'une clé factice répond `200 {"valid":false,"reason":"not_found"}`. **`/pr-review` APPROVE** (37 mutations du relecteur, 35 rouges). Appliqué dans `b107cf4` : le test « une tentative non inscriptible n'envoie rien » (mutation de la revue : 0 rouge sur 185, puis 1 sur 187) et celui du verdict non inscriptible ; `storage` reformulé, puisqu'il couvre aussi un verdict non écrit. Décisions de Max le 10-03 : une clé révoquée **reste contrôlée** une fois par 24 h tant qu'elle est enregistrée, et c'est déclaré dans le guide (aucune interface ne retire une clé : seul l'enregistrement d'une autre l'arrête) ; la carte Mises à jour nomme la révocation, la page d'erreur non. Rust 168 → **187**, vitest 1314 → **1348**, CI lue dans les logs (runs 446/447 puis 449/450). Hors PR : la-compagnie-maximus#184 corrigée, son commentaire du 10-02 disant à tort que l'édition Gratuite ne fait jamais cette requête. **Non gardé par les tests** : l'effet du fournisseur React qui lance le contrôle, l'enregistrement de la commande dans `lib.rs` et la réponse réelle du serveur pour une clé révoquée, d'où la vérification manuelle. **Acquis** : la fermeture `match_request` de mockito tourne pendant que la requête est en vol, ce qui prouve un ordre « écrit avant la requête » ; une suite Rust rouge imprime `error: test failed`, donc seul `could not compile` signale une erreur de compilation. Aucune migration DB (v1→v17). (ref #365, PR #368, #367, la-compagnie-maximus#184)
- 2026-10-01 : **#350 mergée (PR #366, `main` `0a9c9f7`) — l'activation envoie un identifiant dérivé, et l'issue reste ouverte pour une vérification manuelle due avant le tag.** `/warmup` (audit quotidien vert, 10 runs d'affilée), puis `/analyze 350` : le corps du 09-24 n'avait jamais été édité (sa timeline ne porte que des références), et #270, #347 et le go-live l'avaient périmé sur quatre points — « avant go-live », « retomberaient en Gratuite » (faux depuis D12), `current_entitlements` qui ne compare plus rien au jeton, `get_machine_id` sans appelant. Trois sites manquaient : la suppression du jeton dans `deactivate_machine_impl`, le marqueur « Cette machine » de `LicenseCard`, et le Bearer des cours, qui envoie un jeton antérieur avec son claim brut. Décisions de Max : **D1** une machine déjà activée garde son activation (jeton brut accepté) ; **D2** le critère « aucun identifiant brut ne quitte le poste » est borné aux activations faites avec la version corrigée ; **D3** le client ne suppose rien du serveur. Deux suites ouvertes dans leurs dépôts : maximus-api#82 (identifiants bruts déjà stockés, lignes désactivées comprises) et la-compagnie-maximus#184 (la politique de confidentialité ne déclare aucune donnée d'activation). **Livré** : `derive_machine_id` (`HMAC-SHA256` sous la clé figée `com.simpl.resultat/license-machine-id/v1`, 64 caractères hexadécimaux, identifiant vide refusé) ; `MachineIds` (dérivé et brut) ; activation qui dérive juste avant l'appel et exige le dérivé dans le jeton reçu ; `is_this_machine` calculé côté Rust pour les deux formes ; règle de suppression du jeton à la désactivation ; `get_machine_id` retirée et `ActivationStatus` sans identifiant ; ADR 0020. Aucune dépendance ajoutée, `Cargo.lock` intact. **Écarts au corps de l'issue**, déclarés dans la PR : la règle de suppression est plus large que la phrase de l'issue (toute ligne de ce poste emporte le jeton, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée), parce que la règle littérale laissait un jeton expiré sur le disque ; la liste, la désactivation et l'état d'activation n'échouent plus sur un identifiant du système illisible. Plan checker `pass` au premier tour, ses remarques mineures intégrées : tests épinglés sur les vecteurs en littéraux, « aucun appel » observé contre un mock et non déduit. **`/pr-review` APPROVE**, cinq suggestions appliquées dans `0a9c9f7` : la condition de retrait de l'ADR, reprise du corps de l'issue, comptait deux ans depuis la v0.15.0, or une v0.15.0 gratuite ne se met plus à jour et peut encore activer en brut des mois plus tard, donc la borne est la dernière émission d'un jeton brut, connue du serveur seul ; « plus aucune commande ne remet l'identifiant du système au webview » était plus large que le code (la liste porte encore la ligne brute d'une activation antérieure) ; deux lignes de test ; la seconde ligne que crée une réactivation tant que le serveur n'a pas rehaché ses lignes ; la vérification manuelle par la valeur et non par la longueur. **Preuves** : 17 mutations de l'auteur rouges ; la mutation de la revue restée verte remesurée à 0 rouge sur `e5662ad`, puis 1 rouge sur `0a9c9f7` ; vecteurs recalculés hors du code ; Rust 151 → **168**, vitest 1311 → **1314** ; CI lue dans les logs sur les deux têtes (runs 441 à 444). **Non gardé par les tests** : le câblage des commandes à l'identifiant réel du poste, d'où la vérification manuelle. **Acquis** : mockito sert le premier mock correspondant qui attend encore des hits, donc un mock `expect(0)` posé à côté d'un mock valide ne prouve rien ; un seul mock avec `match_request`. Merge par l'API en `fast-forward-only` épinglé sur la tête, branche supprimée. Aucune migration DB (v1→v17). (ref #350, PR #366, maximus-api#82, la-compagnie-maximus#184)
- 2026-09-30 : **#347 mergée (PR #364, ferme aussi #360) — l'édition ne dépend plus que de la clé, et deux revues ont trouvé ce que les tests ne gardaient pas.** `/analyze` (09-29) a revérifié le corps du 09-24 contre `main` et `maximus-api`, puis fait trancher deux décisions par Max. **D12** : le jeton d'activation ne fait plus jamais retomber en Gratuite. Une clé copiée sans son jeton gardait déjà son édition, donc la règle ne protégeait rien ; elle piégeait la restauration légitime de données (#360) et toute machine activée au bout des 2 ans du jeton. **D13** : clé révoquée = drapeau de session. Livré : `LicenseError` étendu aux erreurs de saisie (`invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`), nommées à la source par `store_license_in` et `read_license_in` ; édition résolue depuis la seule `license.key` (`entitlements_from_dir`) ; `LicenseKeyForm` extrait ; compteur n/limite, dates `fr-CA`/`en-CA`, « Sans échéance » au-delà de 50 ans ; tests de #363 absorbés. **Écart au corps de l'issue** : le drapeau révoqué vit dans `LicenseContext`, parce que le plan checker a vu que le reducer du hook meurt avec la carte. **Revue 1 (APPROVE)** : 3 mutations vertes (état « Activée » non testé à son point de décision, filtre des erreurs sous Machines, coche verte) et un défaut préexistant, le jeton d'une clé précédente qui survivait à l'enregistrement d'une autre clé (« Activée » sans « Activer », cours refusés au nom de l'ancienne licence). Corrigés dans `3f90635` : `activation_status_in`, jeton supprimé au changement de clé, carte remontée par `licenseIdentity`, message `invalidSignature` qui renvoie d'abord à la copie (`jsonwebtoken` vérifie la signature avant de décoder la charge, donc une copie abîmée finit souvent en `InvalidSignature`). **Revue 2 (APPROVE)** : course activation / nouvelle clé, badge « Activée » d'une clé révoquée, renvoi de l'ADR ; corrigés dans `cdf4df8`. Preuves : reproduction rouge sur `5d549d6`, puis 27 + 8 + 4 mutations rouges ; Rust 128 → 151, vitest 1266 → 1311, CI vérifiée dans les logs à chaque tête. Trouvé en route : le 403 `license_revoked` des cours s'affiche comme « abonnement premium requis » (consigné sur #365). Merge par l'API en `fast-forward-only`, milestone `planned-2026-09-24-paiements` fermée 3/3. Aucune migration DB (v1→v17). (ref #347, #360, #363, PR #364)
- 2026-09-27 : **#346 mergée (PR #362) — « Obtenir Base » ouvre enfin la page produit, et la revue a trouvé que le câblage des boutons n'était gardé par aucun test.** Livré par la PR : `upsellCtaFor` (règle pure : Base achetable, Premium désactivé avec la note « pas encore offert en ligne ») ; `PurchaseButton`, `HaveKeyButton` (rendu seulement sous un routeur, via `useInRouterContext`) et `DownloadLatestButton` partagés ; `UpsellGate` suit la règle et `ProfileFormModal` le réutilise (fin du mode upsell compact) ; carte Mises à jour et page d'erreur en Gratuite ; texte de la carte Compte tranché le 09-24 ; amendement de l'ADR 0017. **`/pr-review` APPROVE**, avec un constat principal : la PR supposait que sans jsdom seules les fonctions pures se testent, or `renderToStaticMarkup` tourne dans l'environnement node de vitest et `FilterPanel.test.tsx` appelait déjà un composant comme une fonction ; six mutations des composants passaient la suite sans un rouge. **Appliqué dans `170b22d`** : 9 tests de rendu (`UpsellCtas.render.test.tsx`) ; un `logError` sur l'échec d'`openUrl` dans les trois sites qui ouvrent la page produit (`initLogCapture` ne capte pas `unhandledrejection`, donc rien n'arrivait dans Journaux) ; un commentaire dans `upsellCtaFor` (le bouton ne vend que Base, donc rendre Premium achetable sans lui donner le palier ferait vendre Base sur le Bilan) ; la réserve sur la carte Compte dans l'ADR (`check_account_edition` accorde encore Premium à un abonnement actif, chemin dormant tant qu'aucun abonnement n'est vendu) ; l'étape d'achat placée avant la saisie de la clé dans le guide et les docs intégrées ; trois corrections de CHANGELOG. **Preuve** : 12 mutations rejouées sur la tête finale dans un worktree jetable (`npm ci`), 11 rouges avec code de sortie 1. La 12e (`ProfileFormModal` sans `onNavigate={onClose}`) demande un clic et reste à vérifier à la main ; `LicenseCard` n'a pas de test, ses hooks réels empêchent de l'appeler comme une fonction. Merge par l'API en `fast-forward-only` épinglé sur `head_commit_id`, #346 fermée par `Fixes`. Aucune migration DB (v1→v17). (ref #346, PR #362)
- 2026-09-27 : **#270 mergée (PR #359) — le client de licence parle `/v1`, nomme chaque refus, et trois revues ont trouvé ce que les tests ne gardaient pas.** `/fix-issue 270` sur le corps réécrit par le `/plan-run` du 09-24. Plan vérifié par le plan checker en 2 itérations (la 1re : un refus serveur sur cinq sans test). **Livré** : `POST /v1/licenses/{activate,deactivate,verify}` avec `product` et clé rognée dans `*_inner` ; `LicenseError` sérialisé `{code}` comme `FetchPriceError`, lu dans `error.code` de l'enveloppe (un 404 n'est jamais « route absente » ; un 404 sans code connu est `server`) ; verify répond **toujours 200**, donc `{valid:false}` devient une erreur codée au lieu de la liste vide qui affichait « Aucune machine activée » pour une clé révoquée ; corps d'un non-2xx lu sans exiger de JSON (page HTML 502 → `server`), corps 2xx illisible → `invalid_response`. TS : `parseLicenseError` + table vers 14 clés `license.errors.*` (pluriels i18next pour le délai), wrappers typés `{ok, value|error}`, hook `useLicenseMachines` à reducer exporté (erreur d'activation distincte de l'erreur de liste, parcours limite de machines en 4 requêtes sous le 5/min/IP du serveur, boutons inactifs pendant une désactivation ou un rechargement), `purchaseUrl` dans `src/shared/`. Désactivation idempotente (`no_active_activation` = fait), écart assumé et documenté. **Méthode** : chaque défaut d'origine réintroduit sur l'arbre final et compté en rouge (11 mutations) ; puis **3 passes `/pr-review`, toutes APPROVE**, dont les mutations ont trouvé 7 comportements corrects mais non gardés (un 404 lu comme clé introuvable, une table de messages comparée à elle-même, l'idempotence qui avale `license_not_found`, la relecture du statut après désactivation, `machine_name` à `null`, un 2xx illisible, deux courses de boutons) — chacun épinglé puis prouvé rouge. Une puce du CHANGELOG retirée (l'édition ne change pas visiblement après une activation) et une reformulée (l'ancienne URL est redirigée par le site depuis la-compagnie-maximus#153). **Hors PR, trouvés en route** : maximus-api#70 (réactivation d'une machine déjà désactivée → 500 ; `UNIQUE(license_id, machine_id)` sans condition sur `is_active` + `INSERT` sans `onConflict` ; reproduit sur Postgres embarqué ; upsert recommandé, sans migration ; ajouté comme prérequis sur maximus-api#62) ; #360 (jeton d'une autre machine → Gratuite, section Machines masquée, ressaisie de clé inopérante ; trois pistes, `status:triage`). Merge par l'API (rebase ff, `main` `0c9b8f8`). Aucune migration DB (v1→v17). (ref #270, PR #359, #360, maximus-api#70)
- 2026-09-24 : **Vague paiements planifiée via `/plan-run spec-paiements` — deux milestones `planned-2026-09-24-paiements`, un défaut de licence trouvé en revue, et des clés de 100 ans.** Préambule : `/warmup` (audit quotidien vert, runs 415/416), puis commit des skills en attente depuis le 09-13 (`11e12ab` : `/issue-license`, `verify-release.sh`, garde « clés testeurs avant le tag » de `/release`) — **le courriel personnel de Michel retiré avant le premier commit** : le dépôt `Simpl-Resultat` est public et l'adresse n'était dans aucun fichier suivi ; celui de Max conservé, déjà présent dans 3 fichiers suivis. Le classifier a refusé de charger `purge-secret-forgejo` (« Git Destructive ») : scan des lignes ajoutées fait à la main. **Étape 0** : spec d'origine dans la-compagnie-maximus (07-11), corps issus de `/analyze 270` (09-13) jamais passés en `/review-spec`, donc **toutes les affirmations revérifiées** contre le code et la prod. Résultats : `verify` répond **toujours 200** (`{valid:false}` → le client affiche « Aucune machine activée » pour une clé révoquée) ; un 404 d'activate est un `license_not_found` métier, pas une route absente ; `ErrorPage` est hors routeur ; `machine_limit` est déjà dans le JWT local ; le 429 `rate_limit_exceeded` n'était traité nulle part ; `FetchPriceError` est le modèle d'erreur à reproduire. Décisions de Max D1-D8 : #153 dans la vague (seule réparation du bouton « Acheter » des v0.15.0 gratuites, qui ne recevront jamais #270) ; 409 → message + liste dépliée via verify ; note Premium « pas encore offert en ligne » ; texte exact de la carte Compte ; `removeLicense` retirée ; Windows d'abord sans détection ; #270 établit le contrat des codes d'erreur ; périmètre inchangé. **`/review-spec` (3 experts) → 🔴** : la tâche D2 effaçait le message 409 (`loadMachines()` commence par `setMachineError(null)`) et « Aucune machine » restait affichée en erreur ; le repli `activateError` (« Activation failed » en EN) contredisait #270. Constats chiffrés revérifiés à la source, aucun réfuté. **Le défaut de fond** : `signActivationJwt` signe le jeton d'activation pour **2 ans**, la clé pour 10, et le client valide le jeton avec `strict_validation()` → une machine qui a cliqué « Activer » retombe en Gratuite au bout de 2 ans, alors qu'une clé jamais activée garde son édition ; « Activer » ne rappelait pas `refresh()`. D10 : un jeton expiré mais signé pour cette machine compte comme absent (#347), ce qui corrige aussi les jetons déjà émis. **Durée des clés** : retirer l'expiration est impossible (tous les binaires installés exigent `exp`, CWE-613) ; 100 ans passe partout, et les 10 ans de la décision #15 n'étaient que le maximum du schéma de `/generate` → D9 : 100 ans hors vague (maximus-api#63, et la-compagnie-maximus#157 pour les CGV, dont « mises à jour pendant sa durée » est à reformuler) ; l'état « clé expirée le {date} » est abandonné, « Sans échéance » au-delà de 50 ans. D11 : lien « Télécharger la dernière version » en Gratuite, sans appel réseau. Lot de 9 corrections intégré : hook `useLicenseMachines` à reducer testé, `PurchaseButton` partagé, délai de 5 s et repli complet côté site, routes `/telecharger/*` et repli `latest.json` retirés, redirection sans langue, etc. **Mécanique** : milestones créées (simpl-resultat id 87, site id 88) plutôt que renommage de `spec-paiements`, qui reste le parapluie des trois dépôts ; #350 ouverte (hachage HMAC de l'identifiant machine, avant go-live). Step 9 vérifié sur l'état réel de Forgejo. **Gotcha skills** : `plan-run` Step 6bis laisse entendre qu'un seul `/autopilot <nom>` couvre deux milestones homonymes, mais `autopilot` refuse le multi-dépôts (`SKILL.md:53`) → un run par dépôt. Aucune migration DB (v1→v17). (ref #270, #346, #347, #350, la-compagnie-maximus#153/#157, maximus-api#63)
- 2026-09-21 : **RUSTSEC-2026-0285 (`rustls`) corrigée — l'audit quotidien rouge depuis 7 jours, et la prémisse de #345 restreinte par la mesure.** Trouvée au `/warmup` : `audit.yml` échouait tous les matins depuis le **2026-09-15** (runs 405→411), dernier vert le 404 du 09-14, sans aucun push sur `main` — le dernier commit était `97ee3c3` du 09-13. Une seule vulnérabilité : **RUSTSEC-2026-0285** / GHSA-2mjx-qc3c-rqvc, `rustls` 0.23.36, datée du **09-14**, CVSS 5.3 medium, vecteur `C:L/I:N/A:N`. L'advisory est parue **après** le tir du 09-14 à 06:00 UTC, d'où un premier rouge dès le lendemain — propagation plus rapide que les deux jours de `h2`. **Ce n'est pas un contournement** : rustls acceptait des messages de poignée de main TLS 1.3 au mauvais niveau de chiffrement lorsqu'ils suivaient un changement de clé dans le même enregistrement (un `EncryptedExtensions` en clair collé au `ServerHello`), là où la RFC 8446 §5.1 impose de fermer la connexion ; la transcription restant authentifiée, un attaquant en position réseau ne peut ni altérer ni compléter une poignée de main. **Atteignabilité par triple explicite**, jamais `--target all` : arbre identique sur les deux cibles livrées, mais **un seul chemin** — `tauri-plugin-updater → reqwest 0.13.2`, la mise à jour automatique. Plus étroit qu'`h2` en #343 : le `reqwest 0.12.28` déclaré en `Cargo.toml:43` (licence, auth, cours, rétroaction) passe par `hyper-tls → native-tls → openssl` et est **absent** de l'arbre inverse de `rustls`. Correctif publié + crate atteignable = **règle 1** de `.cargo/audit.toml` : corrigé, pas suppressé ; ni le fichier de suppressions ni le garde-fou (`CRATES="rsa"`) ne bougent. **Le piège attendu ne s'est pas produit, et c'est le résultat intéressant** : #345 annonce que tout bump atteignant le sous-arbre réseau/TLS fait redescendre les 7 pointeurs `windows-sys` de `0.61.2` — or `cargo update -p rustls --precise 0.23.45` n'en a déplacé aucun. Le déclencheur tient à `hyper` et à ce qu'il tire, pas à la couche TLS en général ; le corps de #345 est donc trop large et un commentaire l'y consigne, avec une suggestion : formuler le garde-fou sur la **version résolue de `windows-sys`** plutôt que sur une liste de crates déclencheurs — la liste est ce qui vient d'être trouvé faux, la version est ce qui est protégé. Conséquence pratique : aucune strophe écrite à la main, la sortie de cargo est conservée, donc les checksums viennent de l'index et non du rédacteur. `rustls-webpki` 0.103.13 → 0.103.15 l'accompagne et est **requis** : 0.23.45 resserre sa borne de `^0.103.5` à `^0.103.14`, même forme que le `http ^1 → ^1.1` relevé par la revue de #344, mais cette fois contraignante ; les quatre correctifs que #310 avait obtenus en posant 0.103.13 sont conservés. **Preuves plutôt que déductions** : contrôle sur l'ancien lock (exit 1 sur RUSTSEC-2026-0285) montrant que l'outil local reproduit le rouge de la CI ; `cargo metadata --locked` acceptant le lock sans le réécrire ; checksums vérifiés au `sha256sum` des `.crate`, et surtout les lignes `Downloaded`→`Compiling rustls v0.23.45` du run CI, qui prouvent l'archive réellement utilisée là où `cargo metadata` ne prouve que le graphe ; garde-fou tracé (`Suppression guard: 2 checks, exit 0`) ; **111 Rust** ; et `workflow_dispatch` d'`audit.yml` **sur `main` après merge** (run 414) plutôt que d'inférer le vert du run de PR. `/pr-review` **APPROVE** en re-prouvant depuis la source plutôt qu'en relayant — checksums authentifiés contre l'index crates.io, borne resserrée lue dans le champ `deps` de l'index, absence de `reqwest 0.12.28` de l'arbre inverse complet — et a levé une note fondée appliquée en `9abf474` : « aucun changement de comportement » valait pour le code de l'app, pas pour la couche TLS, où une poignée de main non conforme est désormais fermée. **Deux acquis de process** : (1) sur un événement `pull_request`, le filtre `paths` s'évalue contre le diff complet de la PR et non le delta du dernier commit — un commit markdown seul a donc rejoué `check-rust` (run 413), ce qui est la bonne sémantique ; (2) merge par l'API Forgejo (rebase ff, SHA inchangé) plutôt qu'en local, ce qui évite la réconciliation manuelle des PRs. **Pas de v0.15.1** : arbitrage de Max, l'entrée reste sous `[Unreleased]`. Aucune migration DB (v1→v17). (ref #348, #345, PR #349)
- 2026-09-13 : **Étape 10 de v0.15.0 consignée — le cycle de mise à jour réel passe, et le « code à 6 chiffres » reçu par un testeur n'est pas une étape de la mise à jour.** Max rapporte que v0.14.0 → v0.15.0 fonctionne chez les deux testeurs. Corroboration Forgejo au 09-13 : les assets v0.15.0 comptent exactement 1 `.deb` et 1 `-setup.exe` téléchargés, 0 `.sig` (l'updater lit les signatures dans `latest.json`) — cohérent avec une machine Linux et une Windows passées par l'updater, sans en être la preuve ; invite polkit Linux non consignée. Trace rédigée pour le **corps** de la release (Forgejo n'a pas de commentaires de release — `docs/qa-update-cycle.md` disait « commentaire », corrigé avec le `PATCH` à rejouer) ; le `PATCH` lui-même est une écriture externe bloquée par le classifier du mode auto → payload préparé, commande remise à Max en `!`. **Le code par courriel, retracé dans le code plutôt que deviné** : ni `tauri-plugin-updater`, ni la saisie de clé (validation Ed25519 locale), ni l'activation machine (`license_key` + `machine_id` ; côté `maximus-api`, seul `fulfillmentService` envoie un courriel) n'en émettent. La seule surface est **« Se connecter » de la carte Compte Maximus**, première carte de Paramètres → Utilisateurs, juste au-dessus de la carte Licence où un testeur vient saisir sa clé — PKCE dans le navigateur système → page Logto, dont le connecteur SMTP (Resend) envoie un code à 6 chiffres valide 10 min pour trois flux, d'après le `sign-in-exp` public du tenant : connexion par courriel + code (`verificationCode: true` sur l'identifiant email), « mot de passe oublié », inscription (`signUp.verify: true`) ; MFA = TOTP seul, donc exclu. Lequel des trois : seul le journal d'audit Logto le dit (console, ou `GET /api/logs?userId=` avec le secret M2M qui ne vit que sur le VPS). Sans effet sur l'édition : `current_entitlements` ne lit le compte que pour un abonnement `active`, sinon retombe sur la clé Premium. **Cause probable du clic** : le texte de la carte (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé — piège pour tout testeur qui arrive avec une clé ; reformulation à ouvrir en issue. `logto-ops.md` enrichi (flux émetteurs du code + requête d'audit). **Suite dans la même séance** : évaluation pré-vente par 3 audits parallèles (desktop, API, site ; synthèse en mémoire `project-pre-sale-gaps`) — la chaîne Stripe est prouvée en mode test depuis le 07-19, ce qui bloque est de l'alignement : #270, Premium verrouillé sans offre vendable, identité vendeur fictive (CGV + `merchantInfo.ts`), copie produit « gratuit/bêta » contradictoire, mode live (#62/#141), backup Postgres des licences non prouvé ; puis `/analyze 270` → corps réécrit et 3 issues ouvertes (#346, #347, la-compagnie-maximus#153). Aucune migration DB (v1→v17). (ref v0.15.0, #320, #270, #346, #347)
- 2026-09-12 : **v0.15.0 taggée et publiée (`920dedd`, run 402 vert) — et deux clés Premium émises juste avant, parce que la release ferme la porte derrière elle.** Point de départ : Max demande comment les testeurs (lui et Michel) gardent l'accès une fois le gating actif. Lecture du code plutôt que de la spec : en v0.14.0 `FEATURE_TIERS` laisse `auto-update` **ouvert en Free** (« temporarily open until the license server is live »), v0.15.0 le passe Base+ — donc une installation sans clé reçoit v0.15.0 automatiquement et **plus jamais rien ensuite**, et se retrouve Free : profil actif seul, Budget/Ajustements/rapports avancés/Bilan verrouillés. Ni clause de grand-père ni période de grâce dans le code, `SR_DEV_EDITION` compilé hors des builds publiés. Réponse : une clé par testeur, saisie **avant** le tag (la carte Licence existe en v0.14.0 et la clé publique est identique entre v0.14.0 et `main`), validation locale sans réseau ; l'activation machine n'est requise que pour les cours du Bilan (`activation.token` en Bearer vers `/v1/prices`), et l'ancien chemin `/licenses/activate` du binaire passe par un 308 vers `/v1/` avec produit inféré du préfixe `SR-` (#48, vérifié en prod). Émission via `POST /v1/licenses/generate` (clé admin de Coolify déposée en `~/.maximus-admin-key`, bearer passé par process substitution, clés écrites en 600 hors transcription), puis **vérification indépendante** : signature Ed25519 contre la clé publique embarquée, `exp`+`iat` exigés par `strict_validation()`, préfixe/édition cohérents, expiration 2036-09-09. **Le détour qui a coûté la séance** : le VPS répondait `Connection timed out` sans lien de réauth — pas une attente navigateur mais **`peer's node key has expired`** (Tailscale, 180 j, `last seen 16d`), invisible depuis la console admin et sans réparation à distance ; l'IP publique `54.39.97.77` a le port 22 ouvert mais refuse la clé du ThinkPad, réparé par Max via le Terminal Coolify + *Disable key expiry*. Le classifier du mode auto bloque tout `ssh` vers le VPS (Tailscale et IP publique) → commandes remises à Max en `!`. Lecture Logto : 4 comptes, Michel = `michelt` (04-14), les deux autres sont le second compte de Max et une reliquat de La Suite Booking. Table `licenses` : une seule vraie clé, la Base de Max du 07-19, **révoquée** (Stripe test + remboursement) — et le client ne voit jamais `is_revoked` hors ligne, seul le serveur (activation, cours) la refuse. Release : pré-vol vert (111 Rust, 1181 vitest), 5 manifestes + 2 changelogs + ligne `CLAUDE.md` 0.6.3→0.15.0, tag annoté confirmé par Max avant push (publie `latest.json` aux installations existantes), run 402 en 22 min, 7 artefacts et `latest.json` vérifiés par script. Étape 10 à dérouler. (ref v0.15.0, #297-#301, #323-#331, #259, #310, #311, #343)
- 2026-09-04 : **#343 ouverte, #344 mergée — l'audit quotidien rouge depuis 17 jours, corrigé (`main` `5154c52`).** Découvert au `/warmup` : `audit.yml` échouait tous les matins depuis le **2026-08-19** (runs 375→391), dernier vert le 08-18, **aucun push sur `main` dans l'intervalle**. Une seule vulnérabilité : **RUSTSEC-2026-0258** / GHSA-q83h-524g-xf6h, `h2` 0.4.13, datée du 08-17 — DoS de sévérité faible, les frames HTTP/2 `DATA` vides sont acceptées et mises en file **sans aucune limite**, donc sur un flux non drainé la mémoire croît sans borne et la longueur peut déborder en panique. Le délai 08-17→08-19 est la propagation dans l'advisory-db. Ces 17 jours **sont** le mode d'échec que l'ADR 0018 visait à empêcher — « alarme permanente = alarme ignorée » — cette fois par le haut : le gate criait, personne ne regardait. **Atteignabilité par triple explicite**, jamais `--target all` : `h2` est sur les **deux** cibles livrées par deux chemins, `hyper`←`hyper-rustls`←`reqwest 0.13.2`←`tauri-plugin-updater` (le chemin de la mise à jour automatique) et `hyper-tls`←`reqwest 0.12.28` déclaré directement en `Cargo.toml:43` (licence, auth, cours, rétroaction — 4 modules). Correctif publié + crate atteignable = **règle 1** de `.cargo/audit.toml`, donc corrigé et non suppressé ; ni le fichier de suppressions ni le garde-fou de `check-rust.yml` ne bougent. **Le piège, et la raison d'un diff de 2 lignes** : `cargo update -p h2` donne bien 0.4.19 mais réaiguille au passage 7 pointeurs `windows-sys` (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) de `0.61.2` vers 0.48/0.52/0.59 — **exactement ce que #321 avait défait délibérément le 08-15**. Laisser passer aurait régressé cette décision en silence, 3 semaines après. Le lock a donc été restauré et **seule la strophe `h2`** appliquée (version + checksum), après avoir **retracé depuis la source** — `git show v0.14.0:src-tauri/Cargo.lock`, les 7 crates comparés un à un — que `0.61.2` est bien l'état du tag, donc la seule configuration `windows-sys` dont on ait la preuve qu'elle compile sous Windows ; relayer la note de #321 n'aurait pas suffi. `cargo metadata --locked` accepte le résultat **sans le réécrire**. **`/pr-review` APPROVE et a trouvé ce que j'avais manqué** : une strophe écrite à la main ne garantit plus ni checksum ni liste de dépendances, et `h2` 0.4.19 **resserre sa borne `http` de `^1` à `^1.1`** — le genre de changement qui invalide un lock épinglé ; satisfait ici (`http 1.4.0`), checksum authentifié par `sha256sum` du `.crate`, et nuance utile qu'il pose : `cargo metadata --locked` prouve le graphe, **pas** le checksum — c'est le `Downloaded`→`Compiling` du run 392 qui le prouve. **Preuves plutôt que déductions** : contrôle sur l'ancien lock (exit 1 sur RUSTSEC-2026-0258) montrant que l'outil local reproduit le rouge de la CI ; log de la CI lu ligne à ligne parce que l'étape `cargo audit` de `check-rust` est `continue-on-error: true` et qu'un job vert n'y prouve rien ; garde-fou tracé (`Suppression guard: 2 checks, exit 0`) ; **111 Rust** ; et `workflow_dispatch` d'`audit.yml` **sur `main` après merge** (run 393, `Job succeeded`) plutôt que d'inférer le vert du run de PR. **Deux acquis au passage** : (1) le **glob `src-tauri/**` est enfin prouvé** — cette PR est le cas test isolé attendu depuis #232 (lock + 2 `.md` seulement) : `check-rust` a tourné, `check-frontend` correctement sauté par sa denylist ; le suivi peut être fermé ; (2) **#345 ouverte** — la contrainte `windows-sys 0.61.2` en est à sa 3e rencontre (#310 l'introduit, #321 la défait, #344 l'évite à la main) et ne vit dans **aucun** ADR, aucun commentaire de `Cargo.toml`, aucun garde CI : seulement 3 lignes de prose de STATE.md et un message de commit, donc le prochain `cargo update` la régressera en silence. Merge via l'API Forgejo (rebase ff, SHA inchangé) plutôt qu'en local, ce qui évite la réconciliation manuelle des PRs. Aucune migration DB (v1→v17). (ref #343, #345, PR #344)
- 2026-10-18 : **v0.17.2 taggée et publiée (`f42f360`, run 570 vert), sur demande de Max (« /release 0.17.2 »).** Pré-vol sur le tip `e0c0f9e` : build, 2297 vitest, `cargo check`, 267 tests Rust, `.claude/worktrees/` vide. Bump des 5 manifestes, 2 changelogs et de la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`, garde `windows-sys` rejoué sur le lock édité (9 contrôles, exit 0). `[Unreleased]` portait des Added (la convention donnerait 0.18.0) : question posée, Max a confirmé 0.17.2. Gating changé par #369 (Répartition en Base) : clés Premium des deux testeurs déjà émises, rien à émettre ; le tableau comparatif du site était déjà en avance, commentaire 25050 sur la-compagnie-maximus#180. Tag annoté poussé seul (jamais `--tags`). Étape 9 : `verify-release.sh` en `VERDICT : OK` (7 artefacts, trois clés, extensions, `file:` des signatures, version) ; journal du run : Node `v24.22.0` (#376 prouvé), « Generate latest.json » un paquet par type, garde jq passée, `Delete HTTP 204` puis `Upload HTTP 201` ; endpoint de `tauri.conf.json` en 0.17.2 à trois clés. Restent à Max : étape 10 (cycle réel v0.17.1 → v0.17.2, Linux et Windows, clé Base+), vérifications dans l'app (#421, #369, retours v0.17.1), cycle `.rpm` si une machine est disponible. Aucune migration DB (v1→v17). (ref v0.17.2, #419, #421, #369, #376, #320, la-compagnie-maximus#180)
- 2026-10-18 : **#421 mergée — `/fix-issue` (contrôle du plan passé du premier coup) → `/pr-review` APPROVE de niveau haut → un commit de suivi en tête → merge ff par l'API (PR #465, `main` `ff9656d`) : les confirmations attendent la réponse de l'utilisateur ; issue gardée ouverte (`Refs`, `status:blocked`) jusqu'à la vérification manuelle de Max.** Cause : le script d'initialisation desktop de `tauri-plugin-dialog` 2.6.0 remplace `window.confirm` par une fonction asynchrone, donc chaque garde `if (!confirm(...)) return;` testait une promesse, toujours vraie ; défaut présent dans toutes les versions publiées, le plus grave étant la suppression d'un profil secondaire et de sa base sur « Annuler ». Correctif : `confirmAction()` sur `ask` du plugin, un échec valant refus ; `@tauri-apps/plugin-dialog` ajouté à la version du crate, d'où le niveau haut (dépendance ajoutée). `tsc` ne relève pas `!promise` (mutation M1 verte sous `tsc`), d'où la garde `native-dialog-guard.test.ts`. Preuves : reproduction par six tests rouges de `useLicenseMachines.test.ts` avec une confirmation asynchrone ; `ProfileFormModal.delete.test.ts` sur le harnais `hookHarness` ; mutations de l'auteur rouges à 8 sur 8 ; revue (commentaire 24953) avec 8 mutations du relecteur sur 9 rouges, la survivante étant un alias (`const c = confirmAction`). Suggestion 1 appliquée après la revue (`ff9656d`, tests seuls, non relu, nommé dans le commentaire 25025) : l'alias et trois formes natives (`window["confirm"]`, `confirm?.(`, `const { confirm } = window`) sont désormais rouges. Rebase sur `5222329` (PR #450, mergée entre-temps, `SKILL.md` seul) ; `check-frontend` vert sur `ff9656d`, 2297 tests (2256 avant #421). Suggestion 2 → #467 (`status:triage`). Quatrième PR mesurée du pilote #458. Les deux prérequis du tag de la v0.17.2, #419 et #421, sont mergés. Aucune migration (v1→v17). (ref #421, PR #465, #467, #458)
- 2026-10-18 : **#419 mergée — `/fix-issue` (contrôle du plan refusé deux fois, chaque fois sur un point réel) → `/pr-review` APPROVE de niveau haut → merge ff par l'API (PR #464, `main` `c859405`) : un `latest.json` vide ou à plusieurs documents ne passe plus la garde d'avant le `DELETE` du registre, et l'étape « Publish » est testée telle quelle.** Le filtre `scripts/check-latest-json.jq` exige exactement un document, appelé avec `-s` par `release.yml` ; défaut reproduit avant le correctif avec le binaire statique officiel de jq 1.6 (celui d'`ubuntu:22.04`) : fichier vide accepté en rc 0, refusé en rc 4 par jq 1.7. `build-latest-json.test.mjs` (28 → 36 tests) extrait le `run:` de l'étape « Publish », identique à l'octet à une lecture PyYAML (46 lignes), et le lance sous `bash -e -o pipefail` avec un `curl` factice en tête du `PATH` ; une garde vérifie que ce texte ne contient aucune expression de workflow, que le runner substituerait même dans un commentaire sans que le test le voie. Refus du plan check : `REVIEW.md` non mis à jour alors que le déclencheur de `check-frontend` changeait, puis une expression de workflow littérale dans un commentaire du `run:`, que le runner aurait évaluée ; troisième passage : pass. **Décision de Max** : `check-frontend` ne saute plus `.claude/**` ni `release.yml`, puisque le test lit `verify-release.sh` et exécute l'étape « Publish » (environ 2,5 min de runner de plus sur ces PR) ; `REVIEW.md`, `CLAUDE.md` et `architecture.md` suivent. Aussi : commentaire périmé de `release.yml:104` (point 3 de #461, commentaire 24939, points 1 et 2 toujours ouverts), ligne `TargetsNotFound` de `tauri dev` sous Linux dans `docs/qa-update-cycle.md`, skill `/release` à jour. Hors dépôt : `la-compagnie-maximus/docs/forgejo-ops.md`, commit `9a8e3f5` (`/actions/tasks` ne lit `limit` qu'avec `page`, remesuré le 2026-10-18 sur 654 tâches ; cas du run 513, que `cancel-in-progress` n'a pas annulé). Preuves : cinq mutations de l'auteur, toutes rouges sous jq 1.6 et 1.7 ; revue de niveau haut (commentaire 24912), six mutations du relecteur toutes rouges, passe sécurité sans constat ; `check-frontend` run 565 vert sous jq 1.6 (134 fichiers, 2256 tests) ; #419 fermée par le merge (commentaire 24941). Troisième PR mesurée du pilote #458. **Reste à prouver** (suggestion 1 de la revue) : aucune PR ne touchant que `.claude/**` ou `release.yml` n'a encore montré que `check-frontend` se déclenche, la PR #464 touchant aussi `scripts/` ; la première de ce type en fera la preuve. Reste avant le tag de la v0.17.2 : #421. Aucune migration (v1→v17). (ref #419, PR #464, #461, #458)
- 2026-10-18 : **#455 mergée — `/fix-issue` → `/pr-review` de niveau haut REQUEST_CHANGES, corrigé dans le texte → merge ff par l'API sans second tour (PR #459, `main` `c5efc38`) : `tauri-plugin-updater` compilé sans sa feature `zip`, `audit.yml` de nouveau vert. #457 fermée : les skills de revue lisent `REVIEW.md`.** `zip` 4.6.1, compilé pour Windows seulement par la feature par défaut `zip` de l'updater, n'est corrigé qu'en 9.0.0, hors de portée de `tauri-plugin-updater` 2.14.0 (`zip ^4`) : l'updater déclaré en `default-features = false` avec `rustls-tls` sort `zip`, `arbitrary` et `derive_arbitrary` du lock (666 → 663) et avec eux RUSTSEC-2026-0367 à -0369, sans entrée dans `.cargo/audit.toml` (règle 1 de l'ADR 0018). `tar` quittant le graphe Linux, le canari de la garde des suppressions passe à `webkit2gtk`, conditionnel à Linux comme l'était `tar` (le contrôle du plan a écarté `tauri`, inconditionnel, que proposait l'analyse) ; preuve Windows par `cargo xwin check`, témoin sur `main` (`zip` compilé) puis branche (sans `zip`). Première PR mesurée du pilote #458, de niveau haut (fixé par `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, l'étape de verdict de `check-rust.yml` et le skill `/release`) : la revue (commentaire 24757) rejoue le correctif (quatre mutations du canari, `cargo xwin check`, passe sécurité sans constat de §1 à §4) et bloque sur une fausse preuve du corps de la PR : le cycle de mise à jour de la prochaine release part de la v0.17.1, dont l'updater a encore `zip`, il n'exécute donc pas le chemin d'installation sans `zip`. Corrigé sans commit dans le corps de la PR et dans celui de #455 (commentaire 24806) ; **décision de Max** : merger sans second tour. Preuves : rebase sur `main`, les neuf fichiers de la PR identiques à l'octet à la tête relue `85b7ef9` ; `check-rust` run 563 vert sur `c5efc38` (canari `webkit2gtk`, gardes 2/2 et 9/9, 267 tests, audit à 663 crates, lock non réécrit) ; après le merge, `workflow_dispatch` d'`audit.yml` : run 564 vert (cargo-audit 0.22.2, 1338 avis, 663 crates, aucune vulnérabilité, aucun des trois IDs `zip` ; 18 avertissements autorisés, les 12 d'avant et six `unmaintained` parus le 2026-10-18, RUSTSEC-2026-0370 à -0372) ; PR en `Refs #455`, d'où une fermeture à la main (commentaire 24830). #457 (skills globaux, hors git, aucune PR) : `pr-review` lit `REVIEW.md` sur la branche par défaut (Context), calcule le niveau au Step 0bis et l'annonce en première ligne du verdict ; profondeur par niveau (bas = lecture du diff ; haut = mutation obligatoire, passe sécurité bloquante faite dans le fork, car un fork ne lance pas d'agent et `security-scan` n'est défini qu'au niveau du monorepo, APPROVE avec « Max doit lire avant le merge. ») ; savoir propre à simpl-resultat retiré du skill ; `sprint` 4b renvoie à `/pr-review` au lieu d'une copie de checklist (la copie avait perdu la section Tests) ; `DAILY-REPORT` d'`autopilot` avec une section « Max doit lire » et `prs_high_level` ; `fix-issue` 6.5 prend ses commandes dans `REVIEW.md` ; une entrée de CHANGELOG par skill. Preuve : la revue de la PR #459 ouvre sur la ligne de niveau (commentaire 24757) ; fermeture au commentaire 24764. Pilote #458 en `status:in-progress`, relevé ouvert (commentaire 24819), PR 1 = #459. **Acquis** : le cycle réel de mise à jour d'une release exécute l'updater de la version précédente ; seul le `check()` final passe par le nouveau. Suivi #463 (l'étape 10 de `/release` et `docs/qa-update-cycle.md` rattachent le cycle à la release qui livre le changement, alors qu'un changement de l'updater ne se prouve qu'au cycle N → N+1). Aucune migration (v1→v17). (ref #455, PR #459, #457, #458, #463)
- 2026-10-18 : **#456 mergée — `/fix-issue` (contrôle du plan refusé trois fois, chaque fois sur des points réels, derniers points appliqués, poursuite décidée par Max) → deux `/pr-review` APPROVE, le second après application des suggestions du premier → merge ff par l'API (PR #460, `main` `24da72b`) : `REVIEW.md` à la racine.** Calibration sur les PR mergées de #344 à #452 : 3/28/8 sur les 39 PR de référence (20,5 % de niveau haut pour une cible de 20 % ; écart justifié, les huit touchent la licence, les bases de profils et les migrations, ou `release.yml`, qui détient la clé de signature), contre 3/19/17 avec les motifs d'essai du document de départ, et 6/33/12 sur les 51 de la plage. Les « 39 dernières PR » de ce document venaient d'un tri par date de mise à jour (`limit=40&sort=recentupdate`) : liste figée dans la PR. **Décisions de Max** : une montée de version d'un paquet déjà présent dans un lock reste de niveau normal, correctifs d'advisory compris (2026-10-17) ; `db.ts` et `dataExportService.ts` restent normaux même quand le diff touche le verrou ou la restauration (2026-10-18). Preuves : script relancé à neuf contre l'API à chaque changement de règle, niveaux identiques PR par PR ; `--check` rouge sur une copie du tableau privée d'un motif ; CI `check-frontend` 562 verte (134 fichiers, 2248 tests). **Acquis** : l'API Forgejo marque un fichier modifié `changed`, pas `modified` (un test sur `modified` mettait toutes les PR de lock en haut) ; un contrôle de contenu de workflow doit ignorer les commentaires (#375 sortait haute sur le mot « toolchain ») ; les pages de l'API plafonnent à 50 éléments. Suivis : #461 (CSV de `src/__fixtures__/samples/` compilés par `include_str!` hors des `paths` de `check-rust`, `cargo-xwin` et toolchain non épinglés dans `release.yml`, commentaire périmé de `release.yml:104`), #462 (suggestions de la seconde revue, entrées du pilote #458), commentaire sur #457 (lecture sur `main`). Aucune migration (v1→v17). (ref #456, PR #460, #461, #462, #457, #458)
- 2026-10-17 : **#369 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → deux commits de suivi en tête → merge ff par l'API (PR #452, `main` `a8c3fd6`) : la Répartition réservée à l'édition Base.** L'écran de déverrouillage la rangeait déjà en Base (`upsell.features.adjustments`) alors que l'app la laissait ouverte en Gratuite ; désormais, en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé (`UpsellDialog`) pour toute ligne, jamais la fenêtre de répartition ; bouton inactif tant que la licence n'est pas `ready` ; répartitions existantes comptées, icône orange gardée. `splitAccessFor` est plus strict que `profileGate` (`allowed` ne court-circuite pas `ready`) ; `TransactionTable` ne rend pas la fenêtre de répartition en `locked` mais la garde pendant un rafraîchissement de licence, et ne rend pas l'incitation en `open`. Refus du plan check (MAJOR) : le câblage de `TransactionTable` n'était pas testé, d'où `TransactionTable.wiring.test.ts` sur le harnais `hookHarness` de #409 ; second passage : pass. Preuves : 21 mutations de l'auteur, toutes rouges ; revue (commentaire 24129) avec 14 des 15 mutations du relecteur rouges, la survivante R14 (un clic verrouillé qui ouvrirait aussi la fenêtre de répartition) épinglée par `d5d0e89` ; `a8c3fd6` réunit la clé de la Répartition dans `SPLIT_FEATURE` ; ces deux commits en tête, non relus, sont nommés dans le commentaire 24148 ; tip en worktree `npm ci` propre (sonde rc=1, build, 2248 vitest sur 134 fichiers, contre 2222) ; CI 557 et 559 ; #369 fermée par le merge. Site : le tableau comparatif de la-compagnie-maximus#180 (PR #298 de ce dépôt-là), en production depuis le 2026-10-16, range déjà la Répartition en Base, en avance sur l'app, décalage accepté par Max ; commentaire 24164 posé le 2026-10-17 (mergé, publié avec la prochaine release, pas encore disponible). Suivis : #451 (incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (`type:bug`, compensation d'une transaction répartie laissée dans l'ancienne catégorie au reclassement, car `updateTransactionCategory` ne touche que le parent ; rare, mais irréparable en Gratuite depuis #369). Aucune migration (v1→v17). (ref #369, PR #452, #451, #454, la-compagnie-maximus#180)
- 2026-10-16 : **#447 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → merge ff par l'API (PR #448, `main` `1763a9f`) : `rustls` 0.23.45 → 0.23.46, corrigeant RUSTSEC-2026-0346 à -0349.** Publiés le 2026-10-14, les quatre avis rougissaient `audit.yml` (runs 553 et 555) sans que le lock ait bougé depuis la v0.17.1 ; repérés par la Phase 5.6 du `/warmup` le 2026-10-15, pas par le Défenseur, dont le check `deps` ne lance jamais `cargo audit` (**décision de Max** le 2026-10-15 : sous-check `cargo-audit`, agent-defenseurs#186). Aucun n'est atteignable (`rustls` atteint par `tauri-plugin-updater` → `reqwest` 0.13 seulement ; fournisseur `ring` sans groupe post-quantique, `brotli` non compilée, ni ECH ni `ClientSessionStore` maison), mais la règle 1 de `.cargo/audit.toml` dit corriger, pas supprimer. Diff : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée, une puce Sécurité par CHANGELOG. Refus du plan check (MAJOR) : « No behaviour change » avait déjà été recadré par `9abf474` au bump précédent, et la 0.23.46 apporte une dizaine d'autres corrections de la couche TLS (21 fichiers de `src/` en amont), donc l'entrée restreint « aucun changement » au code de l'application ; second passage : pass. Preuves : `cargo-audit` 0.22.2 (binaire de la CI) rouge sur l'arbre intact (4 IDs) puis vert ; `check-rust` run 554 lu ligne à ligne (gardes 2/2 et 9/9, 267 tests, 0 vulnérabilité ; à froid, rustc 1.99.1 du 2026-10-13 ayant changé la clé du cache) ; revue (commentaire 23853) avec mutation inverse rouge sur les quatre IDs ; tip en worktree propre (`npm ci`, sonde rc=1, build, 2222 vitest, `cargo test --locked` à 267, gardes, audit exit 0) ; après le merge, `audit.yml` en `workflow_dispatch` sur `main` : run 556 vert (666 crates, 12 warnings autorisés, 0 vulnérabilité). PR en `Refs #447` (critère post-merge), d'où une fermeture à la main (commentaire 23894). Pas de release corrective (décision de Max) : le bump part avec le prochain tag, cycle réel de mise à jour compris. **Acquis** : `cargo-audit` écrit « 1 vulnerability found! » mais « N vulnerabilities found! », chercher `vulnerabilit(y|ies) found` ou `error: [0-9]+ vulnerabilit` ; le journal de `check-rust` porte des codes ANSI (`CARGO_TERM_COLOR=always`) à retirer avant tout `grep`. Suivi #449 (`--locked` absent de `check-rust.yml` et de `release.yml`). Aucune migration (v1→v17). (ref #447, PR #448, #449, agent-defenseurs#186)
- 2026-10-15 : **Milestone `planned-2026-10-12-retours-onboarding-v0171` mergée — `/autopilot` (8 workers en série, 1 h 14, worker N+1 lancé pendant la CI du maillon N, CI lue à chaque maillon, runs 543-551) → huit `/pr-review` APPROVE en parallèle (aucun bloquant) → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `934bcbf`, 8 PRs `merged`).** Tip éprouvé en worktree `npm ci` propre : sonde rc=1, build, 2222 vitest (2065 avant), 267 Rust. 31 décisions HIGH, 25 MEDIUM, 0 LOW ; mutations des workers et des relecteurs presque toutes rouges, les survivantes sans effet de comportement ou épinglées. **Acquis** : deux workers sur huit ont perdu leur implémentation non commitée au `git checkout --` de la passe de mutations → consigne « commit avant la première mutation » ajoutée en cours de run, zéro incident ensuite ; les corps de PR des workers ne portent pas `Resolves #N` en clair (seulement les commits), sans effet sur un merge ff. Trou global relevé par deux revues : aucun test ne détecte une clé i18n présente dans une seule langue (#446). Aucune migration (v1→v17). (ref #424, #422, #427, #426, #430, #431, #425, #432, PRs #438-#445, #446)
- 2026-10-13 : **Milestone `planned-2026-10-10-fiabilite-tests` mergée — `/autopilot` (4 workers en série, 1 h 25, CI lue à chaque maillon) → quatre `/pr-review` APPROVE en parallèle → suivis en tête de pile → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `c17bf5d`, 4 PRs `merged`).** Mesure du rattrapage : 306 ms en release pour 100 001 transactions (T490, NVMe), sous la règle des 3 s, donc `STARTUP_TIMEOUT_MS` reste à 10 000 (ADR 0021). Inventaire de #389 : 27 fichiers et non 24, trois mocks bruts venus de #423. Suivis appliqués en tête (`74fae14`, `a1c31a1`, `ac3a981`, `86eff9b`, `c17bf5d`), non relus : `close()` d'un ancien proxy rejeté, échec de fermeture consigné sans bloquer le changement de profil, `withoutFolders` qui ne laisse plus passer un dossier contenant une espace, garde i18n plus stricte, badge traduit. Tip éprouvé en worktree `npm ci` propre : sonde rc=1, tsc, build, 2065 vitest, 262 Rust. ADR 0024 (serveur MCP, Proposed) rebasé sur le merge puis poussé (`977ef3a`). **Acquis** : l'orchestrateur a d'abord contredit à tort un constat juste du worker (badge `best-effort` codé en dur), sur un `grep` tronqué par `head` ; ne jamais conclure à l'absence depuis une sortie tronquée. Aucune migration (v1→v17). (ref #408, #409, #389, #395, PRs #433-#436, #437)
- 2026-10-12 : **#423 mergée (PR #428, `main` `8898213`) — menus déroulants de catégorie avec saisie dans le formulaire de catégorie et le dialogue mot-clé.** Issue créée et analysée le jour même (`/analyze` : types `Category` vs `CategoryTreeNode` incompatibles → type structurel `CategoryLike` sur le combobox ; le formulaire ne connaissait pas l'id édité → prop `editingId` ; deux listes différentes → **D2** de Max : liste des Transactions ; **D1** : #423 porte l'exclusion UI, #393 garde la validation service). Plan check refusé une fois (la liste `absolute` était rognée par le scrollport du dialogue, réglé en sortant les champs du bloc défilant). Première revue **REQUEST_CHANGES** : la règle ne regardait que les enfants directs, une racine avec petits-enfants pouvait recevoir un parent (4ᵉ niveau) ; corrigé par la hauteur du sous-arbre, test rouge d'abord sur la sonde du relecteur. Seconde revue **APPROVE**, quatre suivis appliqués en tête (`8898213`, non relu : commentaires #393 corrigés, garde de cycle épinglée, espions sur `buildParentOptions`, garde redondante retirée). Preuves : 1963 vitest, 9 mutations rouges de l'auteur (une survivante remplacée : la règle « descendance » était impliquée par la règle de profondeur), CI 526-528, worktree `npm ci` propre. **Acquis** : `/commits/<sha>/statuses` renvoie l'historique (`pending` puis `success`), lire `/commits/<sha>/status` agrégé ; `head_commit_id` du merge exige le SHA complet (409 « head out of date » sur un SHA court). Aucune migration (v1→v17). (ref #423, PR #428, #429, #393)
## Blockers actifs
- Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers.
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop est devenue une dépendance du go-live.** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. Sources : la-compagnie-maximus#141 (corps du 2026-09-30) et maximus-api#62 (revalidation du 2026-09-29), relus le 2026-09-30 ; depuis ce dépôt, seuls `/healthz` (200) et les CGV publiées ont été relus. Les deux issues restent ouvertes pour leur reliquat (réglages au Dashboard Stripe, la-compagnie-maximus#150 pour Link dans les textes légaux, ménage de configuration côté maximus-api), et la-compagnie-maximus#141 attend désormais ce dépôt : #346 et #347 publiées dans une release, puis l'updater vérifié (Free refuse et invite à acheter, Base accepte), ainsi que #365 et #350. **À trancher par Max : le contenu de la prochaine release.** #350 est mergée depuis le 2026-10-01 (PR #366) : la demande de la-compagnie-maximus#141, « avant que les ventes s'accumulent », est tenue si la release part avec elle. #365 est mergée depuis le 2026-10-03 (PR #368) ; la-compagnie-maximus#184 doit déclarer son contrôle avant la release qui l'embarque. **Trois vérifications manuelles en attente avant le tag** : (1) en Gratuite, le modal de création de profil ouvert depuis `ProfileSwitcher`, puis « J'ai déjà une clé », doit se fermer sur la carte Licence (seule mutation que les tests de #346 ne gardent pas) ; (2) pour #350, sur un poste activé par la v0.15.0 : état « Activée » et marqueur « Cette machine » conservés, puis « Désactiver » et « Activer » doivent donner un jeton dont le claim vaut le dérivé (commande dans #350, attendu `64 brut: False dérivé: True`). La dérivation devient définitive à la première activation en production, et rien d'autre ne tient le câblage de l'activation à l'identifiant réel du poste ; (3) pour #365, avec la clé réellement révoquée du contrôle d'achat du 2026-09-30 : l'enregistrer, redémarrer, lire « the license server says the key was revoked » dans le Journal, puis constater l'édition Gratuite et la révocation nommée (carte Licence, écran verrouillé, carte Mises à jour) ; étapes dans #365.
- Backlog ouvert (`status:ready`, non bloqué) : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). Hors vague, pour la release qui suit le go-live : **#365** (vérification en ligne de la révocation : mergée le 2026-10-03 par la PR #368, ouverte en `status:blocked` jusqu'à sa vérification manuelle), **#367** (`status:triage` : jeton d'activation d'une ancienne licence resté à côté d'une autre clé par la v0.15.0 ; carte « Activée », cours refusés, aucun moyen de réactiver), **#350** (hachage de l'identifiant machine : mergée le 2026-10-01 par la PR #366, ouverte en `status:blocked` jusqu'à sa vérification manuelle), reste de #363 (plafond de `/issue-license` à 36525 jours et essai d'une vraie clé de 100 ans, débloqués par le déploiement de maximus-api#63) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01, en `status:triage` : maximus-api#82 (identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (données d'activation absentes de la politique de confidentialité, à quoi s'ajoute le contrôle de révocation de #365, faits livrés consignés le 2026-10-03) ; clôture du go-live = la-compagnie-maximus#141 + maximus-api#62 ; `spec-paiements` reste le parapluie des trois dépôts (ici 3/5, restent #350 et #363) ; `spec-onboarding-phase1` **0/7** (#351 à #357, `status:ready`, pile linéaire ; spécifiée et passée en `/review-spec` le 2026-09-25, mise en issues le 2026-09-26 ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git) ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **3/4** (#232 livrée le 2026-07-27 ; reste **#234** connectivité du serveur de cache runner, qui débloquerait `Swatinem/rust-cache` — accès hôte VPS requis). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. Restent **#317** (re-évaluer `react-router` à une migration v8), **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications) et **#345** (domicilier la contrainte `windows-sys 0.61.2` — analysée le 09-04 : le corps d'origine, écrit de mémoire en fin de revue, se trompait sur trois points. Le déclencheur n'est pas « n'importe quel `cargo update` » mais tout bump atteignant le sous-arbre réseau/TLS — mesuré : `-p anyhow` ne bouge rien, `-p hyper` fait descendre les 7 — soit précisément là où tombent les advisories. **Prémisse restreinte le 2026-09-21 par #348** : `-p rustls`, au cœur de ce même sous-arbre, n'en déplace **aucun** — le déclencheur tient à `hyper` et à ce qu'il tire, pas à la couche TLS en général, donc « sous-arbre réseau/TLS » prédit une dérive là où il n'y en a pas et ferait écrire une strophe à la main sans nécessité. D'où la suggestion portée en commentaire sur l'issue : formuler le garde-fou sur la **version résolue de `windows-sys`** plutôt que sur une liste de crates déclencheurs — la liste est ce qui vient d'être trouvé faux, la version est ce qui est protégé. #310 avait **bien** vu la dérive et l'avait jugée inoffensive *sur Linux*, où elle l'est ; son coût tombe sur le build Windows que personne ne mesurait : **3 versions de `windows-sys` compilées contre 5**. Aucun risque de correction — les 7 crates déclarent des bornes larges couvrant les deux états. Et cargo annonce « Locking 1 package » en écrivant 8 changements, donc le silence est le sien. Décision : garde-fou CI sur la version + ADR portant la condition de sortie. Le check croisé Windows depuis Linux a été **testé et écarté** — `cc-rs` s'arrête sur `lib.exe`, il faudrait `cargo-xwin`). Côté npm, **#358** ouverte le 2026-09-26 par `/analyse-vulnerabilite` : `npm audit` remonte 7 findings (4 high, 3 moderate) sur 4 dépendances racines, contre les 2 acceptés au 07-27, et chaque correctif tient dans la borne déjà déclarée ; le lock se régénère avec `npx npm@11` (npm 10 plante sur un bump de vitest 4.1.x).
- Nouveau bug ouvert : **#320** — les installations `.rpm` ne reçoivent aucune mise à jour (`latest.json` ne porte qu'une entrée `linux-x86_64` construite depuis le `.deb`, `release.yml:104-122`), alors que les `.rpm` continuent d'être publiés en assets. Documenté en §3 de `docs/qa-update-cycle.md` (« ne pas tester : cassé, pas seulement non vérifié »).
- **Vérifications dues sur la v0.17.2 publiée** (étape 9 de `/release` faite le 2026-10-18 : `verify-release.sh` OK, Node `v24.22.0`, trois clés ; étape 10 faite sous Linux le 2026-10-18, trace dans le corps de la release ; restent les points ci-dessous, à Max) :
- Retours d'onboarding v0.17.1 (#438-#445), dans l'app : cas de la section « Retours de la v0.17.1 » de `docs/qa-onboarding.html` et parcours guidé à huit étapes, en particulier `v0171-import-source-reopen`, `v0171-budget-card-leaves`, l'encadré « Structure attendue » dans les deux thèmes et la première ouverture d'un profil qui avait fini le parcours (#446).
- Répartition réservée à Base (#369), dans l'app : en Gratuite, cadenas sur le bouton Répartition de la page Transactions et écran de déverrouillage au clic, jamais la fenêtre de répartition, lignes déjà réparties toujours marquées de l'icône orange ; en Base, fenêtre de répartition comme avant. Commentaire posé sur la-compagnie-maximus#180 à la publication (25050).
- `release.yml` sous Node 24 (#376) : **fait** (`v24.22.0`, run 570). Le diff touchant `release.yml`, l'étape 10 de `/release` (cycle réel de mise à jour, `docs/qa-update-cycle.md`) s'applique aussi, selon la règle du skill : **faite sous Linux** (`.deb`, v0.17.1 → v0.17.2, Max, 2026-10-18 ; invite de mot de passe vue, type non consigné), **reste Windows**. Le bump de `rustls` de #447 (dépendance de `tauri-plugin-updater`) la rend obligatoire lui aussi. L'updater compilé sans `zip` (#455) est aussi dans cette release, mais ce cycle part de la v0.17.1, dont l'updater a encore `zip` : il ne prouve rien de l'updater sans `zip` (#463).
- `latest.json` à trois clés (#320, clé générique `linux-x86_64` retirée le 2026-10-11) : **fait** (journal et `verify-release.sh`, run 570). Cycle réel depuis une installation `.rpm` (§3 de `docs/qa-update-cycle.md`) dès qu'une machine Fedora ou openSUSE est disponible, sinon « non déroulé » avec la raison dans la trace de la release.
- Confirmations de #421 : **fait** (Max, sous Linux, 2026-10-18, concluant ; Windows non déroulé) ; #421 fermée à la main le même jour (commentaire 25074).
- **Vérifications dues à la release suivante** (celle d'après la v0.17.2) : cycle réel de mise à jour Windows depuis la première version qui embarque #455 (et #447), première exécution de l'installation par l'updater sans `zip` et du téléchargement par `rustls` 0.23.46 ; dû même si cette release ne touche pas l'updater (#463).
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Go-live clos : la-compagnie-maximus#141** fermée le 2026-10-10 sur décision de Max, l'updater vérifié (Free refuse et invite à acheter, Base accepte ; confirmé le même jour). Restent pour mémoire, sans issue : le Dashboard Stripe (logo, paramètres Checkout, reçus automatiques, alertes d'échec de webhook) et les relevés du commentaire de fermeture. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). L'étape 10 sous Windows est faite (Michel, confirmée le 2026-10-10).
- Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone : **#369** (réserver le fractionnement de transactions à l'édition Base) **fermée le 2026-10-17** (PR #452, voir Décisions) ; **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes) reste en `status:ready`. **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; #363 fermée le 2026-10-10 sur décision de Max (ses tests, absorbés par #347, sont livrés dans la v0.16.0) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; go-live clos (la-compagnie-maximus#141 fermée le 2026-10-10, maximus-api#62 le 2026-10-03) ; `spec-paiements` fermée ici 5/5 ; onboarding phase 2 (parcours guidé) **mergée** : milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 le 2026-10-08 (#396 à #401, `ee17f31`), suivis **#408** et **#409** (`status:triage`) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** fermée le 2026-10-12 (Node 24, PR #414). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. **#447 fermée le 2026-10-16** (PR #448, run 556 vert), quatre avis `rustls` sur ce même chemin ; le Défenseur n'audite pas le Rust (agent-defenseurs#186), `audit.yml` reste le seul canal pour les avis RustSec. **#455 fermée le 2026-10-18** (PR #459, run 564 vert), trois avis `zip` sur ce même chemin, retirés avec la feature `zip` de l'updater plutôt que supprimés. **#317** fermée le 2026-10-12 par la PR #417 (#358). Reste **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications). **#345** fermée le 2026-10-12 (PR #415) : garde `Verify windows-sys pinning` dans `check-rust.yml` et ADR 0023, qui portent la mesure, le coût (3 contre 5 versions compilées pour Windows) et la condition de sortie. Côté npm, **#358** fermée le 2026-10-12 (PR #417) : lock régénéré par `npm@11.21.0` dans les bornes de `package.json`, `npm audit` 8 → 0 au 2026-10-10.
- **#320** fermée le 2026-10-12 (PR #416, clé générique retirée par la PR #418) : une clé `latest.json` par paquet Linux, vérifiée sur la v0.17.2 publiée (run 570, `verify-release.sh`) ; cycle `.rpm` encore dû (puce « Vérifications dues sur la v0.17.2 publiée ») ; le §3 de `docs/qa-update-cycle.md` est devenu la procédure de test rpm.
- Suivis ouverts du chantier import (milestone fermée) : le refus du format « montant absolu + indicateur D/C » ne scanne que `amountCol ± 1`, donc une colonne D/C placée ailleurs passe encore ; dérives de comptes préexistantes dans `CLAUDE.md` et `docs/architecture.md` (composants, pages, services, `Version actuelle : 0.6.3` vs tag v0.14.0) mesurées et documentées en revue, non corrigées.
- **Étape 10 de la release v0.15.0 consignée le 2026-09-13** — plus un blocker : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs ; trace rédigée pour le corps de la release (`PATCH` à appliquer par Max) ; invite d'authentification Linux non consignée ; #320 reste ouverte (`.rpm`). Deux restes : (1) le texte de la carte Compte Maximus (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé, et a vraisemblablement conduit un testeur à se connecter pour rien (code de vérification Logto reçu par courriel) — reformulation livrée par #346 (PR #362), avec une réserve notée dans l'ADR 0017 (exacte tant que le chemin abonnement → Premium reste dormant) ; (2) le flux Logto exact (connexion par code, mot de passe oublié ou inscription) reste à lire dans le journal d'audit. Position produit : v0.15.0 ; `[Unreleased]` porte l'entrée `rustls` du 2026-09-21, puis #270, #346, #347, #350 et #365.
- **Étape 10 de la release v0.15.0 consignée le 2026-09-13** — plus un blocker : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs ; trace rédigée pour le corps de la release (`PATCH` à appliquer par Max) ; invite d'authentification Linux non consignée ; #320 reste ouverte (`.rpm`). Deux restes : (1) le texte de la carte Compte Maximus (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé, et a vraisemblablement conduit un testeur à se connecter pour rien (code de vérification Logto reçu par courriel) — reformulation livrée par #346 (PR #362), avec une réserve notée dans l'ADR 0017 (exacte tant que le chemin abonnement → Premium reste dormant) ; (2) le flux Logto exact (connexion par code, mot de passe oublié ou inscription) reste à lire dans le journal d'audit. Position produit : v0.17.2 (2026-10-18) ; `[Unreleased]` vide.

View file

@ -40,3 +40,7 @@ Les 6 migrations actuelles :
- **Immutabilité** : une migration appliquée ne peut jamais être modifiée (erreur de checksum), ce qui force la création de nouvelles migrations correctives
- **Synchronisation manuelle** : le fichier `consolidated_schema.sql` doit être mis à jour manuellement après chaque nouvelle migration
- **Pas de rollback** : les migrations sont unidirectionnelles (pas de `down`)
## Amendement #390 (2026-10-06)
Le schéma consolidé **crée** un profil secondaire, il ne le tient pas à jour. tauri-plugin-sql n'applique les migrations qu'à la base enregistrée par `add_migrations`, celle du profil par défaut (`simpl_resultat.db`). Jusqu'à #390, un profil secondaire gardait le schéma de la version qui l'avait créé. Depuis #390, un nouveau profil reçoit à sa création l'historique complet de `_sqlx_migrations`, et la commande `prepare_profile_db` applique à chaque ouverture les versions qui manquent à un profil secondaire. Voir l'[ADR 0021](0021-migrations-profils-secondaires.md). Les six migrations listées plus haut sont celles de 2025 ; il y en a dix-sept à la v0.17.0.

View file

@ -38,3 +38,12 @@ Architecture :
- **Pas de vue agrégée** : impossible de voir les données de tous les profils en même temps
- **Synchronisation du schéma** : chaque base doit être au même niveau de migration (géré par le plugin sql au démarrage)
- **Fichier `profiles.json` externe** : les métadonnées des profils ne sont pas dans SQLite, ce qui crée une dépendance à un fichier JSON séparé
## Amendement #390 (2026-10-06)
Deux affirmations des conséquences étaient fausses jusqu'à #390 :
- « Les migrations s'appliquent indépendamment à chaque base » et « géré par le plugin sql au démarrage » : le plugin ne migre que la base du profil par défaut. Un profil secondaire gardait le schéma de la version qui l'avait créé. Il rattrape désormais ses migrations à chaque ouverture ([ADR 0021](0021-migrations-profils-secondaires.md)).
- « Supprimer un profil = supprimer un fichier `.db` » : sous Linux, la commande cherchait la base sous `app_data_dir()` alors que le plugin la range sous `app_config_dir()`, et le fichier restait sur le disque. La suppression vise désormais le dossier du plugin et emporte aussi les fichiers annexes de SQLite et les copies prises avant un rattrapage.
`profiles.json` reste dans le répertoire de données de l'application (`app_data_dir()`) ; les bases sont dans le répertoire de configuration (`app_config_dir()`), qui n'est le même dossier que sous Windows et macOS.

View file

@ -14,15 +14,19 @@
>
> **Ce qui change.** D13 est remplacée pour le cas révoqué : l'état vit dans `license.check`, à côté de la clé, sous la forme `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. `entitlements_from_dir` rend `("free", [])` quand cet état dit `revoked` pour la clé enregistrée : `get_edition`, `check_entitlement` (`auto-update`) et la matrice du front suivent sans autre changement. D12 est inchangée, le jeton d'activation ne pèse toujours pas sur l'édition. Un fichier absent, illisible, au JSON invalide, ou qui parle d'une autre clé se lit « pas d'état » : la clé n'est pas tenue pour révoquée et un contrôle est dû. Un fichier corrompu ne rétrograde donc jamais un client légitime, et une autre clé enregistrée reprend son édition.
>
> **La requête au démarrage, et ses bornes.** Jusqu'ici aucune requête ne partait au lancement : les mises à jour, les cours et la rétroaction partent sur un clic, et le contrôle d'un compte Maximus connecté à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction. `check_license_revocation` est la première exception, bornée ainsi : (1) rien ne part sans une clé enregistrée qui se valide localement ; (2) au plus une tentative par 24 h, quel qu'en soit le résultat, parce que `attempted_at` est écrit **avant** l'appel ; (3) la requête est `POST /v1/licenses/verify` avec le produit et la clé, sans identifiant machine, nom d'ordinateur ni donnée d'usage ; (4) chaque contrôle écrit son issue dans le Journal de l'app, y compris « rien envoyé » quand la dernière tentative a moins de 24 h ; (5) le contrôle ne part qu'au démarrage, sans minuteur en cours de session, et une clé saisie en cours de session est contrôlée au démarrage suivant. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app) et un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition).
> **La requête au démarrage et à la saisie d'une clé, et ses bornes.** Jusqu'ici aucune requête ne partait au lancement : les mises à jour, les cours et la rétroaction partent sur un clic, et le contrôle d'un compte Maximus connecté à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction. `check_license_revocation` est la première exception, bornée ainsi : (1) rien ne part sans une clé enregistrée qui se valide localement ; (2) au plus une tentative par 24 h, quel qu'en soit le résultat, parce que `attempted_at` est écrit **avant** l'appel ; (3) la requête est `POST /v1/licenses/verify` avec le produit et la clé, sans identifiant machine, nom d'ordinateur ni donnée d'usage ; (4) chaque contrôle écrit son issue dans le Journal de l'app, y compris « rien envoyé » quand la dernière tentative a moins de 24 h ; (5) le contrôle part au démarrage et à la saisie d'une clé, sans minuteur en cours de session, toujours au plus une tentative par 24 h et par clé (`license.check` ne retient que la dernière clé contrôlée : revenir à une clé précédente en rend un nouveau dû). Le contrôle à la saisie a été ajouté par #373 (2026-10-04) : jusque-là, une clé saisie en cours de session n'était contrôlée qu'au démarrage suivant, et une clé révoquée jamais contrôlée sur ce poste gardait son édition d'ici là, puisqu'aucune activation n'est requise depuis #347. Il part en arrière-plan, après l'enregistrement de la clé : la saisie rend la main sans attendre le serveur, hors ligne comme en ligne, et une clé révoquée affiche son édition une à deux secondes avant de passer en Gratuite. Rust ne laisse passer qu'un contrôle à la fois, verrou tenu jusqu'au verdict (`check_revocation_in_turn`) : une clé saisie avant la fin du chargement initial, contrôlée par la saisie et par le démarrage, n'envoie qu'une requête, le second contrôle lisant la tentative du premier ; et le contrôle d'une clé saisie pendant celui d'une autre attend ce verdict au lieu de l'écraser. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app), un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition) et, pour #373, attendre la réponse dans la saisie (l'indicateur de validation tournerait jusqu'à 15 s hors ligne).
>
> **Fail-open.** Seule une réponse « révoquée » pose l'état, seule une réponse `valid:true` le lève. Erreur réseau, délai dépassé, 429, 5xx, réponse illisible, `not_found` et `expired` le laissent tel quel : un utilisateur hors ligne n'est jamais rétrogradé, et une clé connue comme révoquée le reste. Aucun chemin serveur ne réactive une licence aujourd'hui ; la levée ne sert qu'à une correction manuelle de la base. Une clé connue comme révoquée reste donc contrôlée une fois par 24 h, tant qu'elle est enregistrée (décision de Max du 2026-10-03, à la revue de la PR #368) : c'est ce qui reprend une telle correction sans rien demander à l'utilisateur. Le coût est assumé et déclaré dans le guide : le serveur reçoit chaque jour la clé d'un acheteur remboursé qui continue d'utiliser l'app, et aucune interface ne retire une clé enregistrée, donc seul l'enregistrement d'une autre clé arrête cet envoi. Écarté : arrêter le contrôle à la révocation, ce qui aurait obligé à émettre une nouvelle clé pour réparer une révocation faite par erreur.
> **Fail-open.** Seule une réponse « révoquée » pose l'état, seule une réponse `valid:true` le lève. Erreur réseau, délai dépassé, 429, 5xx, réponse illisible, `not_found` et `expired` le laissent tel quel : un utilisateur hors ligne n'est jamais rétrogradé, et une clé connue comme révoquée le reste. Aucun chemin serveur ne réactive une licence aujourd'hui ; la levée ne sert qu'à une correction manuelle de la base. Une clé connue comme révoquée reste donc contrôlée une fois par 24 h, tant qu'elle est enregistrée (décision de Max du 2026-10-03, à la revue de la PR #368) : c'est ce qui reprend une telle correction sans rien demander à l'utilisateur. Le coût est assumé et déclaré dans le guide : le serveur reçoit chaque jour la clé d'un acheteur remboursé qui continue d'utiliser l'app, tant qu'elle reste enregistrée. Écarté : arrêter le contrôle à la révocation, ce qui aurait obligé à émettre une nouvelle clé pour réparer une révocation faite par erreur.
>
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis », et le jeton périmé est suivi en #367.
> **Le retrait de la clé (#370, 2026-10-04).** Jusque-là, aucune interface ne retirait une clé enregistrée, et seul l'enregistrement d'une autre clé arrêtait cet envoi. « Retirer la clé », dans la carte Licence, l'arrête aussi : `remove_license` supprime `license.key` d'abord, puis le jeton d'activation, et une suppression qui échoue rend `storage` sans jamais laisser la clé sans son jeton. `license.check` est gardé : indexé par le condensé de la clé, il ne pèse sur aucune autre, et le supprimer rendrait son édition à une clé révoquée retirée puis ressaisie jusqu'au contrôle suivant, un contournement mis à la portée de tous par un bouton. Une telle clé reste donc en Gratuite, par cet état si aucune autre clé n'a été contrôlée entre-temps, sinon par le contrôle à la saisie (#373) ; hors ligne après le contrôle d'une autre clé, elle garde son édition jusqu'au contrôle suivant (fail-open, ci-dessus). Sur une machine activée avec une clé valide, le retrait libère d'abord sa place sur le serveur de licences (décision de Max du 2026-10-03) ; si le serveur ne répond pas, rien n'est supprimé et l'app demande s'il faut retirer la clé quand même, la place restant occupée.
>
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis ». Depuis #367 (2026-10-04), ce refus supprime aussi le jeton refusé, toujours sans écrire l'état de la clé (décision de Max du 2026-10-03) : ce jeton ne sert plus à rien, quelle que soit la clé enregistrée, et la carte Licence repropose « Activer », qui répare une clé valide ; si la clé enregistrée est elle-même révoquée, « Activer » le dit et l'enregistre sous cette clé. La suppression n'a lieu que si le fichier vaut encore le jeton présenté : une réactivation arrivée pendant la requête refusée garde son jeton neuf. La liste des machines supprime de même un jeton qu'aucune de ses lignes ne représente (règle 4 de l'[ADR 0020](0020-identifiant-machine-derive.md)). Chacune de ces suppressions écrit une ligne au Journal, à code fixe.
>
> **Côté interface.** `LicenseContext` lit l'état depuis Rust (`info.revoked`) et ne décide plus rien ; la carte Licence, chaque écran verrouillé (`UpsellGate`) et la carte Mises à jour en Gratuite nomment la révocation, sans bandeau global ; la page d'erreur, page de repli après un plantage, reste sans cette ligne (revue de la PR #368). Une clé révoquée ressaisie reste en Gratuite.
>
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage), et une app laissée ouverte n'est pas recontrôlée avant son redémarrage. La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
> **Amendement du 2026-10-16 (#369)** — la répartition d'une transaction sur plusieurs catégories (bouton « Répartition » de la page Transactions) passe dans l'édition Base, avec les ajustements, sur décision de Max du 2026-10-03 (analyse de maximus/la-compagnie-maximus#180). L'écran de déverrouillage le promettait déjà (`upsell.features.adjustments`) alors que l'app ne la verrouillait pas : le code s'aligne sur l'offre, il ne la change pas. La page Transactions reste Free ; seul son bouton est gaté, par la clé `adjustments`. En Gratuite, le bouton garde sa place et son icône, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne, répartie ou non. Tant que la licence n'est pas `ready`, il n'affiche aucun cadenas et ne fait rien (`splitAccessFor`, plus strict que `isProfileSwitchLocked` : `allowed` n'y court-circuite pas `ready`). Conformément au §5, les répartitions existantes (`transactions.parent_transaction_id`, `is_split`) restent en base, comptées par les rapports et le tableau de bord, et signalées par l'icône orange ; seules leur ouverture et leur modification sont verrouillées. Écartés : une fenêtre de répartition en lecture seule, et l'annulation d'une répartition permise en Gratuite.
## Contexte
@ -32,12 +36,12 @@ Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
| Module | Gratuite | Base | Premium |
|---|---|---|---|
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ |
| Tableau de bord, Import, Transactions (sans la Répartition, #369), Catégories | ✓ | ✓ | ✓ |
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
| Profil (un seul) | ✓ | ✓ | ✓ |
| Budget | — | ✓ | ✓ |
| Ajustements | — | ✓ | ✓ |
| Ajustements, Répartition des transactions (bouton de la page Transactions, #369) | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Profils multiples | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ |
@ -81,11 +85,11 @@ Le gating est appliqué **uniquement dans l'interface** (routes, navigation, poi
### 4. Upsell verrouillé visible, jamais masqué
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**. Depuis #369, une action gatée peut aussi vivre dans une page Free : le bouton Répartition de la page Transactions porte le cadenas (`SplitButton`, si `ready && !allowed` pour `adjustments`) et ouvre `UpsellGate` dans une fenêtre (`UpsellDialog`).
### 5. Durcissement sec, non destructif
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils. Même règle pour les répartitions de transactions (#369) : en Gratuite, elles restent comptées par les rapports et signalées dans la liste ; seules leur ouverture et leur modification sont verrouillées.
### 6. Dev-override compilé hors des builds normaux
@ -142,6 +146,7 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage), `dev_override_edition` (CWE-489)
- `src/shared/splitGate.ts` — accès du bouton Répartition (`open`, `locked`, `pending` ; #369) ; `src/components/transactions/SplitButton.tsx` ; `src/components/shared/UpsellDialog.tsx` — écran verrouillé dans une fenêtre
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `dev_override_edition` (CWE-489)
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #367 (jeton d'activation périmé laissé par la v0.15.0)
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0) ; #369 (Répartition réservée à l'édition Base, voir l'amendement en tête)

View file

@ -2,13 +2,15 @@
- Status: **Accepted**
- Date: 2026-07-27
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré)
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré), #455 (canari `tar` → `webkit2gtk`)
- S'appuie sur #232 (split de `check.yml`, création de `audit.yml`, retrait du `|| true` qui masquait la sortie de `cargo audit`)
> **Amendement du 2026-07-27 (#312/#313)** — la décision et ses trois règles sont inchangées ; seules les entrées ont bougé. Les deux advisories `quick-xml` ont **quitté la liste le jour même** : `plist` 1.10.0 tire `quick-xml` 0.41.0, qui porte le correctif, dans la borne existante de `tauri`. Elles sont donc **résolues, pas acceptées**, et le mécanisme a fonctionné comme prévu — la vérification par cible a montré qu'un correctif était devenu atteignable, et l'entrée est partie. Il ne reste que `RUSTSEC-2023-0071` (`rsa`), et le garde-fou ne boucle plus que sur ce crate.
>
> Trois passages ci-dessous sont **datés du 2026-07-27 avant ce bump** et à lire comme historiques : `quick-xml` comme exemple de l'insuffisance de `--target all` (règle 1), l'entrée `quick-xml` du tableau de contexte, et l'alternative « override / `[patch.crates-io]` » — cette dernière **n'est plus vraie**, `plist` 1.10.0 déclarant désormais `quick-xml ^0.41.0`. L'argument de fond qu'elle illustre (`[patch.crates-io]` ne franchit pas une frontière semver-incompatible) reste correct, seul le cas d'espèce a disparu.
> **Amendement du 2026-10-17 (#455)** — le canari du garde-fou change ; la décision et ses trois règles restent. Pour retirer `zip` 4.6.1 (RUSTSEC-2026-0367 à -0369, sans version corrigée compatible avec l'updater), `tauri-plugin-updater` est compilé sans sa feature `zip`, qui tirait aussi `tar` : `tar` a quitté le graphe Linux, et le canari d'origine aurait rougi par construction. Il est remplacé par `webkit2gtk`, la webview Linux de Tauri (`wry`), qu'un build Linux de Tauri v2 ne peut pas perdre. Le choix garde la propriété qui faisait la valeur de `tar` : une dépendance présente sur `x86_64-unknown-linux-gnu` et absente de `x86_64-pc-windows-msvc`, dont la présence prouve aussi que `--target` résout les arêtes conditionnelles sur lesquelles repose la règle 1. `tauri`, envisagé à l'analyse de #455, est inconditionnel et n'aurait prouvé que l'affichage de `cargo tree`.
## Contexte
#232 a retiré le `|| true` qui avalait la sortie de `cargo audit` et créé `audit.yml`, un job quotidien (06:00 UTC) **volontairement bloquant** : il *est* le canal de notification pour les advisories publiées entre deux PR Rust, `check-rust.yml` ne tournant que sur ~1 PR sur 40.
@ -52,7 +54,7 @@ L'atteignabilité est une propriété du graphe **résolu aujourd'hui**, pas une
Trois détails la rendent fiable plutôt que décorative :
- le code de sortie de `cargo tree` est testé séparément de sa sortie standard — un crate absent sort en 0 avec un stdout vide, tandis qu'un échec de `cargo tree` sort en non-zéro avec un stdout vide lui aussi ; sans cette distinction, une panne de l'outil se lirait comme une preuve d'absence ;
- un **canari** (`tar`, dépendance réellement présente) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
- un **canari** (`webkit2gtk`, dépendance réellement présente, et seulement sous Linux ; `tar` jusqu'à #455) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
- `.cargo/**` est ajouté aux `paths` de `check-rust.yml` pour qu'une PR ne touchant que la liste déclenche bien la vérification.
Le pendant — une entrée devenue *inutile*, que rien ne signale — est couvert par une issue de suivi portant la condition de retrait : #312 l'a joué pour `quick-xml` (et a servi : le déclencheur avait sauté sans que personne le remarque), #319 le fait pour `rsa`. **Toute entrée de la liste doit en avoir une**, sinon le garde-fou ne couvre qu'une moitié du risque.
@ -91,4 +93,4 @@ Le pendant — une entrée devenue *inutile*, que rien ne signale — est couver
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify suppressed advisories are still unreachable »
- [`.forgejo/workflows/audit.yml`](../../.forgejo/workflows/audit.yml) — le gate quotidien bloquant
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) — récupération de cours via `reqwest`, l'un des deux chemins qui rendent `rustls-webpki` atteignable
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante)
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante), #455 (updater sans sa feature `zip`, canari `tar` → `webkit2gtk`)

View file

@ -2,7 +2,7 @@
- Status: **Accepted**
- Date: 2026-10-01
- Issues: #350 (cette décision), maximus/maximus-api#82 (identifiants bruts déjà stockés par le serveur), maximus/la-compagnie-maximus#184 (données d'activation absentes de la politique de confidentialité)
- Issues: #350 (cette décision), maximus/maximus-api#82 (identifiants bruts déjà stockés par le serveur), maximus/la-compagnie-maximus#184 (données d'activation absentes de la politique de confidentialité), #367 (jeton qui ne représente plus aucune activation, règle 4)
- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md), amendement #347 (l'édition suit la clé seule), et sur l'[ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) (cours du marché via maximus-api)
## Contexte
@ -62,6 +62,7 @@ Un jeton valide dont le claim `machine_id` vaut l'identifiant **brut** de cette
| de cette machine | ligne d'une autre machine | gardé |
- **Un identifiant du système illisible ne bloque ni la liste ni la désactivation.** Aucune ligne n'est alors marquée, et le jeton ne part que s'il a été émis pour la ligne désactivée. L'état d'activation se lit « Non activée », et l'activation échoue avant tout appel.
- **Un jeton qui ne représente plus aucune activation part aussi (#367, 2026-10-04).** À l'ouverture de la liste des machines, `drop_token_of_unlisted_machine_in` supprime le jeton quand aucune ligne de la clé enregistrée ne nomme ce poste, sous l'une ou l'autre forme : jeton d'une autre licence laissé par la v0.15.0, place libérée depuis un autre poste. Rien ne part sans identifiant du système lisible, sur une liste incomplète (une ligne illisible pourrait être celle de ce poste), pour un jeton qui n'active pas ce poste, ni quand le jeton a changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Le refus `license_revoked` de la route des cours supprime le jeton qu'il a refusé, à la même condition. Une ligne que le serveur rehacherait (maximus/maximus-api#82) n'est reconnue que si elle vaut exactement le dérivé de la règle 1 : sous une autre forme, une machine activée en brut perdrait son jeton à l'ouverture de la liste, et sa réactivation prendrait une seconde place. Un test l'épingle côté client avec le vecteur de référence.
### 5. Ce que la transition laisse en place (D2)

View file

@ -0,0 +1,93 @@
# ADR 0021 — Les profils secondaires rattrapent leurs migrations à l'ouverture
- Status: **Accepted**
- Date: 2026-10-06
- Issues: #390
- Modifie l'[ADR 0003](0003-sqlx-migrations.md) (le schéma consolidé crée un profil, il ne le tient pas à jour) et l'[ADR 0005](0005-multi-profile-db.md) (« les migrations s'appliquent indépendamment à chaque base » était faux pour les profils secondaires)
## Contexte
Le constat de départ est un bug d'onboarding : classer une transaction à la main ne cochait pas la case 5 de la liste de démarrage. `updateTransactionCategory` liait le booléen JS `true`, que tauri-plugin-sql 2.3.2 transmet à sqlx comme une valeur JSON, donc stockée en texte `'true'`. La liste compte `is_manually_categorized = 1` et ne trouvait rien. Corriger les valeurs déjà en base par une migration v18 a fait apparaître deux défauts plus anciens.
**Les profils secondaires ne reçoivent aucune migration.** Le plugin associe la liste des migrations à une URL (`add_migrations("sqlite:simpl_resultat.db", …)`) et la retire au premier `Database.load`. Un profil secondaire (`profile_<id>.db`, depuis la v0.3.0) est créé par `initializeNewProfileDb` avec le schéma consolidé de la version qui le crée, puis n'en reçoit plus aucune. Un profil secondaire créé avant la v0.15.0 n'a donc pas les colonnes de format de `import_sources` (v17), et un profil créé avant la v0.10.0 n'a ni `balance_securities` ni `balance_accounts.kind`. Les commentaires de `consolidated_schema.sql` et de `get_new_profile_init_sql`, ainsi que l'ADR 0005, affirmaient le contraire.
**Trois commandes cherchaient les bases au mauvais endroit sous Linux.** Le plugin range les bases sous `app_config_dir()` (`wrapper.rs` : `DbPool::connect` puis `path_mapper`). `repair_migrations`, `delete_profile_db` et `compute_account_return` les cherchaient sous `app_data_dir()`, là où vivent `profiles.json` et les fichiers de licence. C'est le même dossier sous Windows et macOS, pas sous Linux (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`). Sous Linux, `repair_migrations` ne faisait donc rien, supprimer un profil laissait sa base sur le disque, et le rendement d'un compte du Bilan échouait.
## Décision
**À chaque ouverture d'un profil, avant `Database.load`, la commande `prepare_profile_db` amène un profil secondaire à la dernière migration, avec la même liste que le profil par défaut, et répare les drapeaux texte de tous les profils.** Neuf règles la tiennent ; le code est dans `src-tauri/src/database/profile_db.rs`.
### 1. Une seule liste de migrations
`migrations()` (lib.rs) sert le plugin et le rattrapage. Le test `migration_checksums_match_the_released_ones` épingle le SHA-384 de chaque version, calculé avant que la liste soit sortie de `run()`. L'historique d'un profil secondaire vit dans la même table `_sqlx_migrations` que celui du profil par défaut, au format sqlx : même DDL, même checksum.
### 2. Un nouveau profil naît avec son historique
`get_new_profile_init_sql` renvoie le schéma consolidé, puis `history_sql`, qui inscrit toutes les versions avec leur checksum. Le plugin exécute les deux dans le même flux. Un profil créé depuis #390 n'a donc jamais rien à déduire : les versions suivantes l'atteignent par le rattrapage.
### 3. Un profil sans historique : un plancher déduit du schéma
Les profils créés avant #390 n'ont pas d'historique. Ils forment un ensemble fermé, un état par schéma consolidé qu'une version publiée a livré, et chaque état est conservé en fixture sous `src-tauri/src/database/fixtures/` :
| État | Versions qui l'ont créé | Plancher |
|---|---|---|
| A1 | v0.3.0 à v0.8.3, sauf v0.4.0 | 6 |
| A2 | v0.4.0 (seed à trois niveaux, retiré en v0.4.1) | 6 |
| B | v0.8.4 | 8 |
| C et D | v0.9.0, v0.9.1 (même schéma ; v11 ne touche que des données) | 10 |
| E | v0.10.0 à v0.14.0 | 16 |
| F | v0.15.0 à v0.17.x | 17 |
`infer_floor` reconnaît l'état par la dernière colonne ou table ajoutée, en partant de la plus récente. Le plancher vient du schéma, jamais de l'historique : une première écriture interrompue ne peut donc pas faire passer une version inférieure pour une version à appliquer. C'est ce qui interdit de rejouer le seed v2 (`DELETE FROM categories`) sur une base remplie. Un schéma qu'aucun marqueur ne reconnaît n'est pas touché. Aucun marqueur ne s'ajoute après la v17, puisqu'aucune base sans historique ne peut porter une version postérieure.
### 4. Une copie avant la première écriture
Quand une version reste à appliquer, la base est d'abord copiée par `VACUUM INTO` vers `<dossier des bases>/migration-backups/<profil>-before-v<N>.db`. Le chemin est lié, jamais formaté dans le SQL, ce qui tient un dossier comme `C:\Users\L'Heureux`. La copie est écrite en `.tmp`, synchronisée sur disque, puis renommée : un fichier final existant est donc complet. Une copie qui échoue arrête tout avant la moindre écriture. La copie est supprimée avec le profil.
**Rétention (#395, décision de Max du 2026-10-10) : une seule copie par profil, la plus récente.** La copie est refaite à chaque rattrapage, même quand un fichier du même nom existe : laissé par un rattrapage qui s'est arrêté, il précède les données saisies depuis. Le renommage remplace ce fichier, qui n'est jamais supprimé d'abord. Après un rattrapage **réussi**, et seulement après le renommage, les autres `<profil>-before-v*.db` (et `.db.tmp`) de ce profil sont supprimés (`remove_backups(dossier, profil, Some(copie))`, au mieux : une copie restée ne nuit pas). Rien n'est supprimé quand le rattrapage s'arrête, quand la copie échoue, ni quand rien n'est à appliquer. Coût accepté : tant qu'un rattrapage échoue, chaque ouverture refait un `VACUUM INTO`.
Elle reste dans le dossier des bases, pas dans `~/Documents/Simpl-Resultat/backups/` que la sauvegarde manuelle utilise : ce dossier est souvent synchronisé vers un service en ligne (OneDrive sous Windows), et une copie automatique en clair d'une base financière n'a rien à y faire.
### 5. Des écritures atomiques
L'historique, soit la table et chaque version jusqu'au plancher, est écrit en une transaction. Chaque version à appliquer s'exécute ensuite dans sa propre transaction. La première erreur arrête le rattrapage et les versions déjà appliquées restent inscrites.
### 6. Les clés étrangères restent actives
Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étrangère est reprise une fois sans elles. Les lignes qu'elle laisse sans parent repassent à NULL quand leur clé déclare `ON DELETE SET NULL` ; toute autre violation annule la version. Le cas réel est la v7, qui ajoute trois sous-catégories sous la catégorie 31 : un profil A dont les catégories viennent d'une restauration plus récente n'a plus de catégorie 31, et les trois sous-catégories deviennent des catégories racines. Couper les clés pour tout le rattrapage aurait laissé ces parents introuvables, et la restauration d'une sauvegarde de ce profil aurait échoué.
### 7. L'échec reste ouvert
Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `<profile folder>` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Limite : un chemin relatif sans début reconnu (`dossier/sous dossier/x.db`) n'a que ses séparateurs remplacés par `-` ; il ne part pas du dossier personnel, donc n'en contient pas le nom. Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`.
### 8. Un seul dossier des bases
`profile_db_path` résout `app_config_dir()`, le dossier du plugin, pour les quatre commandes qui ouvrent une base. Il n'accepte que les deux formes de nom que l'application a jamais données : `simpl_resultat.db` et `profile_<hex>.db`.
### 9. Les drapeaux texte sont réparés partout
À chaque ouverture, dans tous les profils, `'true'` devient 1 et tout autre texte 0. L'opération est idempotente. Côté TypeScript, `toSqlFlag` empêche d'écrire de nouveau un booléen, y compris à la restauration d'une sauvegarde qui porte encore `"true"`.
## Conséquences
### Positives
- Un profil secondaire ancien retrouve le schéma courant à sa prochaine ouverture, et chaque migration future l'atteint.
- Sous Linux, supprimer un profil supprime enfin sa base, ses fichiers annexes et ses copies.
- Les règles sont éprouvées sur les sept états réels, avec des données d'utilisateur : comptes du Bilan convertis par v12 à v16, source d'import complétée par v17, drapeau réparé.
### Négatives et points de vigilance
- **Une correction de données livrée seulement en migration n'atteint pas un nouveau profil**, qui naît avec l'historique complet. Elle doit aussi entrer dans `consolidated_schema.sql` (règle `.claude/rules/sql-migrations.md`). Le test qui compare chaque état rattrapé à un profil neuf ne regarde que le schéma.
- **Deux moteurs appliquent les mêmes migrations** : sqlx pour le profil par défaut, rusqlite pour les autres. Le SQL est le même et les clés étrangères sont actives des deux côtés. La reprise sans clés étrangères n'existe que du côté rusqlite.
- **La commande est synchrone.** Tauri l'exécute sur le fil principal, ce qui sérialise deux ouvertures du même profil (React StrictMode monte deux fois en développement). L'interface attend pendant un rattrapage, une fois par profil ancien, et la durée est écrite au Journal.
- **Le délai de démarrage couvre le rattrapage du profil actif** (#395). `App.tsx` affiche la page d'erreur si l'ouverture dépasse `STARTUP_TIMEOUT_MS` (10 s, `src/utils/startupConnection.ts`), délai qui couvre `repair_migrations`, `prepare_profile_db` et `Database.load`. Mesure (test `#[ignore]` `a_large_profile_catch_up_duration`) : un profil au plus bas plancher reconnu (6, schéma v0.3.0) avec 100 001 transactions (17 Mo, 10 001 drapeaux texte), rattrapé de la v7 à la v17 avec la copie `VACUUM INTO`, le `foreign_key_check` final et la réparation des drapeaux, prend **306 ms en release** et 422 ms en debug, le 2026-10-12, sur le ThinkPad T490 de Max (Intel Core i5-8365U, 4 cœurs / 8 fils, 1,6 à 4,1 GHz, 16 Go, SSD NVMe), sous Linux. Règle fixée en planification : au-delà de 3 s, le délai passait à 30 s ; la mesure étant sous 3 s, il reste à 10 s. Risque résiduel : un disque lent (HDD, antivirus Windows qui inspecte la copie), une base bien plus grosse ou une machine bien plus faible peuvent dépasser 10 s. La page d'erreur s'affiche alors, mais l'ouverture continue et, depuis #395, l'application s'affiche dès qu'elle aboutit (`setStartupError(null)` au succès). Au-delà de 15 s, l'alerte « profile opening » du verrou de `db.ts` (#408) s'écrit aussi au Journal, ce qui est attendu.
- **Le test qui distingue les deux dossiers ne voit l'écart que sous Linux**, la plateforme de la CI. La vérification à la main sous Linux reste due.
- **Les profils supprimés sous Linux avant ce correctif** ont laissé leur base dans `~/.config/com.simpl.resultat/`. Rien ne supprime automatiquement un fichier que `profiles.json` ne cite pas : un `profiles.json` abîmé coûterait alors de vrais profils.
## Alternatives écartées
- **Une migration v18 de données**, proposée par le corps de #390 : elle n'aurait réparé que le profil par défaut.
- **Enregistrer chaque profil auprès du plugin au démarrage** (`add_migrations` par URL dans `setup`) : l'état des migrations du plugin est privé et figé au démarrage, un profil créé en cours de session n'y figurerait pas, et sqlx aurait rejoué v1 et v2 sur une base sans historique.
- **Un rattrapage côté TypeScript** : le plugin n'expose aucune transaction (voir `db.ts`), et ni `VACUUM INTO` ni les clés étrangères ne seraient testables hors de Tauri.
- **Des clés étrangères coupées pendant tout le rattrapage** : les actions `ON DELETE` ne joueraient plus comme sur le profil par défaut, et les lignes sans parent feraient échouer la restauration d'une sauvegarde de ce profil.

View file

@ -0,0 +1,94 @@
# ADR 0022 — Canal d'événements de données : les écritures signalent, le parcours de démarrage relit
- Status: **Accepted**
- Date: 2026-10-08
- Issues: #396 (milestone `planned-2026-10-06-onboarding-parcours-guide`, phase 2 de l'onboarding, #391) ; amendé par #425 (étapes de visite, §2, §4 et §5)
- S'appuie sur l'[ADR 0002](0002-useReducer-vs-redux.md) (un `useReducer` par domaine, pas de store global) et l'[ADR 0016](0016-persistance-etat-ui-par-profil.md) (préférences par profil dans `user_preferences`)
## Contexte
La phase 2 de l'onboarding fait suivre l'utilisateur par le parcours de démarrage : un bandeau sur chaque page et, sur la page de l'étape en cours, une carte qui voit l'étape se cocher pendant que la page est ouverte. Il faut donc que l'état du parcours soit partagé par toute l'application et relu après chaque écriture qui le concerne.
Rien ne le permettait. Les pages lisent leurs données à leur montage, et aucun mécanisme ne relie une écriture à un écran qui en dépend. La liste de démarrage de la phase 1 (#351) vivait dans `DashboardPage`, lue une fois au montage : choisir un dossier d'import, importer un fichier ou classer une transaction ne la mettait à jour qu'au retour sur le tableau de bord.
Le parcours se calcule depuis la base : cinq compteurs lus par `COUNTERS_SQL` (`onboardingService.ts`), sur `import_sources`, `imported_files`, `transactions` et `budget_entries`, plus la préférence `import_folder`. Ces données s'écrivent depuis plusieurs pages et plusieurs services, et une restauration de sauvegarde depuis Paramètres → Données les remplace toutes.
## Décision
**Un canal d'événements de données, `src/services/dataEvents.ts` : les écritures qui peuvent changer un compteur appellent `notifyDataChanged()` une fois réussies, et le fournisseur du parcours, abonné par `subscribeDataChanged`, relit son état.** Le module n'importe pas React ; il suit le modèle de `logService.subscribe`.
### 1. Portée
Le message dit seulement : « des données que lisent des compteurs ont peut-être changé ». Il ne porte rien, ni table, ni identifiant, ni valeur : l'abonné relit ce dont il a besoin, en une requête. Le nom est côté données, parce que la couche de données ne sait pas qui écoute : aucun service n'importe de hook ni de contexte.
`notifyDataChanged` est synchrone et ne lève jamais. Un abonné qui lève est écrit au Journal (`Data events: a subscriber failed`) et les autres sont appelés quand même : une écriture réussie n'est jamais présentée comme un échec à cause d'un abonné.
### 2. Les écritures qui notifient
La liste est construite depuis les tables que lit `COUNTERS_SQL`, plus le dossier d'import. Chaque notification part **après une écriture réussie** ; une écriture qui lève ne notifie pas. Une fonction qui écrit en plusieurs requêtes notifie une fois, après la dernière.
| Service | Écritures | Moment |
|---|---|---|
| `userPreferenceService.ts` | `setImportFolder` | après l'écriture de la préférence (`setPreference` seul ne notifie pas) |
| `importSourceService.ts` | `createSource`, `deleteSource` | après l'écriture ; `createSource` notifie avant la relecture qui suit un conflit de nom, qui ne fait que lire |
| `importedFileService.ts` | `createImportedFile` (insertion comme mise à jour), `updateFileStatus`, `deleteImportWithTransactions`, `deleteAllImportsWithTransactions` | après l'écriture ; pour les suppressions, après la dernière (source orpheline comprise). Un refus du contrôle préalable (transactions liées au Bilan) ne notifie pas |
| `transactionService.ts` | `insertBatch`, `updateTransactionCategory` | `insertBatch` une fois, après la dernière insertion |
| `categorizationService.ts` | `applyKeywordWithReassignment` | après le `COMMIT`, une fois le verrou de `db.ts` relâché ; un `ROLLBACK` relance l'erreur et ne notifie pas |
| `budgetService.ts` | `upsertBudgetEntry`, `deleteBudgetEntry`, `upsertBudgetEntriesForYear`, `applyTemplate` | après l'écriture ; `upsertBudgetEntriesForYear` après les douze mois, `applyTemplate` une fois après la dernière entrée du modèle |
| `dataExportService.ts` | `runRestore` | après le `COMMIT`, une fois le verrou relâché : un seul point pour les trois modes de restauration |
| `useImportWizard.ts` | fin d'`executeImport` | une notification de plus, en succès comme en échec (`finally`), pour que la dernière relecture voie l'état final de l'import |
Cette liste ne doit pas rétrécir. Sans `insertBatch` ni `updateFileStatus`, la dernière relecture d'un import partait avant l'insertion des transactions, et le parcours restait sur un état intermédiaire.
Ne notifient pas :
- `updateSource`, qui ne change aucun compte ; les notes d'une transaction ; les modèles de budget ; toute écriture d'une table que ne lit aucun compteur ;
- `categoryMigrationService.applyMigration`, qui réécrit `budget_entries` (`DELETE` des mois en double sous la catégorie cible, puis `UPDATE` de la catégorie) : il ne tourne que sur sa propre page, la migration des catégories, et la relecture qui suit tout changement de route rattrape le compteur ;
- `transactionService.deleteTransaction` (`DELETE FROM transactions`), sans appelant aujourd'hui : son premier appelant l'ajoute au tableau, avec son test ;
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, `acknowledgeStep`, et `initializeGuidedPath`, la règle D9, et `initializeVisitSteps`, l'initialisation des étapes de visite, #425). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
**Règle pour la suite** : une nouvelle écriture d'une table que lit `COUNTERS_SQL` s'ajoute à ce tableau et notifie, avec un test qui prouve la notification après un succès et son absence après un échec. Une écriture faite dans `withTransaction` notifie après la fin de `withTransaction`, jamais à l'intérieur du rappel : le `COMMIT` est fait et le verrou relâché.
### 3. Une attente de 150 ms côté abonné
L'émetteur n'attend rien. L'abonné attend 150 ms sans nouvel événement avant de relire (`followDataChanges`, `useOnboardingChecklist.ts`) : chaque événement relance l'attente, et une rafale, comme les écritures d'un import (source, fichiers, insertions, fin), coûte une seule relecture. Le démontage de l'abonné désabonne et annule une relecture en attente.
### 4. Les réponses périmées sont ignorées
Les chargements sont numérotés, et le reducer n'applique que la réponse du dernier lancé : une réponse lente ne remplace jamais une plus récente. Une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle, lu avant son écriture : c'est la course entre « Masquer » et un rafraîchissement relevée en #384. Une relecture qui échoue après un premier succès garde l'état affiché, et seul le passage en échec est écrit au Journal, pour ne pas chasser une vraie cause hors des dernières lignes jointes à une rétroaction.
### 5. Abonnés admis
Un abonné :
- relit, et n'écrit rien en réponse : une écriture déclenchée par une notification ouvrirait une boucle ;
- regroupe les rafales par une attente, et ignore les réponses périmées ;
- se désabonne à son démontage ;
- ne fait rien de coûteux dans l'appel lui-même, qui est synchrone : il arme un minuteur.
Une exception admise, comme la règle D9 : le cochage d'office des étapes de visite (#425). Il n'est pas une réponse à une notification, mais une écriture unique par profil (`initializeVisitSteps`), lancée après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, gardée par une promesse partagée contre le double montage de StrictMode, et qui relit elle-même une fois écrite. Elle ne notifie pas, et ses écritures sont idempotentes, sans transaction : un arrêt entre deux refait le même calcul à l'ouverture suivante.
Aujourd'hui, un seul abonné : le fournisseur du parcours de démarrage (`OnboardingProvider`, par `useOnboardingChecklist`). Un abonné qui aurait besoin d'autres tables élargit la portée du canal : il amende cet ADR, avec la liste des écritures concernées et leurs tests.
## Conséquences
### Positives
- Le parcours suit l'utilisateur en direct, sur toutes les pages, sans retour au tableau de bord.
- Une seule liste d'écritures, au même endroit que le code qui écrit, et gardée par un test par service.
- Les services restent ignorants de l'interface : ils n'importent qu'un module sans React.
### Négatives et points de vigilance
- **La liste est manuelle.** Une écriture oubliée ne coche pas l'étape en direct ; la relecture au changement de page la rattrape.
- **Une écriture en plusieurs requêtes sans transaction qui échoue à mi-chemin ne notifie pas**, alors que des lignes ont pu changer (par exemple `insertBatch` après quelques insertions). La notification finale d'`executeImport`, ou la relecture suivante, rattrape.
- **La notification est synchrone.** Un abonné lent ralentirait l'écriture qui notifie : la règle des abonnés admis l'interdit.
- **Le canal est global à la fenêtre.** Un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`) ; le fournisseur du profil précédent se désabonne alors et annule sa relecture en attente.
## Alternatives écartées
- **Relire la base à intervalle fixe** : des requêtes sans raison sur le verrou unique de `db.ts`, et une étape qui se coche avec un retard.
- **Un store global de données** : contraire à l'[ADR 0002](0002-useReducer-vs-redux.md), et un store ne sait pas mieux qu'un canal quand la base change.
- **Notifier depuis `db.ts` à chaque `execute`** : un import écrit une requête par transaction, et le canal ne saurait pas distinguer une table d'une autre ; il deviendrait bavard et sans portée.
- **Des événements typés (table, identifiant)** : plus de couplage, et aucun abonné n'en a besoin, puisque les compteurs se relisent en une requête.
- **Faire appeler `refresh()` par les hooks des pages** : chaque page dépendrait du parcours, et une écriture faite ailleurs, comme une restauration depuis Paramètres → Données, serait oubliée.

View file

@ -0,0 +1,161 @@
# ADR 0023 — Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite
- Status: **Accepted**
- Date: 2026-10-10
- Issues: #345 (cette décision), PR #344 (deuxième dérive, contournée en n'appliquant que la strophe `h2` à la main), #348 (mesure `rustls` : la couche TLS en général ne déclenche pas la dérive)
- S'appuie sur l'[ADR 0018](0018-suppression-advisories-non-atteignables.md) : même motif de garde-fou (chaque vérification loggée, canari, code de sortie de l'outil testé à part de sa sortie)
## Contexte
Sept crates du fichier de verrouillage déclarent `windows-sys` par une borne qui couvre plusieurs mineures :
| crate | exigence déclarée sur `windows-sys` |
|---|---|
| `colored` 3.1.1, `winapi-util` 0.1.11 | `>=0.48, <=0.61` |
| `errno` 0.3.14, `rustix` 1.1.3, `tempfile` 3.24.0 | `>=0.52, <0.62` |
| `rustls-platform-verifier` 0.6.2 | `>=0.52.0, <0.62.0` |
| `dirs-sys` 0.5.0 | `>=0.59.0` |
Ce sont les seuls du graphe dans ce cas (relevé par `cargo metadata` le 2026-10-10). Toutes les autres déclarations sont des `^0.x`, qui n'admettent qu'une mineure, donc une seule des versions présentes : elles ne peuvent pas dériver. Le lock contient six versions de `windows-sys`, toutes cibles confondues (0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2), et les sept crates peuvent pointer n'importe laquelle de celles que leur borne admet, sans ajouter de paquet au lock et sans violer aucun contrat.
Les sept pointent **`0.61.2`**. C'est l'état du tag v0.14.0 (2026-07-18), dont la release (run 322) a traversé un vrai build Windows NSIS, et celui de chaque release depuis : v0.15.0 (run 402), v0.16.0 (run 451), v0.17.0 (run 491), v0.17.1 (run 508), vérifié le 2026-10-10 en rejouant le garde ci-dessous sur le lock de chaque tag. L'état dérivé n'a traversé aucun build Windows : aucune release n'a eu lieu pendant la seule fenêtre où `main` l'a porté, de #310 (2026-07-27) à la PR #321 (2026-08-15).
### La contrainte n'était tenue par rien
| | |
|---|---|
| #310 (2026-07-27) | `cargo update -p rustls-webpki -p tar` fait descendre les sept arêtes ; vu, et jugé inoffensif parce que sans effet sur Linux |
| PR #321 (2026-08-15) | la revue les remonte à `0.61.2`, crate par crate contre le tag v0.14.0 |
| PR #344 (2026-09-04) | `cargo update -p h2` recommence ; contourné en n'appliquant que la strophe `h2` à la main |
| #348 (2026-09-21) | `cargo update -p rustls --precise 0.23.45` ne bouge rien, mais il a fallu remesurer pour le savoir |
Hors du lock, la contrainte ne vivait que dans de la prose (`STATE.md`, des messages de commit) : la prochaine mise à jour la régresserait, et il faudrait qu'un relecteur reconnaisse sept noms de crates pour le voir. C'est le mode d'échec que l'ADR 0018 traite pour les suppressions d'advisories : une décision qui ne vit que dans de la prose n'est pas une décision tenue.
### Le déclencheur, mesuré
| commande, sur une copie du lock | effet sur les sept arêtes | mesure |
|---|---|---|
| `cargo update -p anyhow` | aucun | 2026-09-04 |
| `cargo update -p rustls --precise 0.23.45` | aucun ; le `diff` de toutes les lignes `windows-sys` du lock est vide | 2026-09-21 (#348) |
| `cargo update -p hyper` | les sept descendent : `colored` et `winapi-util` vers 0.48.0, `errno`, `rustix`, `tempfile` et `rustls-platform-verifier` vers 0.52.0, `dirs-sys` vers 0.59.0 | 2026-09-04, refaite le 2026-10-10 (`hyper` 1.8.1 → 1.12.0) |
Dans cette mesure, chaque arête est descendue vers la plus basse version déjà présente que sa borne admet, et la dérive de #310 (commit `f4b09b0`) a la même répartition : deux arêtes vers 0.48.0, quatre vers 0.52.0, une vers 0.59.0. `hyper` et ce qu'il tire (`h2` dans la PR #344) la déclenchent, la couche TLS en général non : `rustls`, en son cœur, ne déplace rien. Mais `rustls-webpki` et `tar` l'ont déclenchée en #310. Aucune règle simple ne dit quelle mise à jour la provoque, et la dernière qu'on a formulée (« tout bump du sous-arbre réseau/TLS ») a été démentie par #348. C'est donc la version résolue qu'il faut garder, pas une liste de déclencheurs.
**Et cargo n'en dit rien.** Le 2026-10-10, `cargo update -p hyper` annonce :
```
Locking 2 packages to latest compatible versions
Updating hyper v1.8.1 -> v1.12.0
Updating want v0.3.1 -> v0.3.2
```
pendant qu'il réécrit aussi les sept arêtes. L'ensemble des paquets ne bouge pas (666 avant et après) ; seules les arêtes changent, et cargo ne rapporte que les paquets. Or les deux dérives passées sont venues avec des correctifs d'advisories RustSec (`rustls-webpki` et `tar` en #310, `h2` dans la PR #344) : la dérive arrive avec les changements les plus pressés et les moins relus.
### Ce que coûte la dérive
Pas un risque de correction : les deux états sont dans les bornes déclarées par les sept auteurs. Le coût est de taille et de temps de compilation, et il tombe sur le build Windows, que personne ne mesurait :
| état | versions de `windows-sys` compilées pour `x86_64-pc-windows-msvc` |
|---|---|
| épinglé (`0.61.2`) | **3** : 0.59.0, 0.60.2, 0.61.2 |
| dérivé | **5** : les mêmes, plus 0.48.0 et 0.52.0 |
Mesuré par `cargo tree --target x86_64-pc-windows-msvc -e normal,build`, refait le 2026-10-10 sur le lock de `main` et sur sa copie dérivée par `cargo update -p hyper`. Deux crates `windows-sys` complets de plus dans chaque build Windows, ajoutés en effet de bord, dans une configuration qu'aucun build Windows n'a traversée.
## Décision
**Un garde-fou CI bloquant sur la version résolue, et cet ADR pour en porter la raison et la sortie.**
L'étape `Verify windows-sys pinning` de `check-rust.yml`, placée après `cargo check` à côté du garde des suppressions, lit `src-tauri/Cargo.lock` avec `awk` et applique quatre règles. `awk` (mawk) fait partie de l'image de base `ubuntu:22.04`, alors que `python3` n'est présent dans ce conteneur que comme dépendance transitive des paquets `apt` installés à l'étape précédente (journal du run 517), ce que rien ne garantit :
1. **Chaque arête des sept crates.** Pour `colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile` et `winapi-util`, chaque bloc `[[package]]` de ce nom doit porter au moins une arête `windows-sys`, et chacune doit nommer `0.61.2`. L'absence de bloc pour l'un de ces noms fait échouer l'étape (crate renommé ou sorti du graphe). Un suffixe de source sur une arête (` (registry+…)`) est toléré.
2. **Arête nue.** Cargo n'écrit `"windows-sys 0.61.2"` que tant que plusieurs versions coexistent ; quand il n'en reste qu'une, il écrit `"windows-sys"`. Une arête nue ne passe que si le lock ne contient qu'un seul paquet `windows-sys`, en `0.61.2`.
3. **Ensemble des versions.** Les versions de `windows-sys` du lock, toutes cibles confondues et doublons compris, doivent égaler exactement `{0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2}` (état du 2026-10-10). Une version qui apparaît ou disparaît fait échouer l'étape avec un renvoi à cet ADR : c'est soit le coût réel (une version de plus), soit le début de la sortie, et dans les deux cas une décision.
4. **Canari.** La boucle doit avoir lu et vérifié une arête de `tempfile`, crate connu pour en porter une, faute de quoi son silence ne prouve rien et l'étape échoue.
Comme dans l'ADR 0018, chaque vérification est loggée, succès compris (`ok: rustix 1.1.3 -> windows-sys 0.61.2`), puis un bilan (`windows-sys pinning: 9 checks, exit 0`), et le code de sortie d'`awk` est testé à part de sa sortie. Une sortie vide échoue déjà (ensemble des versions, blocs introuvables, canari) ; ce test attrape seul un `awk` qui échoue après avoir tout imprimé, et sans lui le garde sortirait en 0 (éprouvé à la revue de la PR #415). Le garde n'appelle pas cargo, donc ni l'index chaud ni `--locked` ne le concernent ; il lit le lock tel que `cargo check` l'a laissé.
La liste gardée, la version épinglée et l'ensemble attendu sont en tête du script, sous un commentaire qui renvoie ici. On ne les modifie que sur la preuve que demandent les sections suivantes, jamais pour faire passer l'étape au vert.
**Friction assumée** : chaque mise à jour qui déplace ces arêtes (celles qui atteignent `hyper`, entre autres) fera échouer l'étape et demandera une édition à la main du lock. C'est l'objet : un effet de bord silencieux devient une décision explicite.
### Éprouvé par mutation
Le 2026-10-10, le texte du `run:` a été extrait du workflow et lancé tel quel, avec les options du runner (`bash --noprofile --norc -e -o pipefail`), sur des copies du lock :
| lock | sortie |
|---|---|
| celui de `main`, et ceux des tags v0.14.0 à v0.17.1 | exit 0, 9 vérifications |
| dérivé par `cargo update -p hyper` | exit 1, les sept arêtes en échec |
| un bloc `windows-sys` 0.53.0 ajouté | exit 1, ensemble des versions |
| `colored` renommé | exit 1, bloc introuvable |
| absent (`awk` sort en 2) | exit 1, 0 vérification |
| `tempfile` retiré de la liste gardée, ou liste vide (script modifié) | exit 1, canari |
## Procédure quand le garde échoue
La ligne `FAIL:` dit quelle règle a cédé.
- **Une arête a quitté `0.61.2`** (règle 1), en général après un `cargo update` (`-p hyper`, `-p h2` et `-p rustls-webpki -p tar` l'ont fait). Garder la mise à jour voulue et remettre les arêtes, de l'une de deux façons qui mènent au même lock :
- n'appliquer à la main que la ou les strophes du paquet visé (version, checksum, dépendances), comme la PR #344 l'a fait pour `h2` ;
- ou garder la sortie de `cargo update` et ramener à la main les arêtes signalées à `"windows-sys 0.61.2"`, les seules lignes à toucher.
Puis vérifier que cargo accepte le lock tel quel, sans le réécrire :
```
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked > /dev/null
```
Éprouvé le 2026-10-10 sur la copie dérivée par `cargo update -p hyper` : les sept arêtes remises, le diff contre `main` se réduit aux strophes `hyper` et `want`, `cargo metadata --locked` l'accepte sans modifier le fichier, et le garde repasse.
- **L'ensemble des versions a changé** (règle 3). Pour une version ajoutée, chercher qui la tire et si elle est compilée pour Windows (`cargo tree --manifest-path src-tauri/Cargo.toml -i windows-sys@<version> --target x86_64-pc-windows-msvc`) ; si c'est le début du passage à 0.62, la condition de sortie est atteinte (section suivante). Une version retirée est un pas vers la convergence. Dans les deux cas, `EXPECTED` change dans la PR qui change le lock, preuve à l'appui dans la PR.
- **Bloc introuvable** : le crate a été renommé ou est sorti du graphe. Retirer son nom de `CRATES` après avoir revérifié la liste des bornes larges (commande de la section Conséquences).
- **Plus d'arête `windows-sys` dans un bloc gardé** (`has no windows-sys edge left`) : le crate a cessé de dépendre de `windows-sys` dans cette version. Même traitement qu'un bloc introuvable ; si c'est `tempfile`, choisir aussi un autre canari parmi les crates restants.
- **Un second bloc d'un crate gardé dont la borne exclut `0.61.2`** (par exemple un `tempfile` plus ancien, d'avant les bornes larges, tiré par une autre dépendance) : la règle 1 échoue, et la réparation de la première puce est impossible, puisque `cargo metadata --locked` refuse une arête hors borne (code 101, éprouvé à la revue de la PR #415). Décider dans la PR qui change le lock : admettre ce bloc par nom et version dans le script, ou écarter la dépendance qui le tire ; dans les deux cas, amender cet ADR.
- **Canari ou `awk` en échec** : le garde lui-même est cassé. Le réparer ; ne jamais changer le canari ou la liste pour le faire taire.
## Condition de sortie
L'épinglage n'est pas fait pour durer indéfiniment. Il change ou disparaît dans deux cas.
- **Au passage de l'écosystème à `windows-sys` 0.62.** La borne `<=0.61` de `colored` et `winapi-util`, comme le `<0.62` d'`errno`, `rustix`, `tempfile` et `rustls-platform-verifier`, les empêche de suivre ; seul `dirs-sys` (`>=0.59.0`, sans plafond) le peut. Quand un crate du graphe tirera 0.62, la règle 3 rougira et il faudra décider sur preuve : une nouvelle configuration n'est tenue qu'après un vrai build Windows, c'est-à-dire un run `release.yml`. `PIN`, `EXPECTED` et au besoin `CRATES` changent alors dans la même PR que le lock, avec un amendement de cet ADR. Le garde est mis à jour, jamais contourné.
- **À la convergence vers un seul `windows-sys`**, la sortie souhaitée. Plus aucune arête n'a où dériver, et le coût mesuré disparaît. Le garde le signalera (règle 3 en échec, arêtes nues admises par la règle 2) ; l'étape peut alors être retirée, avec un amendement de cet ADR.
## Alternatives considérées
- **Check croisé Windows en CI** (`cargo check --target x86_64-pc-windows-msvc` depuis Linux). L'option la plus attirante, puisqu'elle prouverait que le graphe Windows compile quel que soit l'état. Testée à l'analyse de #345, elle échoue : `cargo check` a besoin du toolchain natif de la cible dès qu'un build script compile du C, et `cc-rs` s'arrête sur `failed to find tool "lib.exe"`. Il faudrait installer sur chaque PR Rust le toolchain croisé que `release.yml` n'installe qu'au tag (`cargo-xwin` compilé depuis les sources, `clang`, `lld`), sur un runner à capacité 1 : un chantier, pas une étape bon marché. À rouvrir si le besoin d'une preuve Windows revient pour d'autres raisons.
- **Garde sur la propriété** (« au plus 3 `windows-sys` dans le graphe Windows »). Il encode le dommage mesuré plutôt qu'un numéro, survit au passage à 0.62 et couvrirait un nouveau crate à borne large. Mais il reste indirect à la lecture, tolère un échange à compte constant, et demande `cargo tree` : le lock ne dit pas quelle arête est compilée pour quelle cible. Écarté au profit de la cohérence avec le motif existant.
- **Garde sur une liste de crates déclencheurs** (`hyper`, la couche TLS). La liste est précisément ce que #348 a trouvé faux.
- **Commentaire seul dans `Cargo.toml`.** `Cargo.toml` n'est pas ce qu'on lit avant de lancer `cargo update`, et rien ne l'exécute.
## Conséquences
### Positives
- La dérive ne passe plus en silence : une mise à jour qui déplace une arête rougit `check-rust` sur la PR même, avec le nom de chaque arête déplacée, au lieu d'attendre qu'un relecteur reconnaisse sept noms de crates dans un diff que cargo n'a pas annoncé.
- La contrainte est domiciliée : qui voit l'étape rougir trouve ici pourquoi, et quoi faire.
- Le motif est celui que le dépôt connaît déjà (ADR 0018), pièges compris.
### Négatives
- **Friction** : chaque mise à jour qui déplace ces arêtes demande une édition à la main du lock.
- **La liste des sept est tenue à la main.** Un crate à borne large qui entrerait dans le graphe ne serait pas gardé, et s'il pointait une version déjà présente, la règle 3 ne le verrait pas non plus. À l'inverse, retirer de `CRATES` un autre nom que le canari passe, avec une vérification de moins dans le bilan (9 → 8) : seule la relecture du diff, sous le commentaire qui renvoie ici, l'attrape. La commande suivante liste les exigences sur `windows-sys` qui ne sont pas un `^0.x` ; le 2026-10-10, elle sort exactement les sept crates de `CRATES` :
```
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked \
| python3 -c 'import json, sys; [print(p["name"], p["version"], d["req"]) for p in json.load(sys.stdin)["packages"] for d in p["dependencies"] if d["name"] == "windows-sys"]' \
| grep -v ' \^0\.'
```
- La règle 3 compte toutes les cibles : 0.45.0, 0.48.0 et 0.52.0 ne sont pas compilées pour `x86_64-pc-windows-msvc` aujourd'hui, et un changement qui ne touche qu'elles la fera rougir quand même. Assumé : le lock ne dit pas ce qui est compilé pour quelle cible, et une fausse alerte se lève en une vérification `cargo tree`.
- Le garde tourne sur les PR qui touchent `src-tauri/**`, `.cargo/**` ou le workflow, et le lundi sur `main`, pas en continu. Une exception : le commit de `/release` modifie `Cargo.lock` directement sur `main`, sans PR, et le garde ne le verrait qu'au lundi suivant, après le build du tag. Le skill `/release` le rejoue donc en local juste après avoir édité le lock, avant de tagger (étape 2).
### Neutre
- Le garde lit le lock, il ne prouve pas que le graphe Windows compile. Cette preuve reste le run `release.yml`.
- Rien ne change pour l'application : #345 lit `Cargo.lock` sans le modifier, et ne déplace aucune dépendance.
## Liens
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify windows-sys pinning »
- [ADR 0018](0018-suppression-advisories-non-atteignables.md) — le garde-fou des suppressions, dont celui-ci reprend le motif
- Issues #345 (cette décision), #348 (mesure `rustls`), #310 (première dérive) ; PR #344 (strophe `h2` appliquée à la main), PR #321 (arêtes remontées à `0.61.2`)

View file

@ -0,0 +1,87 @@
# ADR 0024 — Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium
- Status: **Proposed** (écrit avant le code, à passer Accepted au merge de la milestone `spec-mcp-server`)
- Date: 2026-10-12
- Issues: milestone `spec-mcp-server` (à créer depuis `spec-plan-mcp-server.md`), revue `/review-spec` du 2026-10-12
- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md) (gating par édition, `FEATURE_TIERS` côté Rust), l'[ADR 0021](0021-migrations-profils-secondaires.md) (`prepare_at`, rattrapage des profils secondaires) et l'[ADR 0022](0022-canal-evenements-donnees.md) (canal d'événements de données), qu'il amende
## Contexte
Simpl'Résultat garde toutes les données financières en local et n'envoie rien à un serveur tiers. Les agents IA (Claude Code, Claude Desktop, Hermes, Cursor) parlent un protocole commun, le Model Context Protocol (MCP), pour appeler des outils exposés par un programme local. Aujourd'hui rien ne relie l'app à ces agents : pour demander « où déborde mon budget », l'utilisateur exporte un CSV et le colle.
Trois faits du code structurent la décision :
- **La logique métier vit dans la webview**, en TypeScript, via `tauri-plugin-sql` (`src/services/*`). Côté Rust, seul `rusqlite` ouvre les bases, pour les migrations des profils secondaires et le rendement Modified Dietz.
- **Le PIN ne protège que l'interface** : les bases SQLite sont en clair sous `app_config_dir()`.
- **L'édition se résout en Rust** depuis `license.key` (et le cache de compte), avec révocation et fail-closed.
Et un fait du protocole : en MCP, ce n'est pas l'app qui transmet des données, c'est l'hôte. Chaque résultat d'outil part chez le fournisseur du modèle de l'agent. Le principe privacy-first de l'app reste vrai au sens strict, mais l'utilisateur doit l'activer en connaissance de cause.
## Décision
**Un mode `--mcp` de l'exécutable existant sert un serveur MCP sur stdio avec `rmcp` (SDK Rust officiel, spec 2026-07-28), ouvre en direct la base SQLite d'un profil que l'utilisateur a explicitement autorisé dans l'app, et expose des outils de lecture, deux outils d'écriture et deux prompts. Réservé à l'édition Premium.**
### 1. Un binaire autonome, pas un serveur hébergé par l'app
`simpl_result_lib::entry(args)` choisit `mcp::run_stdio()` si `--mcp` est présent, sinon `run()`. Aucun `tauri::Builder`, aucun plugin single-instance ni deep-link dans ce mode. Le serveur marche app fermée, et rien n'est à empaqueter : pas de sidecar `externalBin`, pas de changement dans `release.yml`.
Le coût accepté : le SQL des outils est réécrit en Rust. Les services TypeScript restent la référence, et des **tests de parité** exécutent le texte SQL de référence (`compareDeltaSql` pour le résumé mensuel, `getExpensesByCategory` pour la répartition) sur une fixture commune et le comparent aux outils. Chaque constante SQL Rust porte un commentaire `// ref: src/services/<fichier>.ts:<ligne>`.
### 2. stdio seul
Aucun port, aucune authentification à inventer, pas de rebinding DNS depuis une page web (CVE-2025-66414 sur les serveurs MCP HTTP locaux ; les trois CVE connues de `rmcp` touchent ses transports HTTP, absents des features retenues). Tout client stdio local fonctionne avec la même commande ; Paramètres affiche celle de Claude Code et le JSON de Claude Desktop. Un hôte distant (Hermes sur le VPS) est hors scope : il demanderait Streamable HTTP, un jeton et un tunnel.
Stdout appartient au protocole : rien d'autre que `rmcp` n'y écrit, les journaux vont sur stderr. Un test lance le binaire et vérifie que le premier octet reçu ouvre un objet JSON.
### 3. Accès par profil, explicite, avec avertissement
Un drapeau `agent_access` dans `profiles.json` (`#[serde(default)]`, absent = faux), posé dans l'app par un interrupteur sur la fiche du profil. Pour tout profil, avec PIN ou non ; la saisie du PIN précède l'activation quand le profil en a un. L'avertissement dit ce qui se passe : l'agent pourra lire toutes les transactions, catégories, budgets et notes du profil, en modifier la catégorisation, et ces données seront transmises au fournisseur du modèle de cet agent ; Simpl'Résultat n'envoie rien lui-même.
Le serveur ne voit que les profils autorisés (`list_profiles`), en sélectionne un par session (`select_profile`), et **relit le drapeau à chaque appel d'outil** : désactivé dans l'app, l'appel suivant répond `profile_not_allowed` et la session se ferme.
Le PIN n'est jamais demandé par le serveur ni transmis à l'hôte.
### 4. Premium, vérifié par le même résolveur que l'app
`("mcp", &[EDITION_PREMIUM])` dans `FEATURE_TIERS`. La chaîne de résolution (dev-override → compte → clé, révocation, fail-closed) est extraite en `license_commands::entitlements_in(data_dir)`, appelée par l'app et par le serveur : un seul résolveur. Le serveur démarre et répond à `initialize` en Free ou Base, mais chaque outil répond `license_required` ; l'édition est relue à chaque appel, donc une clé saisie dans l'app prend effet sans redémarrer l'hôte. Le fichier `license.check` n'est écrit que par l'app : une révocation en ligne n'est détectée qu'au prochain démarrage de l'app.
En mode `--mcp`, le keyring est lu sans jamais créer d'entrée, toute erreur vaut « pas de compte vérifié », et l'appel est borné par un délai : un trousseau verrouillé donne `license_required`, jamais un blocage de `initialize`.
Le binaire release ignore toute variable d'environnement : les dossiers de test (`SR_MCP_DATA_DIR`, `SR_MCP_CONFIG_DIR`) ne sont lus que sous la feature `dev-override`, comme `SR_DEV_EDITION` (CWE-489).
### 5. Lecture et écritures ciblées
Lecture : profils, résumé mensuel (par type de catégorie, comme les rapports : revenus et transferts en somme signée, dépenses en valeur absolue, résultat avant et après transferts), répartition hiérarchique par catégorie, transactions filtrées et paginées, non-catégorisées, budget vs réel, catégories, règles de mots-clés. Écriture : `set_transaction_category` (feuille active et saisissable seulement, `is_manually_categorized = 1`, drapeaux en 1/0) et `create_keyword_rule` (mêmes règles que l'app : 2 à 64 caractères, priorité 100, réactivation d'une règle existante vers la même catégorie, refus vers une autre ; application aux transactions existantes par `LIKE ? ESCAPE` puis frontière de mot, en dry-run tant que `apply_to_existing` est faux). Rien d'autre : ni suppression, ni import, ni budget, ni catégorie nouvelle.
Les libellés, notes et noms de catégorie sont des données importées, jamais des consignes, et les `instructions` du serveur le disent : un libellé piégé ne peut provoquer qu'une catégorisation, réversible dans l'app.
Les refus sont des résultats d'outil (`isError: true`) avec un code stable et un libellé fixe, sans chemin ni message SQLite (le détail va sur stderr, passé par `scrub`), pour que le modèle les lise et se corrige. Les erreurs JSON-RPC sont réservées aux fautes de protocole.
### 6. Bases en WAL, cohérence avec l'app ouverte
Les bases passent en `journal_mode = WAL`, posé par l'app seule (`prepare_at` et après chaque `Database.load`) : lecteurs et écrivain ne se bloquent plus, deux écrivains se sérialisent par `busy_timeout`. Le serveur refuse une base qui n'y est pas (`profile_not_prepared`) ou dont les migrations ne sont pas au dernier niveau (`profile_not_migrated`) : il ne migre ni ne prépare jamais une base qu'il n'a pas créée. `VACUUM INTO` emporte les pages encore dans le WAL, sans checkpoint (vérifié).
L'app détecte les écritures externes au retour du focus : une connexion rusqlite sentinelle, ouverte paresseusement en `tauri::State`, lit `PRAGMA data_version`, qui est par connexion (le pool de `tauri-plugin-sql` ne convient pas). Si la valeur a changé, `notifyDataChanged()` ; la resynchronisation après une écriture interne se fait en un seul point, dans `notifyDataChanged`. Faux positif toléré, courte fenêtre de faux négatif acceptée.
**Amendement de l'ADR 0022** : §2, un nouvel émetteur, le contrôle au focus ; §5, de nouveaux abonnés, les hooks des pages que le serveur peut modifier (`useTransactions`, `useDashboard`, `useBudget`, liste des non-catégorisées), avec les règles existantes (attente de 150 ms, réponses périmées ignorées, désabonnement au démontage, un test par hook).
### 7. Ce que le serveur ne fait pas
Pas de transport HTTP, pas de resources MCP, pas d'outils Bilan, pas d'écriture hors catégorisation, pas de masquage des libellés, pas de PIN par élicitation, pas de chiffrement des bases. Chaque point est une phase 2 possible, listée dans `spec-decisions-mcp-server.md`.
## Conséquences
- **Positives** : un accès agent standard, utilisable par tout client stdio local ; un consentement explicite par profil ; une seule chaîne d'édition ; le WAL profite aussi à l'app (une lecture ne bloque plus une écriture) ; les hooks de page gagnent un rafraîchissement ciblé réutilisable au-delà du MCP.
- **Négatives** : du SQL en double, tenu par les tests de parité ; trois fichiers par base (`.db`, `-wal`, `-shm`), à dire dans le guide pour les sauvegardes à la main ; une base jamais ouverte par la version courante de l'app est refusée par le serveur ; le binaire Windows release est compilé sans console, et l'héritage des pipes de l'hôte se prouve en QA (repli : retirer `windows_subsystem` et masquer la console au démarrage GUI).
- **Privacy** : la politique de confidentialité (la-compagnie-maximus#184) doit dire que l'accès agent, une fois activé par l'utilisateur, fait transiter les données du profil par l'hôte MCP et son fournisseur de modèle.
## Alternatives écartées
- **Serveur hébergé par l'app en marche** (sidecar stdio relié par socket, pont Rust ↔ webview vers les services TS) : zéro duplication et `dataEvents` natif, mais l'app doit tourner, un binaire de plus dans `externalBin` et `release.yml`, et un pont à concevoir.
- **Hybride** (lecture directe app fermée, relais app ouverte) : deux chemins à tester ; réservé à une phase 2 si le binaire autonome montre ses limites.
- **Streamable HTTP** : un port ouvert sur les données financières, un jeton à gérer, le rebinding DNS à contrer ; aucun hôte local n'en a besoin.
- **PIN demandé par élicitation MCP** : le PIN transiterait par l'hôte, et tous les clients ne supportent pas l'élicitation.
- **Profils sans PIN seulement** : un utilisateur qui protège son profil perdrait la fonctionnalité.
- **Agrégats seulement par défaut** : catégoriser exige le libellé ; deux niveaux d'exposition à expliquer pour un gain incertain.
- **Remonter `refreshKey` au focus** : trois lignes, mais la page perdrait filtres et défilement à chaque retour.
- **`wal_checkpoint(TRUNCATE)` avant `VACUUM INTO`** : inutile (la copie lit la vue de la connexion, WAL compris) et nuisible (échoue en `SQLITE_BUSY` dès qu'un lecteur est ouvert).

View file

@ -26,26 +26,28 @@
```
simpl-resultat/
├── src/ # Frontend React/TypeScript
│ ├── components/ # 58 composants organisés par domaine
│ ├── components/ # 61 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants
│ │ ├── import/ # 14 composants (wizard d'import, dont FormatDriftPanel et RepairPathNotice)
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
│ │ ├── import/ # 18 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState, ImportFolderStructure, RootFilesWarning et SourceNameDisplay)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard)
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton)
│ │ └── transactions/ # 5 composants
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState)
│ │ └── transactions/ # 6 composants (dont SplitButton, #369)
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 14 services métier
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
│ ├── i18n/ # Config i18next + locales FR/EN
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
│ ├── App.tsx # Router principal
│ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust
@ -60,7 +62,9 @@ simpl-resultat/
│ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql
│ │ │ └── consolidated_schema.sql
│ │ │ ├── consolidated_schema.sql
│ │ │ ├── profile_db.rs # Rattrapage des profils secondaires (#390, ADR 0021)
│ │ │ └── fixtures/ # Schémas de création des profils publiés (tests)
│ │ ├── lib.rs # Point d'entrée, migrations, plugins
│ │ └── main.rs
│ ├── capabilities/ # Permissions Tauri
@ -71,6 +75,7 @@ simpl-resultat/
│ ├── audit.yml
│ └── release.yml
├── .github/workflows/ # Miroir GitHub (dormant)
├── scripts/ # build-latest-json.sh (latest.json de l'updater, appelé par release.yml, #320) et son test vitest
├── docs/ # Documentation technique
└── config/ # Configuration
```
@ -93,7 +98,7 @@ simpl-resultat/
| `budget_templates` | Modèles de budget réutilisables |
| `budget_template_entries` | Catégories et montants dans les modèles |
| `import_config_templates` | Modèles prédéfinis de config d'import |
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396), `onboarding_acknowledged_<clé>` (« 1 » par étape de visite cochée par « J'ai compris », #425), `onboarding_visit_steps_initialized` (« 1 » une fois les étapes de visite initialisées pour le profil, #425) — voir la section « Onboarding »… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
@ -137,6 +142,17 @@ Index Bilan (9) — 7 ajoutés en v9 :
- FK `balance_account_transfers.account_id` → `balance_accounts(id)` `ON DELETE CASCADE`
- FK `balance_account_transfers.transaction_id` → `transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
### Accès DB et transactions
`src/services/db.ts` fait passer **toute** opération sur la base par un verrou FIFO unique (`runExclusive`). tauri-plugin-sql charge SQLite dans un pool sqlx multi-connexions et n'expose aucune transaction côté JS : un `BEGIN` et son `COMMIT` envoyés par deux `execute` séparés pouvaient partir sur deux connexions et laisser une transaction d'écriture ouverte (le « database is locked » de la 0.10.0). Une lecture ou une écriture isolée prend le verrou le temps d'une instruction ; `withTransaction` le tient pour tout son rappel.
- **Verrou strict, non réentrant (#408).** Le rappel de `withTransaction` reçoit un **handle de transaction** (typé `Database`) qui exécute directement sur la connexion. Toute autre requête attend derrière la transaction, y compris un `getDb()` appelé depuis le rappel ou depuis une fonction qu'il appelle : celui-ci attend la transaction qui l'attend, un interblocage. Règle : dans un rappel, n'utiliser **que** le handle reçu et le passer aux fonctions appelées (par exemple `exec`, obligatoire sur `findOrCreateSecurity`). Avant #408, un drapeau global laissait passer hors verrou toute requête lancée pendant un rappel, d'où qu'elle vienne.
- **Handle révoqué.** À la fin du rappel (succès ou erreur), le handle est révoqué : un appel tardif depuis une promesse orpheline lève `TransactionHandleRevokedError` au lieu de passer hors verrou. `close()` sur le handle lève toujours `TransactionHandleCloseError`.
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule. Un `close()` de l'ancien pool qui échoue ne bloque pas la bascule : l'état est vidé et la génération incrémentée avant le `close()`, l'échec part au Journal (`logWarn`) et le profil suivant s'ouvre quand même. `closeDb()` seul se résout aussi dans ce cas, sans connexion, et tout ancien handle refuse de tourner.
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`. Son `close()` est vérifié de la même façon et lève `DbConnectionClosedError`, au lieu de fermer la connexion du profil courant. Dans le handle de transaction, la vérification est une défense, inatteignable aujourd'hui : rien ne ferme la connexion pendant qu'une transaction tient le verrou.
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. L'alerte cite un `getDb()` dans un rappel comme cause possible, pas comme diagnostic : une transaction longue (grosse restauration, migration de catégories sur un gros profil) dépasse aussi 15 s. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
- **Tests.** Seul `src/services/db.test.ts` voit le verrou (un `Database` factice qui journalise le début et la fin de chaque opération). Les tests de services mockent `db.ts` (`withTransaction: fn => fn(await getDb())`) et ne le voient pas.
## Système de migrations
Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plugin_sql::Migration` :
@ -163,16 +179,25 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
v17 est purement additive : elle n'ajoute **ni table ni index** (20 tables / 24 index inchangés). Le `CHECK` d'`amount_mode` admet `absolute_indicator` dès maintenant, pour que le troisième mode de montant puisse être livré sans nouvelle migration, alors que l'application le **refuse** encore explicitement (voir plus bas). `LIKE` plutôt que `json_extract` : la migration ne dépend d'aucune extension JSON1 dans le SQLite embarqué. `sign_convention` n'est délibérément pas backfillée — son `DEFAULT` restitue la valeur que le code codait en dur, la seule convention passée inférable.
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
La liste est rendue par `migrations()` (lib.rs) ; tauri-plugin-sql ne l'applique qu'à la base du **profil par défaut** (`simpl_resultat.db`, seule URL passée à `add_migrations`). Un test épingle le SHA-384 de chaque version.
## Services TypeScript (18)
Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profile_init_sql` : le fichier `consolidated_schema.sql`, qui porte le schéma complet, puis `history_sql`, qui inscrit toutes les versions dans `_sqlx_migrations` au format sqlx. Le plugin ne les migre jamais. Depuis #390, `prepare_profile_db` les rattrape à chaque ouverture, avant `Database.load` ([ADR 0021](adr/0021-migrations-profils-secondaires.md), `src-tauri/src/database/profile_db.rs`) :
- une base sans historique (créée avant #390) reçoit un **plancher** déduit de son schéma (`infer_floor`) : 6, 8, 10, 16 ou 17 selon la version qui l'a créée, tous rejoués en test depuis `database/fixtures/`. Aucune version au plus égale au plancher n'est rejouée ;
- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases ; elle est refaite à chaque rattrapage, une seule est gardée par profil (les plus anciennes sont supprimées après un rattrapage réussi), elle est nommée au Journal même quand le rattrapage s'arrête (`catch_up_backup`), et supprimée avec le profil (#395) ;
- chaque version s'applique dans sa transaction, clés étrangères actives comme sous sqlx. Une version qui bute sur une clé est reprise une fois sans elles, et ses lignes sans parent repassent à NULL si la clé déclare `ON DELETE SET NULL` ;
- la même commande répare dans tous les profils les drapeaux `is_manually_categorized` stockés en texte (`'true'` → 1).
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
## Services TypeScript (21)
| Service | Responsabilité |
|---------|---------------|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
| `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
| `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
| `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) |
| `importedFileService.ts` | Suivi des fichiers importés |
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
@ -183,8 +208,11 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden`, les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_<clé>` = `"1"`, lues en une requête et filtrées sur `VISIT_STEP_KEYS`, #425) et le drapeau `onboarding_visit_steps_initialized` ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `initializeVisitSteps(acknowledge)` initialise les étapes de visite une fois par profil, **sans transaction** (les deux reconnaissances si `acknowledge`, puis le drapeau, par écritures idempotentes) ; `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep` et `acknowledgeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en huit lignes — voir la section « Onboarding » |
| `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle du démarrage, qui rend `{ outcome, revoked }` (#365) |
| `confirmService.ts` | `confirmAction(message)` : seule voie de confirmation de l'app ([#421](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/421)), par `ask` de `@tauri-apps/plugin-dialog` (icône d'avertissement, boutons `common.confirm` / `common.cancel` dans la langue de l'app). Résout `true` sur la confirmation seulement ; un refus, la fermeture de la boîte ou un échec de l'appel (consigné par `logWarn`) résolvent `false`. Toujours attendu (`await`) : sur desktop, le plugin remplace `window.confirm` par une fonction asynchrone (une `Promise`, toujours vraie) et `window.alert` par une fonction qui ne bloque pas. `src/__integration__/native-dialog-guard.test.ts` refuse tout `confirm` / `alert` natif et toute confirmation non attendue. Seul service qui traduit, par l'instance i18next partagée |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
@ -199,20 +227,21 @@ Un seul service par convention projet (1 service par domaine, splitter seulement
Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
## Hooks (17+)
## Hooks (25+)
Chaque hook encapsule la logique d'état via `useReducer` :
| Hook | Domaine |
|------|---------|
| `useCategories` | Catégories avec hiérarchie |
| `useTransactions` | Transactions et filtrage |
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
| `useDataImport` | Import de données |
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md) |
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
| `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements |
| `useBudget` | Budget |
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
| `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prioritaires sur le snapshot d'un chargement parti avant elles (`ACKNOWLEDGE` garde le verrou de carte, contrairement à `SKIP`), rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. `createVisitStepsInitializer` lance `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` une fois par fournisseur (promesse partagée), après le premier chargement réussi où `budgetReady` est vrai et le drapeau absent (`needsVisitStepsInitialization`), puis relit (#425). Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total), les étapes passées et reconnues et `visitStepsInitialized` ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`, la fin attendant `budgetReady && visitStepsInitialized`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `acknowledge()` / `refresh()` |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
@ -227,7 +256,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition |
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
### Hook transverse — `useCollapsibleGroups`
@ -286,6 +315,83 @@ L'étape `file-preview`, déclarée depuis l'origine dans `ImportWizardStep` san
15 fichiers CSV **synthétiques** (aucune donnée réelle, aucun relevé réel n'entre dans ce dépôt — les signatures de banques sont écrites depuis des dispositions documentées). Ils couvrent des formes qu'un vrai relevé ne réunit jamais toutes : montant signé, débit/crédit, débit/crédit inversés, colonne inutilisée à `0,00`, préambule, en-tête contenant un nombre, en-tête à libellé numérique, sans en-tête, tout-positif, Desjardins quoté, montant absolu + indicateur, plus les quatre dispositions de banque. Posés en #326 **avant** la refonte, avec le comportement fautif figé et marqué `KNOWN DEFECT` + l'issue devant le corriger ; chaque maillon suivant a mis à jour l'attente et retiré le marqueur, sans jamais supprimer un test.
### Gabarits du dossier d'exemple — `src/__fixtures__/samples/`
Deux relevés **inventés** (`exemple-desjardins.csv` : compte chèque Desjardins ; `exemple-tangerine.csv` : carte de crédit Tangerine ; 41 lignes chacun), avec les jetons `{{M1}}` / `{{M2}}` que la commande Rust `create_sample_import_folders` remplace par les deux mois complets précédant le clic (`MM/YYYY`). Les fichiers sont embarqués dans le binaire par `include_str!` : le dépôt est la seule source, il n'y a rien à copier au build. Le module est **séparé** de `csv/index.ts` à dessein — ce corpus est figé à quinze cas par `csvAutoDetect.test.ts` et six boucles y passent chaque fixture ; y ajouter les gabarits élargirait le contrat figé en silence. `src/__integration__/onboarding-samples.test.ts` lit les **mêmes** fichiers et fige ce que l'assistant en fera : chaque fichier reconnu par la signature de la banque qu'il imite, toutes les lignes lues, aucune ligne en erreur, totaux signés attendus, et la part catégorisée par les 180 mots-clés du seed — en laissant exprès des lignes à classer à la main (dont « GARDERIE LES PETITS PAS »), pour que la cinquième case de la liste de démarrage ait quelque chose à cocher (#354).
## Onboarding — états vides et parcours de démarrage
Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`) : les états vides, la liste de démarrage et le dossier d'exemple. Phase 2 (#396-#401) : le parcours guidé, un bandeau en haut de chaque page et une carte d'étape sur la page de l'étape en cours (voir « Parcours guidé », plus bas). Aucune migration ni table : tout se lit dans les données existantes, et le parcours n'écrit que des préférences par profil.
**Les quatre états vides** rendent le composant partagé `EmptyState` (`src/components/shared/` : icône, titre, description, action primaire en `<Link>` ou `<button>`, action secondaire optionnelle, enfants libres). Leur signal est le **compte brut** de transactions du profil, jamais le résultat d'un filtre — un profil filtré jusqu'à zéro ligne garde sa page normale :
| Page | Signal | Composant | Action proposée |
|---|---|---|---|
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a huit lignes dans un ordre fixe (#425). Les six étapes de données se cochent par une **donnée**, jamais par un clic ; les deux étapes de visite par « J'ai compris » (et, pour Rapports, l'ouverture de la carte Tendances mise en évidence). La source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped, acknowledged, visitStepsInitialized })` (`utils/onboardingChecklist.ts`, pur) :
| Case | Source de vérité |
|---|---|
| Créer un profil | toujours cochée — la liste n'existe que dans un profil ouvert |
| Choisir un dossier d'import | préférence `import_folder` non vide |
| Configurer une source d'import | `COUNT(*) FROM import_sources` ≥ 1 |
| Importer un premier fichier | `COUNT(*) FROM imported_files f WHERE f.status = 'completed' AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)` ≥ 1 — un fichier qui n'a amené aucune transaction (toutes ses lignes en erreur, ou sa seule ligne d'en-tête) ne compte pas : `executeImport` écrit la ligne `completed` avant d'insérer |
| Parcourir les catégories et les mots-clés | étape de visite (#425) : préférence `onboarding_acknowledged_categories` = `"1"`, posée par « J'ai compris » sur sa carte ; verrouillée tant que `visitStepsInitialized` est faux, alors exclue du total |
| Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance |
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (7 au lieu de 8) et renvoyée vers `/settings/users` |
| Parcourir les rapports | étape de visite (#425) : préférence `onboarding_acknowledged_reports` = `"1"` ; même verrou que la ligne Catégories avant l'initialisation |
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Les deux lignes de visite sont verrouillées tant que le profil n'a pas initialisé ses étapes de visite : un profil au parcours fini ne passe pas un instant par « Étape suivante : Parcourir les catégories » ; la liste les montre alors « en attente », jamais « verrouillées » (le badge de l'édition Base ne les concerne pas), et son compteur attend `budgetReady && visitStepsInitialized`. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
### Parcours guidé
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `acknowledge(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
**Le canal de données.** `src/services/dataEvents.ts` ([ADR 0022](adr/0022-canal-evenements-donnees.md)) : chaque écriture qui peut changer un compteur du parcours appelle `notifyDataChanged()` une fois réussie (la liste des écritures et la règle d'ajout sont dans l'ADR), et le fournisseur, seul abonné, relit son état 150 ms après la dernière écriture d'une rafale (`followDataChanges`). C'est ce qui coche une étape pendant que sa page est ouverte.
**Les préférences** (`user_preferences`, par profil, donc détruites avec lui et sans effet sur les autres profils, [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Le fournisseur en est le seul écrivain, et aucune de ces écritures ne passe par le canal : il met son état à jour lui-même, une fois l'écriture faite.
| Clé | Valeur | Écrite par |
|---|---|---|
| `onboarding_checklist_hidden` | `"1"` quand le parcours est masqué ; un seul état pour le bandeau et la liste. Supprimée au réaffichage | `hideChecklist` (« Masquer », et « Fermer » à la fin du parcours) et la règle D9 ; `showChecklist` la supprime |
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des huit clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
| `onboarding_guided_initialized` | `"1"` une fois la règle D9 appliquée au profil | `initializeGuidedPath` |
| `onboarding_acknowledged_<clé>` | `"1"` par étape de visite faite (#425), `<clé>` étant l'une des deux clés de `VISIT_STEP_KEYS` (`categories`, `reports`). Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `acknowledgeStep` (« J'ai compris ») et `initializeVisitSteps` |
| `onboarding_visit_steps_initialized` | `"1"` une fois les étapes de visite initialisées pour le profil (#425) | `initializeVisitSteps` |
**La règle D9.** Au premier chargement, `createOnboardingLoader` attend `initializeGuidedPath`, une fois par fournisseur (promesse partagée contre le double montage de StrictMode). Le drapeau `onboarding_guided_initialized` est d'abord lu hors transaction : une fois posé, chaque ouverture suivante coûte cette lecture et n'ouvre aucune transaction. S'il manque, une transaction le relit, puis un profil qui a déjà des transactions reçoit `onboarding_checklist_hidden = "1"`, et le drapeau est posé. Après le `COMMIT`, et seulement si le parcours vient d'être masqué, une ligne est écrite au Journal. Un échec annule les deux écritures et le chargement continue sur l'état stocké ; le drapeau absent fait réessayer à l'ouverture suivante. Un profil vide ou neuf garde donc le parcours, et un profil déjà actif le trouve masqué, réaffichable depuis Paramètres → Utilisateurs.
**Le cochage d'office des étapes de visite** (#425). Une seule fois par profil, après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, le fournisseur appelle `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` (promesse partagée contre le double montage de StrictMode), puis relit. `shouldAcknowledgeVisitSteps` (pur) est vrai quand chaque ligne déverrouillée hors des étapes de visite est faite ou passée : un profil qui avait terminé le parcours les trouve faites et garde « Démarrage terminé », un profil au parcours incomplet les garde à faire. L'initialisation n'ouvre **aucune transaction** : sous le verrou strict de `db.ts` (#408), un appel de service lancé depuis le rappel d'une transaction attendrait derrière ce même verrou. Les écritures sont idempotentes, les reconnaissances d'abord, le drapeau en dernier : un arrêt entre deux refait le même calcul à l'ouverture suivante. Elle ne notifie pas le canal ([ADR 0022](adr/0022-canal-evenements-donnees.md), §5).
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
- `CARD_PAGES` (`/import`, `/categories`, `/transactions`, `/budget`, `/reports`, ces deux pages pour les étapes de visite, #432) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
- `latchCardPage` : le verrou de carte, décrit ci-dessous ;
- `bannerMode` : `none` avant le premier chargement réussi, quand le parcours est masqué et sur le tableau de bord d'un profil sans transaction (sa carte d'accueil porte déjà la liste) ; `card` sur la page verrouillée ; `complete` quand toutes les lignes déverrouillées sont faites ou passées, une fois l'entitlement Budget connu (`budgetReady`) ; `banner` partout ailleurs ;
- `stepNavState` et `readOnboardingNavState` : l'état de navigation de tout lien vers une étape (`{ onboarding: { focus: true } }`, plus `guided: true` pour « Classer une transaction à la main »), et la seule lecture admise de `location.state` (CWE-476) ;
- `showsStepCard`, `cardStep` (l'étape montrée et « Étape n sur N ») et `cardNext` (après « Fait. » : le lien vers l'étape suivante, la fin, ou rien tant que la relecture n'est pas arrivée) ;
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`), `categoriesCardModel` (les mots-clés, cible « Tous les mots-clés », jamais « Réinitialiser ») et `reportsCardModel(checklist, advancedLocked)` (le hub, cible la carte Tendances ; variante qui mentionne le cadenas quand les rapports avancés sont verrouillés, licence connue) ;
- `onboardingTargetAttributes` : les attributs du contrôle mis en évidence.
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady && visitStepsInitialized`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
**Le verrou de carte.** `latchCardPage(previous, { pathname, nextStep, hidden })` rend la page qui porte la carte, ou null. Il est posé quand la route courante est celle de l'étape suivante et figure dans `CARD_PAGES`. Il tient quand l'étape se coche : la carte reste pour annoncer « Fait. » et proposer la suite, même quand l'étape suivante vit sur une autre page. Il est relâché au changement de route, quand le parcours est masqué, et par « Passer » (`SKIP`) ; le rendu suivant le repose si la nouvelle étape suivante est sur la même page. La fonction est idempotente : le fournisseur la dérive pendant le rendu et range le résultat dans son reducer (`SET_CARD_PAGE`), ce qui évite l'image de bandeau qu'un effet laisserait passer sur une page à carte.
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, sur « Tous les mots-clés » de `CategoriesPage` et sur la carte Tendances du hub (`HubReportNavCard`), une seule cible à la fois. Sur le hub, la carte Tendances reçoit aussi `onNavigate`, appelé au clic avant la navigation, tant que la carte d'étape s'affiche et que l'étape n'est pas faite : il reconnaît l'étape des rapports (`acknowledge("reports")`, D17), car suivre l'anneau quitte la page et relâche le verrou de carte. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
**Paramètres → Utilisateurs** lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
## Gating par édition (licence)
Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de licence (`free` / `base` / `premium`) **côté interface uniquement** — un soft-paywall assumé (l'app est GPL ; le seul gate appliqué côté serveur reste la récupération de cours Premium via maximus-api). La matrice complète tier → modules, le rationale et les alternatives rejetées sont dans l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
@ -293,7 +399,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
| Brique | Fichier | Rôle |
|---|---|---|
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé résolue en `free` (invalide, expirée ou révoquée) ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status`. Porte aussi `revoked` (#365, qui remplace le drapeau de session de #347) : l'état vient de Rust (`info.revoked`), persiste à côté de la clé, et l'édition résolue est alors `free` ; le contexte ne décide rien. Il lance le contrôle de révocation une fois par démarrage : `createRevocationCheckStarter` est consommé par le premier état `ready` et ne lance le contrôle que si une clé est enregistrée à ce moment, `performRevocationCheck` écrit l'issue dans le Journal (`REVOCATION_LOG`) et ne recharge le contexte que si l'état a changé. `performSubmitKey` relit l'édition depuis Rust après l'enregistrement d'une clé, pour qu'une clé révoquée ressaisie reste en Gratuite. `licenseIdentity(info)` (courriel, édition, dates) sert aussi de clé React à la carte Licence, qui se remonte pour une autre clé et relit ainsi son activation |
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status`. Porte aussi `revoked` (#365, qui remplace le drapeau de session de #347) : l'état vient de Rust (`info.revoked`), persiste à côté de la clé, et l'édition résolue est alors `free` ; le contexte ne décide rien. Il lance le contrôle de révocation au démarrage et à la saisie d'une clé (#373), par un seul lanceur (`revocationCheckLauncher`), qui rend la main aussitôt : `createRevocationCheckStarter` est consommé par le premier état `ready` et ne lance le contrôle que si une clé est enregistrée à ce moment ; `performSubmitKey` le lance après `VALIDATE_DONE` pour la clé qu'il vient d'enregistrer, sans l'attendre, et jamais après un refus ; `performRevocationCheck` écrit l'issue dans le Journal (`REVOCATION_LOG`) et ne recharge le contexte que si l'état a changé. Une clé saisie avant le premier état `ready` reçoit les deux lancements : Rust ne laisse passer qu'un contrôle à la fois, et le second lit la tentative du premier. `performSubmitKey` relit aussi l'édition depuis Rust après l'enregistrement d'une clé, pour qu'une clé révoquée ressaisie reste en Gratuite. `licenseIdentity(info)` (courriel, édition, dates) sert aussi de clé React à la carte Licence, qui se remonte pour une autre clé et relit ainsi son activation |
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas. Quand la clé enregistrée est révoquée, une ligne (`upsell.revoked`) le dit au-dessus des CTA : le verrou apparaîtrait sinon sans raison (#365). La carte Mises à jour reprend la même ligne dans sa branche Gratuite |
@ -301,20 +407,23 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell, qui réutilise `UpsellGate`) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
| Gate de la Répartition | `src/shared/splitGate.ts`, `src/components/transactions/SplitButton.tsx` | `splitAccessFor(gate)` → `open` / `locked` / `pending` sur `useEntitlement("adjustments")`, lu par `TransactionsPage` et passé à `TransactionTable` en prop obligatoire (#369). `locked` : cadenas sur le bouton et `UpsellDialog` au lieu de la fenêtre de répartition, pour toute ligne ; `pending` (licence pas `ready`, chargement ou erreur) : ni cadenas ni fenêtre, bouton inactif, `allowed` ne court-circuitant pas `ready`. La fenêtre de répartition n'est rendue que hors `locked`, l'incitation que hors `open`. Non destructif : les répartitions existantes restent comptées et gardent leur icône orange |
| `UpsellDialog` | `src/components/shared/UpsellDialog.tsx` | `UpsellGate` dans une fenêtre (`role="dialog"`, `aria-modal`, nom accessible `upsell.title`), pour une action gatée dans une page Free ; `onNavigate` ferme la fenêtre après « J'ai déjà une clé ». Même balisage que la fenêtre d'incitation de `ProfileSwitcher`, pas encore migrée dessus |
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
## Commandes Tauri (36)
## Commandes Tauri (39)
### `fs_commands.rs` — Système de fichiers (6)
### `fs_commands.rs` — Système de fichiers (7)
- `scan_import_folder` — Scan récursif de dossier pour fichiers CSV/TXT
- `scan_import_folder` — Liste les sous-dossiers directs du dossier d'import (une source chacun) avec leurs fichiers CSV/TXT directs, et les CSV/TXT posés à la racine, ignorés (`root_files`)
- `read_file_content` — Lecture avec gestion de l'encodage
- `hash_file` — Hash SHA-256 (détection de doublons)
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
- `get_file_preview` — Aperçu des N premières lignes
- `pick_folder` — Dialogue de sélection de dossier
- `create_sample_import_folders` — Dossier d'exemple (#354). Contrat : reçoit le dossier d'import et la date du jour (`YYYY-MM-DD`) ; écrit « Exemple Desjardins » et « Exemple Tangerine », un relevé `releve-<AAAA-MM>.csv` chacun daté des deux mois complets précédents ; rend les deux **noms** créés, pas les chemins. Refuse si le dossier d'import n'existe pas ou si l'un des deux sous-dossiers existe déjà — les deux existences sont vérifiées **avant** la première écriture, donc rien n'est écrasé ni créé à moitié. Gabarits embarqués par `include_str!` depuis `src/__fixtures__/samples/`
### `export_import_commands.rs` — Export/Import de données (5)
@ -324,36 +433,40 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
- `read_import_file` — Lecture fichier chiffré
- `is_file_encrypted` — Vérification magic SREF
### `profile_commands.rs` — Gestion des profils (7)
### `profile_commands.rs` — Gestion des profils (8)
Les commandes qui ouvrent une base passent par `profile_db_path` : dossier du plugin (`app_config_dir()`) et nom de fichier validé (`simpl_resultat.db` ou `profile_<hex>.db`, #390).
- `load_profiles` — Chargement depuis `profiles.json`
- `save_profiles` — Sauvegarde de la configuration
- `delete_profile_db` — Suppression du fichier de base de données
- `get_new_profile_init_sql` — Récupération du schéma consolidé
- `delete_profile_db` — Suppression de la base d'un profil secondaire, de ses fichiers annexes SQLite (`-wal`, `-shm`, `-journal`) et de ses copies `migration-backups/` (#390)
- `get_new_profile_init_sql` — Schéma consolidé suivi de l'historique complet des migrations (`history_sql`, #390)
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`)
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
- `prepare_profile_db` — Avant chaque ouverture : rattrapage des migrations d'un profil secondaire (copie préalable) et réparation des drapeaux de classement manuel de tout profil. Rend un rapport sans chemin (`ProfilePreparation`). Synchrone, donc sérialisée sur le fil principal — [ADR 0021](adr/0021-migrations-profils-secondaires.md)
### `license_commands.rs` — Licence et activation machine (10)
### `license_commands.rs` — Licence et activation machine (11)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data
- `remove_license` — Retrait de la clé enregistrée (#370) : `license.key` d'abord, puis `activation.token` ; `license.check` est gardé. Avec `release_slot`, libère d'abord la place de cette machine (`/v1/licenses/deactivate`, identifiant du jeton) quand le jeton active ce poste et que la clé n'est pas connue comme révoquée. `no_active_activation`, `license_not_found` et `license_revoked` (enregistré pour la clé envoyée) valent place libre ; toute autre erreur de la libération est rendue sans rien supprimer. Idempotente sans clé
- `store_activation_token` — Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machine
- `read_license` — Lecture de la licence stockée, avec `revoked` : l'état de révocation enregistré pour cette clé (#365)
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365)
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270). Envoie l'identifiant machine **dérivé**, jamais celui du système (#350)
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès. Supprime le jeton local quand il représentait la place libérée
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust. Rend `{ machines, token_dropped }` depuis #367 : une liste complète qui ne nomme pas ce poste supprime le jeton d'activation de ce poste (`token_dropped: "not_listed"`, sinon `null`)
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
- `check_license_revocation` — Contrôle de révocation du démarrage (`POST /v1/licenses/verify`, #365). Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
- `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ».
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ». Le retrait de la clé (#370) vit dans `remove_license_in`, sans `AppHandle` : la libération préalable (`release_this_machine_slot`) passe par `deactivate_idempotent` puis `note_revocation`, et non par `deactivate_in`, qui supprimerait le jeton avant la clé. Toute suppression du jeton passe par `remove_activation_token_in`, qui tolère un jeton absent et rend `storage` sinon ; `store_license_in` et la désactivation l'emploient aussi. Les deux suppressions de #367, décidées par la réponse d'un serveur, passent par `remove_activation_token_if_unchanged_in`, qui ne supprime le fichier que s'il vaut encore le jeton dont parle la réponse : une activation arrivée pendant la requête écrit un jeton neuf, qui reste (CWE-367).
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03). La requête porte `product` et `license_key`, rien d'autre. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, mais n'écrit rien, parce que le serveur y juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (#367). Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03), tant qu'elle est enregistrée : `remove_license` (#370) la retire et garde `license.check`, si bien qu'une clé révoquée retirée puis ressaisie reste en Gratuite. La requête porte `product` et `license_key`, rien d'autre. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, et `fetch_price_in` supprime alors le jeton d'activation refusé, s'il n'a pas changé pendant la requête, mais n'écrit pas l'état de la clé : le serveur y juge la licence du jeton, que le client ne sait pas relier à la clé enregistrée, et ce jeton ne sert plus à rien quelle que soit la clé (#367). `fetch_price_in` lit le jeton dans le dossier de données (`read_stored_activation_token_in`, constante `ACTIVATION_FILE`) et se teste dans un dossier temporaire. Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. `drop_token_of_unlisted_machine_in` (#367), appelée par `list_machines_in`, supprime le jeton quand la liste de la clé enregistrée ne nomme ce poste sous aucune des deux formes, si l'identifiant du système est lisible, si la liste est complète (`machines_from_verify` compte les lignes écartées, que l'affichage tolère toujours), si le jeton active ce poste et s'il n'a pas changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Elle suppose que le serveur, s'il rehache un jour ses lignes brutes (maximus/maximus-api#82), produit exactement le dérivé ci-dessus, ce qu'un test épingle. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
@ -390,7 +503,7 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
### `balance_commands.rs` — Bilan (1)
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif, lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).
@ -404,10 +517,10 @@ Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ord
|--------|------|-----------|
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier ; confirmations par `ask` (`confirmService.ts`, paquet JS `@tauri-apps/plugin-dialog`, #421) | Sur desktop, son script d'initialisation remplace `window.confirm` par une fonction asynchrone et `window.alert` par une fonction non bloquante : jamais de `confirm` / `alert` natif (garde `native-dialog-guard.test.ts`) ; paquet JS à la version du crate |
| `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` ; compilé sans ses features par défaut (`rustls-tls` seule, #455) : aucune extraction d'archive sous Windows et Linux |
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
## OAuth2 et deep-link (Compte Maximus)
@ -440,9 +553,10 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
- **Timeout au démarrage** : `App.tsx` appelle `startProfileConnection` (`src/utils/startupConnection.ts`), qui applique un timeout de 10 secondes (`STARTUP_TIMEOUT_MS`) sur `connectActiveProfile()` et réessaie trois fois — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue ; une ouverture qui aboutit après le délai efface l'erreur et affiche l'application (#395, mesure dans l'ADR 0021)
- **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur
- **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB
- **Préparation du profil** : `prepare_profile_db` (Rust/rusqlite) rattrape les migrations d'un profil secondaire et répare les drapeaux texte avant le chargement ; un échec est écrit au Journal, le profil s'ouvre quand même (#390)
- **Log viewer** : `logService.ts` capture les `console.log/warn/error` dans un buffer circulaire (500 entrées, persisté en `sessionStorage`), affiché dans la page Paramètres via `LogViewerCard`
| Route | Page | Description |
@ -480,28 +594,82 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
- **Langue de fallback** : Anglais (`en`)
- **Fichiers** : `src/i18n/locales/fr.json`, `src/i18n/locales/en.json`
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code.
## Tests (vitest sans jsdom)
Le dépôt n'a pas de jsdom : les tests rendent les composants par `renderToStaticMarkup`, ou appellent hooks et composants comme des fonctions pour lire l'arbre rendu et les props. Un rendu statique ne lance aucun effet et aucun clic ; ce qu'un composant fait avec React entre deux rendus passe par un faux module `react`.
### Harnais de hooks — `src/test/hookHarness.ts` (#409)
Un seul harnais pour tous les tests qui simulent React. Motif d'import, unique, en tête du fichier de test :
```ts
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
import { hookHarness } from "../test/hookHarness";
const harness = hookHarness();
```
Puis `harness.clear()` dans `beforeEach` (un montage neuf), `harness.render(() => Composant(props))` pour un rendu du composant monté, `harness.commit()` pour jouer ses effets. Les valeurs que le test change entre deux rendus (route, valeur de contexte) vivent dans un objet `vi.hoisted` du test. `hookHarness.ts` n'importe React qu'en `import type` : un import de valeur, depuis la fabrique qui construit le faux `react`, chargerait ce faux.
Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
- emplacements par ordre d'appel, curseur remis à zéro à chaque `render()` ; un test peut prérégler l'état d'un `useState` ou d'un `useReducer` avant le premier rendu ;
- `useState` : initialiseur appelé une fois, setter stable, mises à jour fonctionnelles ; **aucun nouveau rendu automatique**, le test rend de nouveau quand il veut lire l'état ;
- `useReducer` : le reducer est appliqué, `dispatch` est stable, et chaque action est aussi enregistrée par l'espion `harness.dispatch` ;
- `useRef` : un objet par emplacement, gardé d'un rendu à l'autre ;
- `useMemo` / `useCallback` : recalculés seulement quand une dépendance change (`Object.is`) ;
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Dans les fichiers de test, le seul faux `t` est `markedT` (`src/test/i18nMock.ts`), servi par la fabrique `i18nMock` ou passé tel quel à une fonction qui prend `t` en paramètre (par exemple `renderCategoryLabel`) : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. La garde ci-dessous ne refuse que les formes qu'elle sait reconnaître : un faux `t` écrit autrement lui échapperait. Motif unique, en tête du fichier de test :
```ts
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
```
Options de la fabrique : `language` (« fr » par défaut ; une fonction est relue à chaque accès, pour un test qui change de langue entre deux rendus : `i18nMock({ language: () => language })`) et `fixture` (valeurs rendues telles quelles pour certaines clés, comme ce que déclare la locale, un objet sous `returnObjects` par exemple ; une valeur lue par la fabrique au chargement du module se déclare par `vi.hoisted`). Une fonction qui prend `t` en paramètre reçoit `markedT` lui-même.
Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée (`toContain(">t(dashboard.title)<")`, `toEqual(["t(onboarding.state.done)"])`) : elles rougissent sur une chaîne qui a sauté `t`. Une assertion par sous-chaîne sur une clé nue reste valide, la clé figurant dans la marque, mais ne prouve pas l'appel à `t`. Dans une expression régulière, échapper les parenthèses et crochets de la marque.
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
## CI/CD
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
### `check-rust.yml` — Vérifications Rust sur PR
### `check-rust.yml` — Vérifications Rust sur PR et chaque semaine
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même). Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`tar`, réellement présent) garantit que son silence prouve quelque chose. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI. Aucune étape de cache non plus : le conteneur de job n'atteint pas le serveur de cache du runner ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)), le restore et le save échouent tous les deux. Le cache reviendra via `Swatinem/rust-cache` une fois #234 réglé.
Juste après, une seconde étape **bloquante**, `Verify windows-sys pinning` ([#345](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/345)), lit `src-tauri/Cargo.lock` avec `awk`. Les sept crates qui déclarent `windows-sys` par une borne couvrant plusieurs mineures (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) doivent tous pointer `0.61.2`, l'état de chaque release Windows depuis v0.14.0, et l'ensemble des versions de `windows-sys` du lock ne doit pas changer. Une mise à jour comme `cargo update -p hyper` re-résout ces arêtes sans que cargo l'annonce, et le build Windows compile alors cinq versions de `windows-sys` au lieu de trois. Chaque vérification est loggée avec un bilan, un canari (`tempfile`) doit être lu et vérifié, et une panne d'`awk` fait échouer l'étape. Quand elle rougit, on remet les arêtes à la main : procédure et condition de sortie dans l'[ADR 0023](adr/0023-windows-sys-pinning.md).
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI.
**Cache** ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)) : `Swatinem/rust-cache` garde `~/.cargo` et les dépendances compilées de `src-tauri/target`, sous une clé faite de la version de rustc, des variables `CARGO*` et `RUST*` du job, des dépendances déclarées dans les manifestes et le lockfile Cargo (la version de l'app n'en fait pas partie), et du job. Il passe par le proxy de cache du runner, sur le port 28100 qu'ufw n'ouvre qu'aux réseaux Docker des jobs (procédure et retour arrière dans `docs/forgejo-ops.md` de la-compagnie-maximus). Le runner (v12.7.0) garde ce qu'écrit un run de PR pour cette seule PR : ses pushes suivants le relisent, les autres PR non. Le run du lundi et `workflow_dispatch` sont donc les seuls à écrire le cache partagé, sur lequel une PR se rabat quand rien ne correspond dans sa propre portée. Le runner supprime une entrée inutilisée depuis 7 jours ou créée il y a plus de 30 jours, et ne garde qu'une entrée par clé, toutes portées confondues. Un run à froid est donc normal dans les cas suivants ; seul un délai dépassé signale un retour de #234 :
- après une nouvelle version stable de Rust ou une modification de l'`env:` du job, jusqu'au lundi suivant ;
- quand une PR sauvegarde sous la clé de l'entrée partagée : elle l'évince pour toutes les PR jusqu'au lundi suivant. Ça arrive quand sa propre portée contient une entrée plus ancienne pour un autre `Cargo.lock`, servie avant l'entrée partagée, par exemple une PR qui a monté une dépendance puis est revenue au lockfile de `main`. Deux PR ouvertes sur un même lockfile, différent de celui de `main`, s'évincent l'une l'autre ;
- quand une entrée qui n'a eu que des correspondances exactes, donc jamais re-sauvegardée, atteint 30 jours ;
- un lundi après une semaine sans requête au cache, la restauration peut échouer en 404 : la recherche ne rafraîchit pas l'entrée, et le ménage qu'elle déclenche peut la supprimer ([forgejo/runner#1741](https://code.forgejo.org/forgejo/runner/issues/1741)). Le run repart alors à froid et re-sauvegarde.
### `check-frontend.yml` — Vérifications frontend sur PR
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown (`paths-ignore`). Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Le cache npm est retiré pour la même raison que côté Rust.
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` (`paths-ignore`). `.claude/**` et `release.yml` le déclenchent ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)) : `scripts/build-latest-json.test.mjs` lit la liste de clés de `verify-release.sh` et exécute le texte de l'étape « Publish latest.json to package registry ». Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. Conséquence à connaître : `npm audit` remonte **2 high en permanence**, les deux clés d'une même advisory `react-router` ([GHSA-qwww-vcr4-c8h2](https://github.com/advisories/GHSA-qwww-vcr4-c8h2), mode React Server Components). Elle est acceptée : l'app est un client de bureau sans serveur — `App.tsx` monte un `BrowserRouter` client-only — et `react-router-dom` étant figé en 7.18.1, sortir de la plage vulnérable demanderait une migration vers react-router v8, pas un bump. Déclencheur de re-évaluation : [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. `npm audit` est à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), après un rafraîchissement du lock dans les bornes de `package.json`. Ce 0 est un constat daté, pas un état garanti : aucun job ne le fait échouer, mais l'étape `npm ci` de `check-frontend` audite à chaque run et en imprime le compte (`found 0 vulnerabilities`, ou le nombre trouvé), et le Défenseur relève les advisories de son côté (#358 en vient). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
### `audit.yml` — Audit RustSec quotidien
`schedule` quotidien (06:00 UTC) + `workflow_dispatch`. `check-rust.yml` ne tournant que sur les PR qui touchent le Rust — environ 1 PR sur 40 — ce workflow garantit qu'un avis de sécurité publié sur une dépendance inchangée ne passe pas inaperçu. Échec bloquant : c'est le canal de notification.
`schedule` quotidien (06:00 UTC) + `workflow_dispatch`. `check-rust.yml` ne tournant que sur les PR qui touchent le Rust et une fois par semaine, avec un `cargo audit` non bloquant, ce workflow garantit qu'un avis de sécurité publié sur une dépendance inchangée ne passe pas inaperçu. Échec bloquant : c'est le canal de notification.
**Un run vert ne signifie pas « zéro advisory »** mais « zéro advisory hors de la liste acceptée ». Cette liste vit dans `.cargo/audit.toml`, à la racine du dépôt, avec pour chaque entrée sa preuve de non-atteignabilité et sa condition de retrait ; la politique qui l'encadre est l'[ADR 0018](adr/0018-suppression-advisories-non-atteignables.md). Au moment de sa mise en place, le `schedule` n'avait encore jamais déclenché ce workflow ([#314](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/314)) — un `workflow_dispatch` vert prouve que la commande sort en 0, pas que l'alarme quotidienne fonctionne.
@ -511,14 +679,17 @@ Les deux workflows `check-*` doivent être verts avant tout merge. Ils évitent
### `release.yml` — Build et publication
Déclenché par les tags `v*`. Deux jobs :
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm`
Déclenché par les tags `v*`. Un seul job, `build-and-release`, dans un conteneur `ubuntu:22.04` :
1. Linux → `.deb` + `.rpm`
2. Windows, compilé depuis Linux par `cargo-xwin` → installeur `.exe` (NSIS)
Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
- JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux) et `windows-x86_64` (l'installeur NSIS). Pas de clé générique `linux-x86_64` depuis le 2026-10-11 : elle servait le `.deb` à des installations qui ne savaient pas l'installer (anciennes AppImage, binaire hors paquet), qui reçoivent désormais `TargetsNotFound` à la vérification (raisons et lecteurs dans `docs/qa-update-cycle.md`). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`.
- JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les trois clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Il est appelé avec `-s` et n'accepte qu'un seul document, si bien qu'un fichier vide ou à deux documents est refusé sous jq 1.6 comme sous 1.7 ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)). Le test du script applique ce même filtre à chaque PR, compare sa liste de clés à celles du script et de `verify-release.sh`, et exécute le texte de l'étape « Publish » avec un `curl` factice (`DELETE` puis `PUT` sur un manifeste valide, aucun appel sur un manifeste refusé), si bien qu'un écart se voit avant le tag.
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur)
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).
## Architecture Decision Records (ADRs)
@ -546,3 +717,7 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
| [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted |
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted |
| [0022](adr/0022-canal-evenements-donnees.md) | Canal d'événements de données : les écritures signalent, le parcours de démarrage relit | 2026-10-08 | Accepted |
| [0023](adr/0023-windows-sys-pinning.md) | Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite | 2026-10-10 | Accepted |
| [0024](adr/0024-serveur-mcp.md) | Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium | 2026-10-12 | Proposed |

View file

@ -16,16 +16,34 @@ Simpl'Résultat vous aide à suivre vos finances personnelles en important des r
- Profils multiples avec bases de données séparées et NIP optionnel
- Mode sombre avec palette gris chaud
- Export et import de données avec chiffrement AES-256 optionnel
- Parcours de démarrage guidé : un bandeau en haut de chaque page et, sur la page de l'étape en cours, une carte qui dit quoi faire
### Démarrage rapide
1. Au premier lancement, choisissez ou créez un profil — chaque profil a sa propre base de données
2. Allez dans Paramètres et définissez votre dossier d'import — créez un sous-dossier par compte bancaire
3. Placez vos relevés bancaires CSV dans le sous-dossier correspondant
4. Ouvrez la page Import et configurez votre source (mapping des colonnes, délimiteur, format de date)
5. Importez vos transactions, puis allez dans Catégories pour configurer les règles de mots-clés
6. Utilisez l'auto-catégorisation sur la page Transactions pour appliquer vos règles en masse
7. Configurez votre Budget et suivez votre progression via les Rapports
Les huit étapes ci-dessous sont celles du parcours de démarrage. La carte d'accueil du tableau de bord d'un profil neuf les liste, puis le bandeau en haut de chaque page mène de l'une à la suivante (voir « Le parcours de démarrage », plus bas) ; chaque étape se coche d'elle-même quand elle est faite.
1. Créer un profil — chaque profil a sa propre base de données
2. Choisir un dossier d'import — le dossier où vous déposerez vos relevés, un sous-dossier par compte bancaire
3. Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier
4. Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé
5. Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer
6. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
7. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
8. Parcourir les rapports — la période, les faits saillants et les cartes de rapports
### Le parcours de démarrage
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste
2. « Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
3. Sur les pages Import, Catégories, Transactions, Budget et Rapports, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte. Sur les pages Catégories et Rapports, la carte se termine par « J'ai compris », qui coche l'étape ; sur la page Rapports, ouvrir la carte Tendances entourée la coche aussi
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire
6. « Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page
7. Quand toutes les étapes sont faites ou passées, le bandeau, ou la carte de la dernière étape, annonce « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. » « Ouvrir le guide » mène à cette section Premiers pas, et « Fermer » masque le parcours
8. « Masquer », dans le bandeau ou dans la liste, masque le bandeau et la liste pour ce profil seulement. Pour les retrouver, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages. Ce bouton est visible dès que le parcours est masqué
9. Un profil qui contenait déjà des transactions avant l'arrivée du parcours guidé le voit masqué d'office à sa première ouverture, comme après « Masquer » ; Paramètres → Utilisateurs le réaffiche. Un profil vide ou neuf le garde
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. Les deux étapes de visite, « Parcourir les catégories et les mots-clés » et « Parcourir les rapports », se cochent par « J'ai compris » sur leur carte ; un profil qui avait terminé le parcours avant leur arrivée les trouve déjà faites. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 7/7.
### Astuces
@ -60,6 +78,9 @@ Gérez plusieurs profils indépendants, chacun avec sa propre base de données.
- Un profil par défaut est créé automatiquement au premier lancement
- Le NIP est demandé à chaque fois que vous accédez à un profil protégé
- La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). Une seule copie est gardée par profil, la plus récente : elle est refaite à chaque mise à jour de la base et supprimée avec le profil
- Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (`~/.config/com.simpl.resultat/profile_….db`) ; vous pouvez supprimer ce fichier à la main
- Le parcours de démarrage est propre à chaque profil : ses étapes se cochent sur les données du profil, et le masquer dans un profil ne le masque pas dans les autres
---
@ -75,15 +96,19 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
- Histogramme empilé des dépenses par catégorie et par mois
- Sélecteur de période ajustable (par défaut : année à ce jour)
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
- Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
### Comment faire
1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total
2. Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage)
3. « Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche
4. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
5. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
6. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
7. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
8. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
9. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
### Astuces
@ -91,6 +116,7 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
- Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée
- Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer
- Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs
- Les cases de la liste de démarrage se cochent sur vos données, pas sur un clic : « Classer une transaction à la main » attend une transaction dont vous avez changé la catégorie vous-même, la catégorisation automatique de l'import ne la coche pas
---
@ -113,14 +139,80 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
### Comment faire
1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page
2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV
2. Créez un sous-dossier par compte et placez-y ses fichiers CSV : la carte de crédit et le compte chèques d'une même banque font deux sources, et les fichiers posés directement dans le dossier parent sont ignorés — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite
3. Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat
4. Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain
5. Sélectionnez les fichiers à importer
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants ; une dépense doit compter parmi les sorties, en négatif
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de ce compte se liront correctement d'eux-mêmes
8. Vérifiez les doublons, examinez le résumé, puis confirmez l'import
### Exporter un CSV depuis votre banque
1. Créez un sous-dossier par compte dans votre dossier d'import (un pour le compte chèque, un pour la carte de crédit, et ainsi de suite)
2. Sur le site de votre banque, choisissez la plage de dates la plus large offerte
3. Gardez le fichier tel quel : ne l'ouvrez pas et ne le réenregistrez pas dans un tableur, qui modifierait les dates et les montants
4. Relancez l'export chaque mois et déposez le nouveau fichier dans le même sous-dossier
Les banques ci-dessous ont une procédure détaillée. La ligne de statut de chacune indique si elle a été confirmée sur un compte réel.
#### Desjardins
*Procédure reconstituée depuis des guides tiers, à confirmer sur un compte réel*
1. Connectez-vous à AccèsD sur le site de Desjardins (pas l'application mobile)
2. Ouvrez Comptes et choisissez le folio voulu
3. Ouvrez l'onglet Relevés (ou Transactions / Historique selon la version)
4. Choisissez la période
5. Cliquez sur Télécharger ou Exporter, puis choisissez le format CSV
L'export ne couvre que l'activité récente du folio. Pour une carte de crédit Desjardins, le CSV n'est pas garanti : certaines cartes n'offrent que le PDF.
#### Tangerine
*Étapes confirmées par la FAQ officielle de Tangerine*
1. Connectez-vous sur tangerine.ca (pas l'application mobile)
2. Ouvrez Transactions
3. Cliquez sur le bouton « Télécharger les transactions » (« Download Transactions » en anglais)
4. Choisissez le compte et la plage de dates
5. Choisissez le format CSV, puis cliquez sur Télécharger
L'historique disponible peut être limité à quelques mois : relancez l'export régulièrement.
#### RBC
*Procédure reconstituée depuis des guides tiers, à confirmer sur un compte réel*
1. Connectez-vous à Banque en direct RBC
2. Ouvrez Mes comptes et choisissez le compte
3. Cliquez sur le bouton Télécharger, au-dessus du tableau des opérations
4. Choisissez le format CSV, puis cliquez sur Continuer
5. Enregistrez le fichier dans le sous-dossier du compte
Les cartes de crédit n'offrent qu'environ trois mois d'historique.
#### Banque Nationale (BNC)
*Aucune procédure publique trouvée pour les particuliers*
1. L'application attend un fichier dont l'en-tête est `Date;Description;Catégorie;Débit;Crédit;Solde`
2. Si votre export BNC a une autre forme, envoyez-nous **uniquement la ligne d'en-tête et une ou deux lignes aux valeurs fictives** via Paramètres → Système → Envoyer un feedback
**Ne collez jamais un numéro de compte, un solde ni une transaction réelle** : la rétroaction est un message texte envoyé à un serveur, c'est la seule exception au fonctionnement local de l'application.
#### BMO
*Procédure reconstituée d'après une description générale, à confirmer sur un compte réel ; format de fichier non encore reconnu*
1. Connectez-vous aux Services bancaires en ligne BMO sur bmo.com (le site, pas l'application)
2. Dans le sommaire, cliquez sur le compte voulu (chèques, épargne ou Mastercard)
3. Au-dessus de la liste des transactions, cliquez sur « Télécharger » (ou « Exporter »)
4. Choisissez le format CSV — les autres options visent QuickBooks, Quicken, Simply Accounting ou Microsoft Money
5. Choisissez la période, puis téléchargez le fichier
L'historique téléchargeable est limité à **123 jours** au maximum, et à environ **trois mois plus les transactions non facturées** pour une Mastercard : exportez chaque mois. Les prêts, hypothèques et marges de crédit ne s'exportent pas (relevés PDF seulement, conservés 7 ans). Les colonnes diffèrent entre les comptes de dépôt et la carte de crédit. BMO n'a pas encore de format reconnu par l'application : la détection générique lit la plupart des exports à montants signés ; si l'aperçu signale des entrées et des sorties inversées, utilisez « Inverser les signes ». Si le fichier n'est pas lu, envoyez-nous **uniquement la ligne d'en-tête et une ou deux lignes aux valeurs fictives** via Paramètres → Système → Envoyer un feedback. **Ne collez jamais un numéro de compte, un solde ni une transaction réelle** : la rétroaction est un message texte envoyé à un serveur, c'est la seule exception au fonctionnement local de l'application.
### Astuces
- Le récapitulatif de l'aperçu est le seul contrôle qui voit le **sens** des montants : un score de 100 % signifie que chaque ligne a été lue, pas que vos dépenses ne sont pas comptées comme des revenus. Prenez deux secondes pour le lire.
@ -132,6 +224,8 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
- Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons
- Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions
- Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer
- Si des fichiers CSV sont posés directement dans le dossier d'import, un avertissement les nomme : déplacez-les dans un sous-dossier par compte
- Le nom d'une source est celui de son sous-dossier
---
@ -147,7 +241,7 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
- Assignation de catégorie en ligne via menu déroulant
- Auto-catégorisation basée sur les règles de mots-clés
- Ajout de mots-clés directement depuis une transaction
- Répartition (split) d'une transaction sur plusieurs catégories
- Répartition (split) d'une transaction sur plusieurs catégories (édition Base et plus)
- Notes sur les transactions
### Comment faire
@ -156,15 +250,15 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
2. Cliquez sur un en-tête de colonne pour trier par ordre croissant ou décroissant
3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie
4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser
5. Pour ajouter une règle de mot-clé depuis une transaction, cliquez sur l'icône + et entrez le mot-clé
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie
5. Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie (édition Base et plus ; en édition Gratuite, le bouton porte un cadenas et ouvre l'écran de déverrouillage)
### Astuces
- Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date
- L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles
- Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles
- Les transactions réparties affichent un indicateur visuel et le détail de la répartition
- Les transactions réparties affichent un indicateur visuel, quelle que soit l'édition ; le détail de la répartition s'ouvre par le bouton Répartition (édition Base et plus)
---
@ -185,7 +279,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
### Comment faire
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel (tapez quelques lettres pour filtrer la liste des parents)
2. Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés
3. Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
@ -246,14 +340,14 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
1. Utilisez le navigateur d'année pour sélectionner l'année du budget
2. Cliquez sur une cellule de mois pour entrer un montant prévu
3. Appuyez sur Entrée pour sauvegarder, Échap pour annuler, ou Tab pour passer au mois suivant
4. Utilisez le bouton de répartition (sur la colonne Annuel) pour distribuer également sur tous les mois
4. Saisissez un montant dans la colonne Annuel pour le répartir également sur les 12 mois ; il remplace les montants mensuels de la ligne
5. Sauvegardez votre budget comme modèle pour le réutiliser les années suivantes
### Astuces
- La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas
- La colonne Annuel additionne automatiquement les 12 mois
- Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup
- Les catégories parentes affichent les sous-totaux agrégés de leurs enfants
- Seules les catégories du dernier niveau acceptent un montant ; les catégories parentes affichent les sous-totaux de leurs enfants
- Replier une catégorie est **purement visuel** : les sous-totaux et les totaux annuels sont toujours calculés sur toutes les lignes, jamais seulement sur les lignes visibles — aucun montant saisi n'est perdu quand vous repliez
---
@ -264,7 +358,7 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
### Le hub
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, quatre cartes mènent aux quatre sous-rapports dédiés.
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, cinq cartes mènent aux cinq sous-rapports dédiés.
Le sélecteur de période en haut à droite est **partagé** entre toutes les pages via l'URL : `?from=YYYY-MM-DD&to=YYYY-MM-DD`. Copiez l'URL pour revenir plus tard au même état ou la partager.
@ -347,7 +441,7 @@ Trois modes accessibles via un tab bar :
Les trois modes ci-dessus, comme le tableau **Par catégorie** des Tendances, présentent les catégories en arbre repliable **à chaque niveau**. Chaque catégorie parente — y compris les niveaux intermédiaires — porte son propre chevron : le déplier révèle ses enfants directs (eux-mêmes repliés), de sorte que vous descendez d'un niveau à la fois. Le tableau s'ouvre **entièrement replié** (seules les catégories de premier niveau visibles) ; le bouton **« Tout déplier / Tout replier »** ouvre ou ferme tous les niveaux d'un coup. Replier une catégorie est **purement visuel** — les sous-totaux, totaux et lignes de résultat restent calculés sur l'ensemble des lignes, jamais sur les seules lignes visibles. Vos choix de repli sont **mémorisés par profil** (dans la base du profil, ils disparaissent donc avec lui).
### Rapport Zoom catégorie (`/reports/category`)
### Rapport Analyse par catégorie (`/reports/category`)
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :
@ -357,10 +451,10 @@ Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclu
### Édition contextuelle des mots-clés
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
**Clic droit** sur n'importe quelle transaction (dans l'analyse par catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
2. Un sélecteur de catégorie cible
2. Un sélecteur de catégorie cible, où taper quelques lettres filtre la liste
3. Un bouton **Appliquer et recatégoriser**
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.
@ -369,7 +463,7 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage)
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives)
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
- L'analyse par catégorie est **protégée contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
---
@ -493,6 +587,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
- Import des données depuis un fichier exporté précédemment
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
- Réaffichage du parcours de démarrage (Paramètres → Utilisateurs) quand il a été masqué
### Comment faire
@ -503,6 +598,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
5. Utilisez la section Gestion des données pour exporter ou importer vos données
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
8. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page
### Astuces
@ -511,7 +607,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Exportez régulièrement pour garder une sauvegarde de vos données
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
- L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Au démarrage, une seule requête part d'elle-même : la vérification de la licence, au plus une fois par 24 heures et seulement si une clé est enregistrée. Aucune télémétrie
- L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Une seule requête part d'elle-même, au démarrage et à la saisie d'une clé : la vérification de la licence, au plus une fois par 24 heures pour une même clé et seulement si une clé est enregistrée. Aucune télémétrie
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
---
@ -526,13 +622,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
|---|:---:|:---:|:---:|
| Tableau de bord | ✓ | ✓ | ✓ |
| Import CSV | ✓ | ✓ | ✓ |
| Transactions | ✓ | ✓ | ✓ |
| Transactions (sans la Répartition) | ✓ | ✓ | ✓ |
| Catégories | ✓ | ✓ | ✓ |
| Rapports — hub et Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré | ✓ | ✓ | ✓ |
| Journal des modifications | ✓ | ✓ | ✓ |
| Profils | 1 profil | ✓ multiples | ✓ multiples |
| Ajustements | — | ✓ | ✓ |
| Répartition des transactions | — | ✓ | ✓ |
| Budget | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ |
@ -540,14 +637,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
### Comment ça se présente
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan) et sur les tuiles de rapports avancés du hub Rapports. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan), sur les tuiles de rapports avancés du hub Rapports et sur le bouton Répartition de la page Transactions. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
- **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
### Comment faire
1. Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition
1. Repérez le cadenas dans la barre latérale, sur les tuiles du hub Rapports ou sur le bouton Répartition de la page Transactions : il marque ce qui est au-dessus de votre édition
2. Cliquez sur un module verrouillé pour voir l'édition requise
3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
@ -559,15 +656,17 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
- **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance »
- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible ou n'a pas pu être vérifiée (le plus souvent parce qu'elle a été copiée en partie ou coupée par un retour à la ligne ; une clé complète qui reste refusée n'a pas été émise par le serveur de licences), elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours. L'application retire aussi d'elle-même une activation qui ne sert plus, et la carte affiche alors « Non activée » : quand la récupération d'un cours est refusée parce que l'activation de cette machine appartient à une licence révoquée (par exemple celle d'une clé remboursée, restée à côté de votre clé actuelle depuis une version antérieure), et quand la liste des machines ne compte pas cette machine (désactivée depuis un autre ordinateur, par exemple). Chacun de ces retraits est inscrit dans les journaux (Paramètres → Système)
- **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition
- **Vérification de la licence** — quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage, au plus une fois par 24 heures, en envoyant le produit et la clé, rien d'autre. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Seul l'enregistrement d'une autre clé arrête l'envoi de la précédente
- **Vérification de la licence** — quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Pour arrêter cette vérification, retirez la clé (« Retirer la clé », ci-dessous) ; l'enregistrement d'une autre clé arrête aussi l'envoi de la précédente
- **Retirer la clé** — « Retirer la clé » retire votre clé de cet ordinateur, après une confirmation qui dit ce que cela change. Une clé valide : les modules de votre édition se verrouillent et l'application repasse en édition Gratuite ; vos données sont conservées et réapparaissent dès qu'une clé valide est entrée. Une clé révoquée : l'application cesse de l'envoyer au serveur de licences. Si cette machine est activée, sa place est d'abord libérée sur le serveur de licences ; si le serveur ne répond pas (hors ligne, par exemple), l'application demande s'il faut retirer la clé quand même, et la place reste alors occupée jusqu'à ce que vous la libériez depuis la liste des machines, sur un autre ordinateur où la clé est enregistrée. Chaque retrait est inscrit dans les journaux. Une clé révoquée retirée puis entrée de nouveau reste révoquée
### Astuces
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide ou révoquée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
- En édition Gratuite, les **répartitions déjà faites restent dans vos données** : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste des transactions — seuls l'ouverture et la modification d'une répartition sont verrouillées
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite

550
docs/qa-onboarding.html Normal file
View file

@ -0,0 +1,550 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QA — Parcours d'accueil</title>
<!--
Checklist de parcours de l'accueil : phase 1 (issues #351 à #357) et parcours
guidé de la phase 2 (issues #396 à #401). Page statique, français seulement,
aucune dépendance réseau : tout est inline. L'état (cases + commentaires) vit
dans le localStorage du navigateur qui ouvre le fichier, sous une clé propre à
cette version de la liste ; « Copier le rapport » produit le texte brut à
coller dans Paramètres → Système → Envoyer un feedback.
La liste de la phase 1 telle que publiée avec la v0.17.0 reste lisible par
`git show v0.17.0:docs/qa-onboarding.html`.
-->
<style>
:root {
--bg: #faf9f7;
--card: #ffffff;
--fg: #1f1d1a;
--muted: #6b665f;
--border: #e3dfd9;
--accent: #b5542e;
--accent-fg: #ffffff;
--done-bg: #f0f5ec;
--focus: #2f6fb3;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1a18;
--card: #262320;
--fg: #ece8e2;
--muted: #a39d94;
--border: #3a3631;
--accent: #d9774c;
--accent-fg: #1c1a18;
--done-bg: #262d22;
--focus: #7fb0e8;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: 24px 16px 64px;
}
main { max-width: 860px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 4px; }
h2 { font-size: 1.05rem; margin: 28px 0 8px; }
p { margin: 0 0 10px; }
.lead { color: var(--muted); }
.meta {
display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center;
margin: 16px 0 8px; color: var(--muted); font-size: 0.9rem;
}
.meta label { display: inline-flex; gap: 6px; align-items: center; }
.meta input[type="text"] {
width: 11rem;
}
.progress {
font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg);
}
.prereq {
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 16px; margin: 12px 0 20px;
}
.prereq ul { margin: 6px 0 0; padding-left: 20px; }
.intro { color: var(--muted); font-size: 0.92rem; }
ol.steps { list-style: none; margin: 0; padding: 0; counter-reset: step; }
ol.steps li {
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; margin-bottom: 10px;
display: grid; grid-template-columns: auto 1fr; gap: 6px 12px;
}
ol.steps li.done { background: var(--done-bg); }
ol.steps li .check { grid-row: 1 / span 3; padding-top: 2px; }
ol.steps li input[type="checkbox"] { width: 20px; height: 20px; margin: 0; cursor: pointer; }
ol.steps li .title { font-weight: 600; cursor: pointer; }
ol.steps li .title::before {
counter-increment: step; content: counter(step) ". ";
color: var(--muted); font-weight: 500;
}
ol.steps li .expect { color: var(--muted); font-size: 0.92rem; }
ol.steps li .expect code {
font: 0.88em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 0 4px;
}
ol.steps li .where { display: grid; gap: 4px; }
ol.steps li .where label { font-size: 0.85rem; color: var(--muted); }
input[type="text"], textarea {
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px;
}
input:focus-visible, textarea:focus-visible, button:focus-visible {
outline: 2px solid var(--focus); outline-offset: 1px;
}
.actions {
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
margin-top: 20px; position: sticky; bottom: 0; padding: 12px 0;
background: var(--bg);
}
button {
font: inherit; font-weight: 600; cursor: pointer; border-radius: 8px;
padding: 8px 14px; border: 1px solid var(--border);
background: var(--card); color: var(--fg);
}
button.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
button:hover { filter: brightness(0.97); }
.status { color: var(--muted); font-size: 0.9rem; min-height: 1.4em; }
.fallback { margin-top: 12px; }
.fallback[hidden] { display: none; }
.fallback textarea { min-height: 14rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem; }
.note { font-size: 0.85rem; color: var(--muted); margin-top: 16px; }
@media (max-width: 520px) {
body { padding: 16px 16px 72px; }
.meta input[type="text"] { width: 100%; }
}
</style>
</head>
<body>
<main>
<h1>QA — Parcours d'accueil</h1>
<p class="lead">Première ouverture de Simpl'Résultat jusqu'à la fin du parcours de démarrage guidé. Coche chaque étape quand elle se passe comme décrit, et note où tu as hésité, même une seconde : c'est l'hésitation qu'on cherche, pas l'échec.</p>
<div class="meta">
<label>Testeur <input type="text" id="tester" placeholder="prénom" autocomplete="off"></label>
<label>Version <input type="text" id="version" placeholder="ex. 0.18.0" autocomplete="off"></label>
<label>Système <input type="text" id="os" placeholder="Windows 11, Ubuntu 24.04…" autocomplete="off"></label>
<span class="progress" id="progress"></span>
</div>
<section class="prereq">
<strong>Avant de commencer</strong>
<ul>
<li>Une version publiée ou un build de release : les cas du parcours guidé vérifient le rendu réel du webview, WebKitGTK sous Linux et WebView2 sous Windows.</li>
<li>Une installation neuve, ou un profil neuf : la liste de démarrage n'apparaît que dans un profil sans transaction.</li>
<li>Pour l'étape Budget, une clé Base ou Premium ; sans clé, l'étape se présente verrouillée « Base et plus » et le total passe à 5 — note-le dans le champ, c'est attendu.</li>
<li>Pour le cas de l'édition Gratuite, une session utilisateur ou une machine sans clé : ne retire pas la clé de ta machine habituelle, puisque retirer une clé efface aussi le jeton d'activation.</li>
<li>Pour la règle D9, un profil qui a des transactions, créé avec la v0.17.0 ou avant, et qu'aucune version du parcours guidé n'a encore ouvert (un build de développement de la pile compte).</li>
<li>Les cas d'accessibilité et de rendu se font sous Linux avec Orca, puis sous Windows avec NVDA.</li>
<li>La page garde tes réponses dans ce navigateur. Rien n'est envoyé : c'est toi qui colles le rapport dans l'application.</li>
<li>Pour vérifier la v0.17.0 elle-même, prends la liste publiée avec elle : <code>git show v0.17.0:docs/qa-onboarding.html</code>.</li>
</ul>
</section>
<div id="sections"></div>
<div class="actions">
<button type="button" class="primary" id="copy">Copier le rapport</button>
<button type="button" id="reset">Réinitialiser</button>
<span class="status" id="status" role="status" aria-live="polite"></span>
</div>
<div class="fallback" id="fallback" hidden>
<p>Le presse-papiers n'est pas accessible depuis ce fichier : sélectionne le texte ci-dessous (il est déjà sélectionné) et copie-le avec Ctrl+C, puis colle-le dans Paramètres → Système → Envoyer un feedback.</p>
<textarea id="report" readonly aria-label="Rapport à copier"></textarea>
</div>
<p class="note">Les noms entre guillemets sont ceux des boutons tels qu'ils apparaissent en français. « Exemple Desjardins » et « Exemple Tangerine » sont des relevés inventés : aucune donnée réelle.</p>
</main>
<script>
(function () {
"use strict";
// v2: the guided path (#396 to #401) changed the list; the answers of the
// phase 1 list stay under their own key.
var STORAGE_KEY = "simpl-resultat.qa-onboarding.v2";
// One group per part of the path, one row per case. `expect` is what the
// tester should see. Ids are unique across the groups.
var SECTIONS = [
{
id: "phase-1",
title: "Phase 1 : premier lancement, états vides et dossier d'exemple",
intro: "Les cas de la phase 1 (#351 à #357) qui restent valides avec le parcours guidé. Ils peuvent se faire dans la même passe que la section suivante, sur le même profil neuf.",
steps: [
{ id: "install", title: "Installer et lancer l'application",
expect: "L'installateur s'exécute sans avertissement bloquant et l'application s'ouvre sur la sélection de profil." },
{ id: "profile", title: "Créer un profil",
expect: "Le profil s'ouvre directement après sa création (NIP facultatif)." },
{ id: "dashboard-empty", title: "Tableau de bord vide avec la liste de démarrage à 1 / 8",
expect: "Une carte d'accueil remplace les graphiques, la liste montre huit lignes, seule « Créer un profil » est cochée, le compteur indique 1 / 8 (ou 1 / 7 sans clé)." },
{ id: "cta-folder", title: "Cliquer « Choisir un dossier d'import »",
expect: "Le bouton de la carte d'accueil mène à la page Import." },
{ id: "import-no-folder", title: "Page Import sans dossier",
expect: "Un état vide « Aucun dossier d'import » avec le bouton « Choisir un dossier » et le lien « Comment exporter un CSV depuis ma banque ? »." },
{ id: "pick-folder", title: "Cliquer « Choisir un dossier » et prendre un dossier vide",
expect: "La fenêtre du système s'ouvre ; une fois le dossier choisi, l'état vide devient « Aucune source trouvée » et nomme le dossier." },
{ id: "create-sample", title: "Cliquer « Créer un dossier d'exemple »",
expect: "Un message annonce les deux dossiers créés et invite à cliquer sur l'un d'eux. Dans l'explorateur de fichiers, les deux sous-dossiers contiennent un fichier releve-AAAA-MM.csv." },
{ id: "two-sources", title: "Deux sources apparaissent",
expect: "« Exemple Desjardins » et « Exemple Tangerine » sont listées comme sources, sans actualiser à la main." },
{ id: "import-desjardins", title: "Importer « Exemple Desjardins » : aperçu, signes, doublons, rapport",
expect: "Le format est détecté (bandeau Desjardins), l'aperçu montre des sorties et des entrées dans le bon sens (pas besoin d'« Inverser les signes »), aucun doublon, puis le rapport d'import indique le nombre de transactions importées." },
{ id: "manual-category", title: "Transactions : classer « GARDERIE LES PETITS PAS » à la main",
expect: "La transaction est sans catégorie après l'import ; lui en donner une à la main la garde classée." },
{ id: "budget", title: "Budget (clé Base ou Premium) : saisir un montant",
expect: "La page Budget montre la grille (pas l'état vide, puisqu'il y a des transactions) ; un montant saisi dans une cellule est conservé." },
{ id: "hide-reshow", title: "« Masquer » sur un second profil, puis réaffichage depuis Paramètres",
expect: "Créer un second profil (clé Base ou plus) : sa carte d'accueil montre la liste à 1 / 8. « Masquer » retire la liste de ce profil seulement, ainsi que le bandeau de ses autres pages ; le premier profil n'est pas touché. Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui ramène le parcours." },
{ id: "guide-link", title: "Ouvrir le guide « Exporter un CSV depuis votre banque » depuis l'état vide d'Import",
expect: "Sur un profil sans dossier ou sans source, le lien de l'état vide ouvre le guide directement sur la sous-section, avec les procédures par banque." }
]
},
{
id: "parcours-guide",
title: "Parcours guidé : un profil neuf de bout en bout",
intro: "Phase 2 (#396 à #401), sur un profil neuf en édition Base ou Premium. De l'étape 2 à l'étape 8, avance seulement par les liens d'étape (ligne de la liste, « Étape suivante »), sans revenir au tableau de bord : chaque page doit dire quoi faire.",
steps: [
{ id: "guided-dashboard", title: "Tableau de bord vide sans bandeau, autres pages avec bandeau",
expect: "Sur le tableau de bord, aucun bandeau de démarrage au-dessus du titre : la carte d'accueil porte déjà la liste. Ouvre Catégories, puis Rapports : chacune commence par le bandeau « Démarrage 1/8 · Étape suivante : Choisir un dossier d'import → », avec « Voir la liste » et « Masquer »." },
{ id: "guided-import-card", title: "Étape 2 : la carte Import, son contour et le focus",
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 8 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
{ id: "guided-import-folder", title: "Étape 3 : dossier choisi, puis dossier d'exemple",
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 8 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Parcourir les catégories et les mots-clés → » ; après « Terminé », elle garde « Fait. »." },
{ id: "guided-categories", title: "Étape 5 : la carte Catégories, « Tous les mots-clés » et « J'ai compris » (#432)",
expect: "Clique « Étape suivante » sur la carte Import. La page Catégories s'ouvre sur la carte « Étape 5 sur 8 · Parcourir les catégories et les mots-clés », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « Tous les mots-clés » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction. » Le contour entoure « Tous les mots-clés », jamais « Réinitialiser ». Ouvrir « Tous les mots-clés » ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → », et le contour disparaît. En anglais, “All Keywords” est entre “ ”." },
{ id: "guided-transactions", title: "Étape 6 : Transactions, entrée guidée",
expect: "Clique « Étape suivante » sur la carte Catégories, après « J'ai compris » (étape 5, #432). La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 6 sur 8 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
{ id: "guided-budget", title: "Étape 7 : Budget, « Tout déplier » puis la cellule du mois",
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 7 sur 8 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. » et propose « Étape suivante : Parcourir les rapports → »." },
{ id: "guided-budget-tab", title: "Budget : Tab enregistre et passe au mois suivant",
expect: "Dans une cellule, saisis un montant puis Tab : le montant est enregistré et la saisie passe au mois suivant, comme le dit la carte." },
{ id: "guided-reports", title: "Étape 8 : la carte Rapports, la carte Tendances et « J'ai compris » (#432)",
expect: "Clique « Étape suivante » sur la carte Budget. La page Rapports s'ouvre sur la carte « Étape 8 sur 8 · Parcourir les rapports », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ». » Le contour entoure la carte Tendances, seule. Changer la période ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et le message de fin « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. », et le contour disparaît. En anglais, “Trends” est entre “ ”." },
{ id: "guided-live", title: "Les étapes se cochent page ouverte",
expect: "Pendant les étapes 2 à 8, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, chaque « J'ai compris », le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
{ id: "guided-end", title: "Fin du parcours : bandeau de fin, « Ouvrir le guide » et « Fermer »",
expect: "Après « J'ai compris » sur la carte Rapports (étape 8, #432), quitte la page : en haut de chaque page, tableau de bord compris, le bandeau dit « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. ». « Ouvrir le guide » ouvre Paramètres → Utilisateurs sur la section « Premiers pas », qui décrit le parcours (« Le parcours de démarrage »). « Fermer » retire le bandeau de toutes les pages, et Paramètres → Utilisateurs propose alors « Afficher la liste de démarrage »." }
]
},
{
id: "parcours-guide-liste",
title: "Parcours guidé : bandeau, liste, Passer et Reprendre",
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
steps: [
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
expect: "Sur une page sans carte (Paramètres, par exemple), « Voir la liste » déplie les huit étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
{ id: "guided-skip-resume", title: "« Passer cette étape », puis « Reprendre »",
expect: "Sur la carte Transactions ou Budget, clique « Passer cette étape » : la carte laisse la place au bandeau (ou au message de fin si c'était la dernière étape) et le compteur avance d'une étape. Sur la page Import, passer une étape fait passer la carte à l'étape suivante de la page. Dans la liste dépliée, l'étape passée montre l'icône « passer », le badge « Passée » et « Reprendre ». Depuis une autre page, « Reprendre » rouvre la page de l'étape avec sa carte, le focus sur son titre, et l'étape redevient à faire." },
{ id: "guided-hide-users", title: "« Masquer » depuis Paramètres → Utilisateurs",
expect: "Sur Paramètres → Utilisateurs, clique « Masquer » dans le bandeau : le bandeau disparaît et, sans quitter la page, la ligne « Parcours de démarrage » apparaît avec « Afficher la liste de démarrage ». Un clic la remplace par « Le parcours de démarrage réapparaît en haut des pages », et le bandeau revient en haut de la page." }
]
},
{
id: "parcours-guide-cas",
title: "Parcours guidé : cas particuliers et limites connues",
intro: "Chaque cas dit sur quel profil le faire. Les limites connues sont à constater et à juger : note si elles gênent.",
steps: [
{ id: "guided-d9", title: "Profil déjà actif masqué d'office, puis réaffiché (règle D9)",
expect: "Ouvre un profil qui avait des transactions avant cette version. Aucun bandeau ni carte, sur aucune page ; le Journal (Paramètres → Système) porte « Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again) ». Paramètres → Utilisateurs propose « Afficher la liste de démarrage » : un clic ramène le bandeau, avec un compteur fidèle aux données du profil. Rouvrir le profil ne le masque plus." },
{ id: "guided-free", title: "Édition Gratuite : 7 étapes, fin à 7/7",
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/7, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 6 sur 7 ») annonce « Fait. » puis « Étape suivante : Parcourir les rapports → » : aucune carte Budget ; après « J'ai compris » sur la carte Rapports, fin à 7/7." },
{ id: "guided-noop-import", title: "Un import qui n'ajoute rien",
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 8 » (« 4 sur 7 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
{ id: "guided-skip-folder", title: "Limite connue : « Passer » sur l'étape du dossier, sans dossier",
expect: "Sur un profil neuf, sur la carte Import « Étape 2 sur 8 · Choisir un dossier d'import », clique « Passer cette étape » sans choisir de dossier. La carte passe à « Étape 3 sur 8 · Configurer une source d'import », mais garde la phrase et le contour du choix du dossier ; un second « Passer » mène à l'étape 4, avec la même phrase, et le bandeau revient au troisième. À juger : les étapes de la page dépendent du dossier." },
{ id: "guided-empty-profile", title: "Étapes d'import passées : Transactions et Budget sans transaction",
expect: "Sur un profil neuf en édition Base ou plus, sur un build de production (le mode dev masque le cas Budget), choisis un dossier d'import vide, puis passe les étapes 3, 4 et 5 avec « Passer cette étape » (l'étape 5 sur la carte Catégories, #432). Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » au-dessus de l'état vide « Aucune transaction pour l'instant », que la barre de filtres et un bref chargement peuvent précéder (limite connue, à juger). Passe l'étape 6 depuis cette carte. Sur la page Budget, la carte « Étape 7 sur 8 · Saisir un premier budget » garde son titre et « Passer cette étape », sans phrase ni contour, au-dessus de l'état vide « Le budget se construit sur vos transactions » ; elle ne dit jamais « Cliquez « Tout déplier »… »." },
{ id: "guided-fallback", title: "Transactions : aucune transaction à classer",
expect: "Sur un profil de test où l'étape 6 reste à faire, rends toutes les transactions catégorisées sans les classer à la main : ajoute, depuis la page Catégories, un mot-clé pour chaque transaction restée sans catégorie, puis clique « Auto-catégoriser » sur la page Transactions. Depuis une autre page, suis « Étape suivante : Classer une transaction à la main → ». La page montre toutes les transactions, période « Tout », une seule fois, et la carte dit « Faites un clic droit sur une transaction, puis « Ajouter comme mot-clé »… ». « Aucune transaction trouvée. » peut s'afficher un très bref instant entre les deux lectures (limite connue)." },
{ id: "guided-same-page", title: "Limite connue : un lien d'étape suivi depuis la page Transactions",
expect: "Passe l'étape 6 depuis sa carte, change les filtres de la page Transactions, puis, sur cette même page, clique « Reprendre » dans la liste du bandeau. La carte revient, mais la page garde tes filtres au lieu de l'entrée guidée ; la phrase de la carte suit le filtre réellement affiché. À juger." },
{ id: "guided-budget-expanded", title: "Budget : grille déjà dépliée par le profil",
expect: "Sur un profil où l'étape 7 reste à faire, déplie la grille (« Tout déplier ») sans saisir de montant, puis quitte la page Budget. Reviens par le lien de l'étape 7 : la grille est restée dépliée et la carte entoure directement la cellule du mois courant." },
{ id: "guided-budget-offscreen", title: "Limite connue : cellule du mois hors de la zone visible",
expect: "En fenêtre étroite, la grille du budget défile horizontalement et la cellule du mois courant peut être hors de vue (les derniers mois de l'année sont à droite) : rien ne défile tout seul. À juger : la phrase et le contour suffisent-ils pour la trouver ?" },
{ id: "guided-budget-zero", title: "Budget : une cellule quittée vide enregistre 0 $",
expect: "Sur un profil où l'étape 7 reste à faire, clique une cellule puis quitte-la sans rien saisir : 0 $ est enregistré et la carte annonce « Fait. ». C'est le comportement existant de la grille ; à juger." },
{ id: "guided-import-flash", title: "Limite connue : « Aucun dossier configuré » à l'ouverture d'Import",
expect: "À l'ouverture de la page Import, la carte du dossier peut afficher un très bref instant « Aucun dossier configuré » avant la lecture du dossier ; l'état vide et la carte de démarrage, eux, attendent cette lecture. À juger." }
]
},
{
id: "retours-v0171",
title: "Retours de la v0.17.1 : textes et cartes du parcours",
intro: "Jalon planned-2026-10-12-retours-onboarding-v0171. Sur un profil neuf, en français, puis en anglais pour relire les mêmes phrases.",
steps: [
{ id: "v0171-import-structure", title: "Import : l'encadré « Structure attendue » (#422)",
expect: "Sans dossier choisi (« Aucun dossier d'import »), puis dans un dossier vide (« Aucune source trouvée »), l'état vide montre l'encadré « Structure attendue » au-dessus du lien « Comment exporter un CSV depuis ma banque ? » : « Dossier parent : choisissez celui-ci », dessous « Carte de crédit : une source » avec releve-2026-08.csv et releve-2026-09.csv, puis « Compte chèques : une autre source », et la note « Les fichiers posés directement dans le dossier parent sont ignorés. ». Les niveaux se lisent en retrait, dans les deux thèmes ; un lecteur d'écran lit une liste imbriquée, sans annoncer les icônes." },
{ id: "v0171-import-card-folder", title: "Import : cartes « Choisir un dossier d'import » et « Configurer une source d'import » (#422)",
expect: "Sans dossier, la carte dit : « Choisissez avec le bouton « Choisir un dossier » le dossier parent de vos relevés. Simpl'Résultat y lit un sous-dossier par compte : un pour la carte de crédit, un pour le compte chèques, et ainsi de suite. » Dans un dossier vide, elle nomme le dossier entre guillemets, demande un sous-dossier par compte et cite « Actualiser » et « Créer un dossier d'exemple » entre guillemets (« deux comptes fictifs »). Une fois les sources présentes : « Cliquez sur la carte d'un compte… ». Nulle part la carte ne parle d'un sous-dossier par banque." },
{ id: "v0171-import-card-preview", title: "Import : la carte de l'aperçu parle des signes (#422)",
expect: "À l'écran d'aperçu de l'assistant, la carte dit qu'une dépense doit compter parmi les sorties, en négatif, cite « Inverser les signes » comme correction, puis « Vérifier les doublons », chacun entre guillemets ; le contour reste sur « Vérifier les doublons ». Aux écrans suivants, « Confirmer » puis « Importer » sont aussi entre guillemets. En anglais, les mêmes libellés sont entre “ ”." },
{ id: "v0171-import-help", title: "Import : l'aide de la page et le guide (#422)",
expect: "L'aide de la page Import (icône ?) commence par « Choisissez le dossier parent de vos relevés… » et porte, après « Prévisualisez vos données… », la règle des signes avec « Inverser les signes ». Dans le guide d'utilisation (section Import), l'étape 2 dit « Créez un sous-dossier par compte… », l'étape 6 qu'une dépense doit compter parmi les sorties, et l'étape 7 parle des prochains fichiers « de ce compte »." },
{ id: "v0171-import-root-files-with-source", title: "Import : fichier posé à la racine, à côté d'une source (#427)",
expect: "Dans le dossier d'import, pose a.csv directement à la racine et crée un sous-dossier visa avec un relevé, puis « Actualiser » : la carte de la source visa s'affiche, et au-dessus d'elle un avertissement (icône triangle) dit « 1 fichier est posé directement dans « <nom du dossier> » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte. ». Le dossier est nommé par son dernier segment, jamais par son chemin." },
{ id: "v0171-import-root-files-no-source", title: "Import : trois fichiers à la racine, aucune source (#427)",
expect: "Dans un dossier sans sous-dossier mais avec trois CSV à la racine, « Actualiser » : l'état vide « Aucune source trouvée » s'affiche, et sous lui l'avertissement au pluriel nomme les trois fichiers, séparés par des virgules. Avec sept fichiers, il en nomme cinq puis « et 2 autres ». En anglais : “… sit directly in “<folder>” and are ignored: … and 2 more.”" },
{ id: "v0171-import-root-files-lifecycle", title: "Import : l'avertissement suit le dossier (#427)",
expect: "Déplace les fichiers de la racine dans un sous-dossier puis « Actualiser » : l'avertissement disparaît. Remets-en un, « Actualiser » : il revient. Ouvre une source puis « Annuler » (ou va au bout d'un import puis « Terminé ») : l'avertissement est toujours là. Dans Paramètres → Systèmes → Journal, aucune ligne ne contient le nom d'un de ces fichiers ni le chemin du dossier." },
{ id: "v0171-import-source-name", title: "Import : le nom de la source est celui de son sous-dossier (#426)",
expect: "Ouvre une source : la configuration affiche sous « Nom de la source » le nom du sous-dossier, en texte, sans champ de saisie, et dessous « Le nom de la source est celui de son sous-dossier. ». La carte de démarrage dit « Vérifiez les fichiers cochés, puis cliquez « Aperçu ». » et ne parle plus du nom. En anglais : “A source is named after its subfolder.” et “Check the selected files, then click “Preview”.”" },
{ id: "v0171-import-source-reopen", title: "Import : la source est retrouvée au prochain import (#426)",
expect: "Importe un fichier d'une source, puis « Terminé » et rouvre la même source : aucun bandeau de détection (le format enregistré est restauré tel quel), le fichier déjà importé porte le badge « Importé » et n'est pas coché. Sur une base créée avant ce correctif, une source déjà renommée garde ce défaut (hors périmètre)." },
{ id: "v0171-transactions-card-keyword", title: "Transactions : la carte propose « Ajouter comme mot-clé » (#430)",
expect: "Par le lien de l'étape 6, la carte dit : « Seules les transactions non catégorisées sont affichées. Faites un clic droit sur l'une d'elles, puis « Ajouter comme mot-clé » : le mot-clé classe cette transaction et les transactions semblables de l'aperçu, et servira aux prochains imports. Gardez un mot propre au commerçant, pas « PAIEMENT », et décochez les transactions déjà bien classées. Vous pouvez aussi cliquer sur sa catégorie et en choisir une. » Avec le filtre « Toutes », la phrase commence par « Faites un clic droit sur une transaction… » et garde la même consigne. Le contour reste sur la catégorie de la première ligne. En anglais, “Add as keyword” et “PAYMENT” sont entre “ ”." },
{ id: "v0171-transactions-keyword-checks", title: "Transactions : la boîte du mot-clé coche l'étape (#430)",
expect: "Sur un profil où l'étape 6 reste à faire, suis la carte : clic droit sur une transaction, « Ajouter comme mot-clé », remplace le mot prérempli par un mot propre au commerçant si besoin, choisis une catégorie, décoche une transaction de l'aperçu, puis « Appliquer et recatégoriser ». La carte annonce « Fait. » sans quitter la page ; la transaction décochée garde sa catégorie. La boîte elle-même n'a pas changé." },
{ id: "v0171-budget-card-leaves", title: "Budget : la carte nomme les feuilles et la colonne « Annuel » (#430)",
expect: "Grille repliée, la carte dit : « Cliquez « Tout déplier » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « Annuel » est réparti également sur les 12 mois et remplace les montants mensuels. » Après « Tout déplier », la phrase commence par « Seules les catégories du dernier niveau… » et garde la phrase sur « Annuel ». Vérifie dans la grille que l'affirmation tient : une ligne à chevron n'accepte pas de montant ; 120 $ dans « Annuel » donne 10 $ par mois et remplace les montants déjà saisis. En anglais, “Expand all” et “Annual” sont entre “ ”." },
{ id: "v0171-visit-eight-rows", title: "Liste de démarrage : huit lignes, deux étapes de visite (#425)",
expect: "Sur un profil neuf, la liste montre dans l'ordre : « Créer un profil », « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget », « Parcourir les rapports ». Le compteur dit 1 / 8 (1 / 7 sans clé, la ligne Budget verrouillée « Base et plus »). Après l'import, « Étape suivante » mène à « Parcourir les catégories et les mots-clés ». Ouvrir Catégories ou Rapports sans cliquer ne coche rien. En anglais : “Browse categories and keywords”, “Browse the reports”." },
{ id: "v0171-visit-got-it", title: "« J'ai compris » coche une étape de visite (#425, #432)",
expect: "Sur la carte d'une étape de visite (pages Catégories et Rapports), « J'ai compris » s'affiche avant « Passer cette étape ». Un clic coche l'étape : la carte reste, annonce « Fait. » et propose l'étape suivante (ou le message de fin après Rapports) ; dans la liste, la ligne est barrée, sans le badge « Passée ». « Passer cette étape » donne, lui, une ligne « Passée » avec « Reprendre ». En anglais : “Got it”." },
{ id: "v0171-reports-free-padlock", title: "Rapports : la carte mentionne le cadenas en édition Gratuite (#432)",
expect: "Sur une session ou une machine sans clé, arrive sur la carte Rapports par « Étape suivante » (« Étape 7 sur 7 »). La phrase finit par « Les cartes à cadenas demandent l'édition Base. », et les cartes Faits saillants, Comparables, Zoom catégorie et Cartes portent un cadenas ; la carte Tendances, entourée, n'en a pas. Avec une clé Base ou plus, la phrase s'arrête à « commencez par « Tendances ». ». En anglais : “Cards with a padlock require the Base edition.”" },
{ id: "v0171-reports-trends-click", title: "Rapports : ouvrir la carte Tendances entourée coche l'étape (#432)",
expect: "Sur un profil où l'étape 8 est la dernière à faire, arrive sur la carte Rapports par « Étape suivante », puis clique la carte Tendances entourée (pas « J'ai compris »). Le rapport Tendances s'ouvre, et le bandeau, en haut de cette page, annonce « Démarrage terminé. » ; dans la liste, « Parcourir les rapports » est barrée, sans le badge « Passée ». Sur un profil où une étape antérieure reste à faire, ouvre Rapports par le menu : le bandeau s'affiche, sans carte d'étape ni contour, et cliquer Tendances ne coche pas « Parcourir les rapports »." },
{ id: "v0171-visit-auto-done", title: "Profil au parcours fini : les étapes de visite sont faites d'office (#425)",
expect: "Ouvre, avec cette version, un profil dont les six anciennes étapes étaient faites ou passées et dont le parcours n'est pas masqué (il affichait « Démarrage terminé »). À aucun moment le bandeau ne passe par « Étape suivante : Parcourir les catégories et les mots-clés » : il garde « Démarrage terminé ». Dans la liste, les deux étapes de visite sont barrées. Le Journal (Paramètres → Système) porte « Onboarding: the path was already complete, visit steps marked done ». Rouvrir le profil ne change plus rien." },
{ id: "v0171-visit-incomplete", title: "Profil au parcours incomplet : les étapes de visite restent à faire (#425)",
expect: "Ouvre, avec cette version, un profil dont au moins une des six anciennes étapes restait à faire. Les deux étapes de visite sont à faire, et le restent une fois les six autres terminées : le compteur dit 6/8, et « Étape suivante » mène à la première étape de visite non faite." },
{ id: "v0171-visit-pending", title: "Limite connue : lignes de visite « en attente » avant l'initialisation (#425)",
expect: "À la toute première ouverture d'un profil avec cette version, le temps de lire la licence et d'initialiser les étapes de visite (un instant), la liste peut montrer les deux étapes de visite « en attente » (cercle pâle, lu « en attente » par un lecteur d'écran), jamais avec le badge « Base et plus », et sans compteur. Elles passent ensuite à faire ou faites. À juger si l'instant est visible." }
]
},
{
id: "parcours-guide-a11y",
title: "Parcours guidé : accessibilité et rendu",
intro: "Sous Linux (WebKitGTK, lecteur d'écran Orca), puis sous Windows (WebView2, lecteur d'écran NVDA). Indique le système dans le champ prévu.",
steps: [
{ id: "guided-render-linux", title: "Contour et pulsation sous Linux (WebKitGTK)",
expect: "Un contour de couleur d'accent, d'environ 3 px et décalé de 2 px, entoure le contrôle désigné sur les trois pages ; la pulsation joue trois fois en un peu moins de 4 secondes, puis le contour reste fixe. Note s'il est rogné par la grille ou par un conteneur." },
{ id: "guided-render-windows", title: "Contour et pulsation sous Windows (WebView2)",
expect: "Même attendu que sous Linux : contour de couleur d'accent d'environ 3 px, décalé de 2 px, trois pulsations, puis un contour fixe, jamais rogné." },
{ id: "guided-reduced-motion", title: "Mouvement réduit (prefers-reduced-motion)",
expect: "Active l'option du système qui réduit les animations (GNOME : réduire les animations, dans Accessibilité ; Windows : effets d'animation désactivés, dans Accessibilité → Effets visuels), puis relance l'application si rien ne change : le contour s'affiche, sans aucune pulsation." },
{ id: "guided-contrast", title: "Contraste du contour, thème clair et thème sombre",
expect: "Dans les deux thèmes (bouton de la barre latérale), le contour se distingue nettement du fond de la page, des cartes et du contour de focus du clavier, sur les pages Import, Transactions et Budget." },
{ id: "guided-keyboard", title: "Clavier seul",
expect: "Sans souris, de l'étape 2 à l'étape 8 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « J'ai compris », « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
{ id: "guided-focus-after-hide", title: "Limite connue : le focus après « Masquer » et « Fermer »",
expect: "Au clavier, active « Masquer » dans le bandeau, puis, sur un autre profil, « Fermer » au message de fin : le bouton disparaît avec le bandeau et le focus retombe sur la page elle-même, comme avec l'ancienne barre compacte ; note d'où Tab repart. Même chose après un lien du bandeau vers une page sans carte (« Ouvrir le guide », ligne Budget verrouillée). À juger, avec Orca puis NVDA : le lecteur d'écran perd-il le fil ?" },
{ id: "guided-orca", title: "Annonces d'Orca (Linux)",
expect: "Le bandeau et la carte s'annoncent comme la région « Parcours de démarrage » ; « Voir la liste » annonce son état replié ou déplié ; dans la liste, chaque étape est suivie de son état (« faite », « passée », « verrouillée », « en attente »), sauf une étape à faire ; à l'arrivée par « Étape suivante », le titre de la carte est lu ; « Fait. » est annoncé sans déplacer le focus ; la confirmation de Paramètres → Utilisateurs est annoncée." },
{ id: "guided-nvda", title: "Annonces de NVDA (Windows)",
expect: "Mêmes annonces que sous Orca : région « Parcours de démarrage », état replié ou déplié de « Voir la liste », état de chaque étape de la liste, titre de la carte à l'arrivée par « Étape suivante », « Fait. » sans déplacement du focus, confirmation de Paramètres → Utilisateurs." }
]
}
];
var ALL_STEPS = [];
SECTIONS.forEach(function (section) {
section.steps.forEach(function (step) { ALL_STEPS.push(step); });
});
var state = load();
function load() {
var fresh = { meta: { tester: "", version: "", os: "" }, steps: {} };
try {
var raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return fresh;
var parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return fresh;
return {
meta: Object.assign(fresh.meta, parsed.meta || {}),
steps: parsed.steps && typeof parsed.steps === "object" ? parsed.steps : {}
};
} catch (e) {
return fresh;
}
}
function save() {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
setStatus("Impossible d'enregistrer dans ce navigateur : le rapport reste copiable, mais l'état sera perdu au rechargement.");
}
}
function stepState(id) {
if (!state.steps[id]) state.steps[id] = { done: false, note: "" };
return state.steps[id];
}
function setStatus(text) {
document.getElementById("status").textContent = text;
}
function countDone() {
var done = 0;
ALL_STEPS.forEach(function (s) { if (stepState(s.id).done) done += 1; });
return done;
}
function updateProgress() {
document.getElementById("progress").textContent = countDone() + " / " + ALL_STEPS.length + " étapes cochées";
}
function renderStep(step, list) {
var st = stepState(step.id);
var li = document.createElement("li");
li.className = st.done ? "done" : "";
var check = document.createElement("div");
check.className = "check";
var box = document.createElement("input");
box.type = "checkbox";
box.id = "done-" + step.id;
box.checked = !!st.done;
box.addEventListener("change", function () {
st.done = box.checked;
li.className = st.done ? "done" : "";
save();
updateProgress();
});
check.appendChild(box);
var title = document.createElement("label");
title.className = "title";
title.htmlFor = box.id;
title.textContent = step.title;
var expect = document.createElement("div");
expect.className = "expect";
expect.textContent = step.expect;
var where = document.createElement("div");
where.className = "where";
var noteLabel = document.createElement("label");
noteLabel.htmlFor = "note-" + step.id;
noteLabel.textContent = "Où as-tu hésité ?";
var note = document.createElement("input");
note.type = "text";
note.id = noteLabel.htmlFor;
note.value = st.note || "";
note.autocomplete = "off";
note.addEventListener("input", function () {
st.note = note.value;
save();
});
where.appendChild(noteLabel);
where.appendChild(note);
li.appendChild(check);
li.appendChild(title);
li.appendChild(expect);
li.appendChild(where);
list.appendChild(li);
}
function render() {
var root = document.getElementById("sections");
root.textContent = "";
SECTIONS.forEach(function (section) {
var block = document.createElement("section");
block.id = section.id;
var heading = document.createElement("h2");
heading.textContent = section.title;
block.appendChild(heading);
var intro = document.createElement("p");
intro.className = "intro";
intro.textContent = section.intro;
block.appendChild(intro);
var list = document.createElement("ol");
list.className = "steps";
section.steps.forEach(function (step) { renderStep(step, list); });
block.appendChild(list);
root.appendChild(block);
});
["tester", "version", "os"].forEach(function (key) {
var input = document.getElementById(key);
input.value = state.meta[key] || "";
input.oninput = function () {
state.meta[key] = input.value;
save();
};
});
updateProgress();
}
function pad(n) { return n < 10 ? "0" + n : String(n); }
function buildReport() {
var now = new Date();
var date = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
" " + pad(now.getHours()) + ":" + pad(now.getMinutes());
var lines = [];
lines.push("QA parcours d'accueil — " + date);
if (state.meta.tester) lines.push("Testeur : " + state.meta.tester);
if (state.meta.version) lines.push("Version : " + state.meta.version);
if (state.meta.os) lines.push("Système : " + state.meta.os);
lines.push("Étapes cochées : " + countDone() + " / " + ALL_STEPS.length);
SECTIONS.forEach(function (section) {
lines.push("");
lines.push("== " + section.title + " ==");
section.steps.forEach(function (step, i) {
var st = stepState(step.id);
lines.push((st.done ? "[x] " : "[ ] ") + (i + 1) + ". " + step.title);
if (st.note && st.note.trim()) lines.push(" Hésitation : " + st.note.trim());
});
});
return lines.join("\n");
}
function showFallback(text) {
var box = document.getElementById("fallback");
var area = document.getElementById("report");
area.value = text;
box.hidden = false;
area.focus();
area.select();
}
document.getElementById("copy").addEventListener("click", function () {
var text = buildReport();
document.getElementById("fallback").hidden = true;
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
navigator.clipboard.writeText(text).then(function () {
setStatus("Rapport copié. Colle-le dans Paramètres → Système → Envoyer un feedback.");
}, function () {
setStatus("Copie automatique refusée : utilise la zone ci-dessous.");
showFallback(text);
});
} else {
setStatus("Copie automatique indisponible : utilise la zone ci-dessous.");
showFallback(text);
}
});
document.getElementById("reset").addEventListener("click", function () {
if (!window.confirm("Effacer toutes les cases et tous les commentaires ?")) return;
state = { meta: { tester: "", version: "", os: "" }, steps: {} };
try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) { /* nothing to remove */ }
document.getElementById("fallback").hidden = true;
setStatus("Checklist réinitialisée.");
render();
});
render();
})();
</script>
</body>
</html>

View file

@ -1,6 +1,6 @@
# QA — Cycle de mise à jour automatique
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo test` prouvent que le chemin de mise à jour **compile**, jamais qu'il fonctionne. Une régression s'y manifeste chez l'utilisateur, au moment de la mise à jour — et la mise à jour automatique étant une fonctionnalité **Base+**, ce sont des utilisateurs payants.
@ -12,9 +12,9 @@ Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo t
Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode d'échec où la checklist se coche « OK » sans avoir rien vérifié.
- [ ] **Une machine Windows** (hôte ou VM) et **une machine Linux de bureau**. Les deux flux sont réellement différents, ils ne se substituent pas l'un à l'autre.
- [ ] Sur la machine Linux, **un agent polkit qui tourne**. L'installation du `.deb` passe par `pkexec` ; sans agent, l'app retombe en cascade sur `zenity`/`kdialog` puis sur un `sudo` dans un terminal inexistant, et **paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur `readyToInstall`** (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.
- [ ] **La version précédente installée sur chaque machine**, depuis les artefacts de la release précédente (`.deb` et `-setup.exe` attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.
- [ ] **Une machine Windows** (hôte ou VM) et **une machine Linux de bureau**, plus, pour le §3, **une machine Fedora ou openSUSE** installée depuis le `.rpm`. Les flux sont réellement différents, ils ne se substituent pas l'un à l'autre.
- [ ] Sur chaque machine Linux, **un agent polkit qui tourne**. L'installation du `.deb` comme du `.rpm` passe par `pkexec` ; sans agent, l'app retombe en cascade sur `zenity`/`kdialog` puis sur un `sudo` dans un terminal inexistant, et **paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur `readyToInstall`** (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.
- [ ] **La version précédente installée sur chaque machine**, depuis les artefacts de la release précédente (`.deb`, `.rpm` et `-setup.exe` attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.
> Sans ça, `check()` compare `release.version > version_courante`, trouve faux, et l'UI affiche « à jour ». La checklist se coche alors comme « rien à mettre à jour » — soit exactement le saut silencieux qu'elle est censée éliminer, avec en prime une trace écrite affirmant qu'elle est passée.
@ -27,9 +27,9 @@ Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode
À écrire explicitement, parce que la tentation est de croire qu'un cycle réussi couvre tout le chemin de mise à jour.
**Prouvé** sur les deux cibles : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
**Prouvé** sur chaque cible déroulée : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
**Non prouvé** : l'extraction d'archive, donc `tar`. Dans `tauri-plugin-updater`, `tar` n'est atteint que par `install_appimage` et la branche macOS `.app.tar.gz`. Aucun de nos bundles ne passe par là — le `.deb` fait `pkexec dpkg -i`, le NSIS écrit le `.exe` dans un répertoire temporaire et le lance. **Tant qu'on ne livre pas d'AppImage, le chemin `tar` reste mort** et aucune checklist manuelle ne le couvrira.
**Non prouvé** : l'extraction d'archive, et pour cause : depuis #455, `tauri-plugin-updater` est compilé sans sa feature `zip`, donc sans `zip` sous Windows ni `tar` sous Linux. Avec `createUpdaterArtifacts: true`, aucun de nos paquets de mise à jour n'est une archive : le NSIS écrit le `.exe` dans un répertoire temporaire et le lance, le `.deb` passe par `pkexec dpkg -i` et le `.rpm` par `pkexec rpm -U` (§3) ; une AppImage brute, si on en livrait une, serait réécrite en place par la branche « rewriting AppImage », qui ne dépend pas de la feature. Seul `createUpdaterArtifacts: "v1Compatible"` produit des archives (`.nsis.zip`, `.msi.zip`, `.AppImage.tar.gz`). **Passer à ce mode exige de rétablir la feature** (`src-tauri/Cargo.toml`) : sans elle, un zip échoue sous Windows (`InvalidUpdaterFormat`, rien n'est installé) et un `.tar.gz` serait écrit par-dessus l'AppImage sous Linux. La branche macOS `.app.tar.gz` garde `tar`, mais l'app n'est pas livrée sur macOS.
---
@ -62,11 +62,40 @@ Point d'entrée : **Paramètres → Systèmes → carte Mises à jour → « Vé
- [ ] Cliquer → état `installing`, l'app redémarre.
- [ ] Vérifier la version, re-vérifier → `upToDate`.
## 3. RPM — cassé, connu
## 3. Linux (.rpm)
- [ ] **Ne pas tester** : ce chemin est cassé, pas seulement non vérifié.
Même parcours que le §2, sur la machine Fedora ou openSUSE. L'updater y lance `pkexec rpm -U` au lieu de `pkexec dpkg -i` (`install_rpm`, même cascade polkit → zenity/kdialog → `sudo`) : mêmes états, même moment pour l'invite, même piège.
`latest.json` ne porte qu'une entrée `linux-x86_64`, construite à partir du `.deb` (`release.yml:104-122`). Une installation rpm reçoit donc des octets deb, que `Installer::Rpm` rejette après vérification de la signature magique du payload. Les artefacts `.rpm` continuent pourtant d'être publiés comme assets de release. Suivi en **#320**.
- [ ] Même point d'entrée ; la carte affiche `available` avec le **numéro de la nouvelle version**.
- [ ] **Pendant que la carte affiche encore « Téléchargement en cours… »**, une invite d'authentification apparaît. **Noter laquelle.**
Invite observée : `________________`
- [ ] Authentifier → `readyToInstall`, puis cliquer → `installing`, l'app redémarre.
- [ ] Vérifier la version, re-vérifier → `upToDate`.
> Une erreur `invalid updater binary format` sur la carte, juste après le téléchargement, est le défaut d'avant #320 : l'installation a reçu le `.deb` et l'a refusé. Vérifier que le `latest.json` publié porte la clé `linux-x86_64-rpm` (`verify-release.sh`, étape 9 de `/release`).
**Pas encore déroulé** : aucune machine Fedora ou openSUSE n'était disponible quand #320 a ajouté la clé `linux-x86_64-rpm`. Le dérouler dès qu'il y en a une ; d'ici là, l'écrire « non déroulé », avec la raison, dans la trace de la release.
Les installations `.rpm` existantes n'ont rien à réinstaller : le binaire du `.rpm` v0.17.1 publié porte le marqueur `__TAURI_BUNDLE_TYPE_VAR_RPM` (vérifié le 2026-10-10), donc il cherche `linux-x86_64-rpm` dès qu'une release publie cette clé. La première release qui la publie est la première que ces installations peuvent recevoir.
### Les clés de `latest.json`
`tauri-plugin-updater` cherche la clé `{os}-{arch}-{installer}`, puis `{os}-{arch}` (`get_urls`) ; l'installeur est le type de paquet que le bundler grave dans le binaire de chaque paquet. `scripts/build-latest-json.sh`, appelé par `release.yml`, écrit donc :
| Clé | Fichier | Lue par |
|---|---|---|
| `linux-x86_64-deb` | `.deb` | les installations `.deb` |
| `linux-x86_64-rpm` | `.rpm` | les installations `.rpm` (#320) |
| `windows-x86_64` | `-setup.exe` | les installations NSIS, qui cherchent d'abord `windows-x86_64-nsis` |
**Pas de clé générique `linux-x86_64`** (retirée le 2026-10-11, décision de Max sur #320). Toutes les versions qui embarquent l'updater (v0.2.1 et suivantes) verrouillent `tauri-plugin-updater` 2.10.0 et `tauri-utils` 2.8.2, et leurs paquets portent le marqueur de type (vérifié dans le `.deb` et le `.rpm` de la v0.17.1) : une installation `.deb` ou `.rpm` trouve sa clé avant de chercher `linux-x86_64`. Cette clé servait le `.deb`, et ses derniers lecteurs ne savaient pas l'installer :
- les AppImage des v0.4.3 à v0.4.5, publiées sur Forgejo pour la v0.4.3 et sur le miroir GitHub pour les v0.4.3 à v0.4.5. Leur updater lit le registre et cherche `linux-x86_64-appimage`, absente, puis `linux-x86_64`. La v0.4.2 vise `…/releases/latest/download/latest.json`, qui répond 404 sur cette instance Forgejo, et les versions antérieures lisent GitHub ;
- un binaire lancé hors de son paquet, sans marqueur, qui ne cherche que `linux-x86_64`. C'est le cas d'un build `npm run tauri dev` sous Linux : sa carte Mises à jour affiche l'erreur `TargetsNotFound` à chaque vérification. Elle est attendue depuis le retrait de la clé, ce n'est pas une régression (#419).
Avec la clé, l'updater les traitait en AppImage (`install_appimage`) et écrivait les octets du `.deb` à la place du fichier lancé, ce qui cassait l'installation. Sans elle, leur vérification échoue avec `TargetsNotFound` et rien n'est installé : la carte Mises à jour affiche une erreur. La voie, pour ces installations, est de télécharger le `.deb` ou le `.rpm` de la dernière release.
---
@ -76,7 +105,7 @@ L'ordre compte, et la première action ne répare pas ce qu'on croit.
1. **Republier le `latest.json` précédent** sur `generic/simpl-resultat/latest` (`DELETE` puis `PUT`, avec le `PACKAGE_TOKEN` qu'utilise `release.yml`). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le `DELETE` que `release.yml` fait avant chaque envoi.
**Les deux verbes ne visent pas le même préfixe d'API**, et `release.yml:217-219` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
**Les deux verbes ne visent pas le même préfixe d'API**, et l'étape « Publish latest.json to package registry » de `release.yml` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
```
DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest

187
package-lock.json generated
View file

@ -1,18 +1,19 @@
{
"name": "simpl_result_scaffold",
"version": "0.16.0",
"version": "0.17.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.16.0",
"version": "0.17.2",
"license": "GPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2",
@ -1767,6 +1768,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
@ -1980,16 +1990,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -1998,13 +2008,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.9",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -2025,9 +2035,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2038,13 +2048,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.9",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@ -2052,14 +2062,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -2068,9 +2078,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@ -2078,13 +2088,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -2103,9 +2113,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.41",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
"version": "2.11.28",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
"integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -2116,9 +2126,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
"version": "4.29.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
"integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
"dev": true,
"funding": [
{
@ -2136,11 +2146,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"electron-to-chromium": "^1.5.376",
"node-releases": "^2.0.48",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.26",
"caniuse-lite": "^1.0.30001813",
"electron-to-chromium": "^1.5.439",
"node-releases": "^2.0.57",
"update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
@ -2150,9 +2160,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001800",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
"version": "1.0.30001815",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
"integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
"dev": true,
"funding": [
{
@ -2171,9 +2181,9 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
@ -2356,9 +2366,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.384",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
"integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
"version": "1.5.452",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz",
"integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==",
"dev": true,
"license": "ISC"
},
@ -2889,9 +2899,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
@ -2908,9 +2918,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
"version": "2.0.58",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
"integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
"dev": true,
"license": "MIT",
"engines": {
@ -3071,9 +3081,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@ -3093,12 +3103,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
@ -3226,10 +3236,11 @@
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@ -3306,9 +3317,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@ -3340,9 +3351,9 @@
"dev": true
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
"integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
"dev": true,
"funding": [
{
@ -3475,19 +3486,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -3515,12 +3526,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -1,7 +1,7 @@
{
"name": "simpl_result_scaffold",
"private": true,
"version": "0.16.0",
"version": "0.17.2",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
@ -17,6 +17,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2",

116
scripts/build-latest-json.sh Executable file
View file

@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Build latest.json, the updater manifest of a release, from its assets (#320).
#
# Usage: build-latest-json.sh <assets-dir> <version> <notes-file> <base-url>
#
# assets-dir directory holding the bundles and their .sig files
# version release version without the leading v, e.g. 0.17.1
# notes-file release notes, embedded verbatim as data (jq --rawfile)
# base-url download URL of the release, without a trailing slash, e.g.
# https://<forgejo>/<owner>/<repo>/releases/download/v0.17.1
#
# Writes the JSON on stdout and its log on stderr. release.yml redirects
# stdout to release-assets/latest.json; scripts/build-latest-json.test.mjs
# runs it under bash -e -o pipefail, as the CI runner does.
#
# Fails closed: unless the assets dir holds exactly one .deb, one .rpm and
# one -setup.exe, each with a non-empty "<asset>.sig" next to it, the script
# exits non-zero before writing anything on stdout. The JSON is built by jq
# alone: notes, file names and signatures enter it as jq values, never as
# text spliced into JSON.
#
# Keys. tauri-plugin-updater 2.x looks up "{os}-{arch}-{installer}" first,
# then "{os}-{arch}" (get_urls in its updater.rs). The installer is the
# bundle type that the bundler writes into the binary of each package.
#
# linux-x86_64-deb the .deb, for .deb installs.
# linux-x86_64-rpm the .rpm, for .rpm installs. Without this key they fell
# back on linux-x86_64, downloaded the .deb and refused it.
# windows-x86_64 the NSIS installer. An NSIS install looks up
# windows-x86_64-nsis first, then this key.
#
# No generic linux-x86_64 key since 2026-10-11 (Max's decision on #320). Every
# version that ships the updater (v0.2.1 and later, tauri-plugin-updater
# 2.10.0) carries its bundle-type marker, so .deb and .rpm installs never
# reach that key. Its only readers were the AppImage installs v0.4.3 to
# v0.4.5 and binaries run outside a package, and the .deb it served overwrote
# them: they now get TargetsNotFound when checking, nothing installed. See
# docs/qa-update-cycle.md, "Les clés de latest.json".
#
# Each URL ends with the file name percent-encoded by jq's @uri, as the upload
# step of release.yml encodes it: the bundle names contain a space.
set -euo pipefail
die() {
printf 'build-latest-json: %s\n' "$*" >&2
exit 1
}
[ "$#" -eq 4 ] || die "usage: build-latest-json.sh <assets-dir> <version> <notes-file> <base-url>"
assets_dir=$1
version=$2
notes_file=$3
base_url=$4
[ -d "$assets_dir" ] || die "assets dir not found: $assets_dir"
[ -n "$version" ] || die "empty version"
[ -f "$notes_file" ] || die "notes file not found: $notes_file"
[ -n "$base_url" ] || die "empty base URL"
# read_asset <kind> <matches...>: the single bundle of a kind and its
# signature, in asset_name and asset_sig. Any other count of matches, or a
# missing or blank "<asset>.sig", stops the script.
asset_name=""
asset_sig=""
read_asset() {
local kind=$1
shift
if [ "$#" -ne 1 ]; then
printf 'build-latest-json: expected exactly one %s in %s, found %d\n' "$kind" "$assets_dir" "$#" >&2
[ "$#" -eq 0 ] || printf ' %s\n' "$@" >&2
exit 1
fi
local file=$1
[ -s "$file.sig" ] || die "missing or empty signature: $file.sig"
asset_name=${file##*/}
asset_sig=$(cat "$file.sig")
[ -n "$asset_sig" ] || die "blank signature: $file.sig"
printf 'build-latest-json: %s <- %s\n' "$kind" "$asset_name" >&2
}
shopt -s nullglob
read_asset .deb "$assets_dir"/*.deb
deb_name=$asset_name
deb_sig=$asset_sig
read_asset .rpm "$assets_dir"/*.rpm
rpm_name=$asset_name
rpm_sig=$asset_sig
read_asset -setup.exe "$assets_dir"/*-setup.exe
exe_name=$asset_name
exe_sig=$asset_sig
pub_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
jq -n \
--arg version "$version" \
--rawfile notes "$notes_file" \
--arg pub_date "$pub_date" \
--arg base "$base_url" \
--arg deb "$deb_name" --arg deb_sig "$deb_sig" \
--arg rpm "$rpm_name" --arg rpm_sig "$rpm_sig" \
--arg exe "$exe_name" --arg exe_sig "$exe_sig" \
'def asset($name; $sig): {signature: $sig, url: ($base + "/" + ($name | @uri))};
{
version: $version,
notes: $notes,
pub_date: $pub_date,
platforms: {
"linux-x86_64-deb": asset($deb; $deb_sig),
"linux-x86_64-rpm": asset($rpm; $rpm_sig),
"windows-x86_64": asset($exe; $exe_sig)
}
}'

View file

@ -0,0 +1,462 @@
/**
* scripts/build-latest-json.sh — the updater manifest of a release (#320).
*
* release.yml runs this script to write latest.json, which every installed
* app reads to find its update. tauri-plugin-updater looks up
* "{os}-{arch}-{installer}" first, then "{os}-{arch}". Before #320 the
* manifest only had linux-x86_64 (the .deb), so an .rpm install downloaded
* the .deb and refused it. Since 2026-10-11 it has no linux-x86_64 key at
* all: its last readers (old AppImage installs, binaries run outside a
* package) could not install the .deb it served.
*
* Each test runs the script as release.yml does, under bash -e -o pipefail
* (the shell of the runner's steps), on a temporary assets directory. The
* bundle names are the real ones of v0.17.1: they contain a space, which the
* URLs must percent-encode. Expected values are literals, never rebuilt from
* the inputs.
*
* It also runs scripts/check-latest-json.jq, the filter release.yml applies
* before deleting the published manifest, so that check is exercised on every
* pull request instead of only at the tag, and compares the key lists of the
* script, of that filter and of verify-release.sh. Last, it runs the text of
* release.yml's "Publish latest.json to package registry" step against a fake
* curl, so the way the step calls the filter is tested too (#419).
*
* check-frontend.yml does not ignore .claude/** nor release.yml for that
* reason: a pull request that only touches verify-release.sh or release.yml
* still runs this file.
*
* Plain .mjs on purpose: tsconfig only covers src/, so a .ts file here would
* look type-checked without being so. Needs bash and jq on PATH;
* check-frontend.yml installs jq for this file.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "build-latest-json.sh");
const CHECK = join(HERE, "check-latest-json.jq");
const VERIFY_RELEASE = join(HERE, "..", ".claude", "skills", "release", "scripts", "verify-release.sh");
const RELEASE_YML = join(HERE, "..", ".forgejo", "workflows", "release.yml");
const BASE = "https://git.example.test/owner/Repo/releases/download/v0.17.1";
const DEB = "Simpl Resultat_0.17.1_amd64.deb";
const RPM = "Simpl Resultat-0.17.1-1.x86_64.rpm";
const EXE = "Simpl Resultat_0.17.1_x64-setup.exe";
// One distinct signature per bundle, to tell which .sig each key carries.
const SIG = {
[DEB]: "RWRkZWItc2lnbmF0dXJl",
[RPM]: "RWRycG0tc2lnbmF0dXJl",
[EXE]: "RWRleGUtc2lnbmF0dXJl",
};
let work;
let assets;
let notesFile;
function writeAsset(name, sig = SIG[name]) {
writeFileSync(join(assets, name), `bytes of ${name}\n`);
if (sig !== null) writeFileSync(join(assets, `${name}.sig`), sig);
}
function writeAllAssets() {
for (const name of [DEB, RPM, EXE]) writeAsset(name);
}
function run(args = [assets, "0.17.1", notesFile, BASE]) {
return spawnSync("bash", ["-e", "-o", "pipefail", SCRIPT, ...args], { encoding: "utf8" });
}
function expectFailure(result, message) {
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toContain(message);
}
// The manifest the script writes for the three bundles of v0.17.1.
function builtManifest() {
writeAllAssets();
const result = run();
expect(result.status).toBe(0);
return JSON.parse(result.stdout);
}
// Runs scripts/check-latest-json.jq with the options release.yml passes, on a
// manifest written to a file: an object is serialized, a string is written as
// is. The "release.yml Publish step" tests below run the step's own text.
function check(manifest, version = "0.17.1") {
const file = join(work, "latest.json");
writeFileSync(file, typeof manifest === "string" ? manifest : JSON.stringify(manifest));
return spawnSync("jq", ["-e", "-s", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" });
}
beforeAll(() => {
const jq = spawnSync("jq", ["--version"], { encoding: "utf8" });
if (jq.status !== 0) {
throw new Error("jq not found: build-latest-json.sh needs it (check-frontend.yml installs it)");
}
});
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), "build-latest-json-"));
assets = join(work, "release-assets");
mkdirSync(assets);
notesFile = join(work, "notes.md");
writeFileSync(notesFile, "### Fixed\n\n- Something (#320).");
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
});
describe("build-latest-json.sh", () => {
it("writes the three keys, each with its own URL and its own asset's signature", () => {
writeAllAssets();
const result = run();
expect(result.status).toBe(0);
const manifest = JSON.parse(result.stdout);
expect(manifest.version).toBe("0.17.1");
expect(manifest.notes).toBe("### Fixed\n\n- Something (#320).");
expect(manifest.pub_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
// No generic linux-x86_64: an install without a key of its own must fail
// its check (TargetsNotFound) rather than receive the .deb.
expect(manifest.platforms).toStrictEqual({
"linux-x86_64-deb": {
signature: "RWRkZWItc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_amd64.deb",
},
"linux-x86_64-rpm": {
signature: "RWRycG0tc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat-0.17.1-1.x86_64.rpm",
},
"windows-x86_64": {
signature: "RWRleGUtc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe",
},
});
// The step log names the bundle picked for each kind.
expect(result.stderr).toContain("build-latest-json: .rpm <- Simpl Resultat-0.17.1-1.x86_64.rpm");
});
it.each([
[".deb", DEB],
[".rpm", RPM],
["-setup.exe", EXE],
])("fails without a %s bundle, writing nothing on stdout", (kind, missing) => {
for (const name of [DEB, RPM, EXE]) {
if (name !== missing) writeAsset(name);
}
expectFailure(run(), `expected exactly one ${kind} in ${assets}, found 0`);
});
it("fails on two .deb bundles", () => {
writeAllAssets();
writeAsset("Simpl Resultat_0.17.0_amd64.deb", "RWRvbGQtZGViLXNpZ25hdHVyZQ==");
expectFailure(run(), `expected exactly one .deb in ${assets}, found 2`);
});
it("serves Windows the NSIS -setup.exe only, not another .exe", () => {
writeAllAssets();
writeAsset("simpl-result.exe", "RWRhcHAtZXhlLXNpZ25hdHVyZQ==");
const result = run();
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).platforms["windows-x86_64"]).toStrictEqual({
signature: "RWRleGUtc2lnbmF0dXJl",
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe",
});
});
it("fails on an empty .sig", () => {
writeAllAssets();
writeFileSync(join(assets, `${RPM}.sig`), "");
expectFailure(run(), `missing or empty signature: ${assets}/${RPM}.sig`);
});
it("fails on a .sig holding only line breaks", () => {
writeAllAssets();
writeFileSync(join(assets, `${EXE}.sig`), "\n\n");
expectFailure(run(), `blank signature: ${assets}/${EXE}.sig`);
});
it("fails on a missing .sig", () => {
writeAllAssets();
unlinkSync(join(assets, `${DEB}.sig`));
expectFailure(run(), `missing or empty signature: ${assets}/${DEB}.sig`);
});
it("keeps notes with quotes, backslashes and a CHANGELOG_EOF line intact", () => {
writeAllAssets();
const notes =
'### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' +
"CHANGELOG_EOF\n" +
'"version": "9.9.9", "platforms": {}\n';
writeFileSync(notesFile, notes);
const result = run();
expect(result.status).toBe(0);
const manifest = JSON.parse(result.stdout);
expect(manifest.notes).toBe(
'### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' +
"CHANGELOG_EOF\n" +
'"version": "9.9.9", "platforms": {}\n',
);
expect(manifest.version).toBe("0.17.1");
expect(Object.keys(manifest.platforms)).toHaveLength(3);
});
it("fails without its four arguments", () => {
writeAllAssets();
expectFailure(run([assets, "0.17.1", notesFile]), "usage: build-latest-json.sh");
});
// The other tests pass 0.17.1, the version the bundle names also carry: a
// script that ignored its argument would pass them all.
it("takes the version from its argument, not from the bundle names", () => {
writeAllAssets();
const result = run([assets, "0.18.0", notesFile, BASE]);
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).version).toBe("0.18.0");
});
it("fails on an empty version", () => {
writeAllAssets();
expectFailure(run([assets, "", notesFile, BASE]), "empty version");
});
});
describe("check-latest-json.jq", () => {
it("passes the script's output for the tag's version", () => {
const result = check(builtManifest());
expect(result.status).toBe(0);
expect(result.stdout).toBe("true\n");
});
it("refuses a manifest whose version is not the tag's", () => {
expect(check(builtManifest(), "0.18.0").status).not.toBe(0);
});
it.each([
["a missing key", (m) => delete m.platforms["linux-x86_64-rpm"]],
["an extra key", (m) => (m.platforms["linux-x86_64-appimage"] = m.platforms["linux-x86_64-deb"])],
["the generic linux-x86_64 key, removed on 2026-10-11", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])],
["the .rpm key on the .deb", (m) => (m.platforms["linux-x86_64-rpm"] = m.platforms["linux-x86_64-deb"])],
["the .deb key on the .rpm", (m) => (m.platforms["linux-x86_64-deb"] = m.platforms["linux-x86_64-rpm"])],
["the Windows key on a .sig", (m) => (m.platforms["windows-x86_64"].url += ".sig")],
["an empty signature", (m) => (m.platforms["linux-x86_64-deb"].signature = "")],
["a signature that is not a string", (m) => (m.platforms["windows-x86_64"].signature = 42)],
["a key without a URL", (m) => delete m.platforms["linux-x86_64-rpm"].url],
["a manifest without platforms", (m) => delete m.platforms],
])("refuses %s", (_, mutate) => {
const manifest = builtManifest();
mutate(manifest);
expect(check(manifest).status).not.toBe(0);
});
it("refuses the shape published up to v0.17.1, without a key per Linux package", () => {
const published = {
version: "0.17.1",
notes: "### Fixed",
pub_date: "2026-10-09T01:44:40Z",
platforms: {
"linux-x86_64": { signature: "RWRkZWItc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_amd64.deb` },
"windows-x86_64": { signature: "RWRleGUtc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_x64-setup.exe` },
},
};
expect(check(published).status).not.toBe(0);
});
it("refuses a file that is not JSON", () => {
expect(check('{"version": "0.17.1", "platforms": ').status).not.toBe(0);
});
// Without -s, jq 1.6 (the CI's and the release container's) produces no
// output for an empty file, which -e lets through with status 0 (#419).
it("refuses an empty file", () => {
expect(check("").status).not.toBe(0);
});
// Without -s, jq judges each document and its exit status follows the last
// one, under 1.6 and 1.7 alike: a valid last document hid a broken first one.
it("refuses a file with two documents, even when the last one is valid", () => {
const valid = JSON.stringify(builtManifest());
expect(check(`{"version": "0.0.0"}\n${valid}\n`).status).not.toBe(0);
expect(check(`${valid}\n${valid}\n`).status).not.toBe(0);
});
});
describe("key lists", () => {
it("are the same in the script, in check-latest-json.jq and in verify-release.sh", () => {
const manifest = builtManifest();
const written = Object.keys(manifest.platforms).sort();
// The filter compares the exact key set: passing the script's output
// proves its list is the script's.
expect(check(manifest).status).toBe(0);
// verify-release.sh keeps its own list, in a Python dict literal.
const dict = readFileSync(VERIFY_RELEASE, "utf8").match(/^EXPECTED = \{([^}]*)\}/m);
expect(dict).not.toBeNull();
const expected = [...dict[1].matchAll(/"([a-z0-9_-]+)":/g)].map((m) => m[1]).sort();
expect(expected).toStrictEqual(written);
});
});
// The text of release.yml's "Publish latest.json to package registry" step,
// dedented. A whitespace-only line stays in the block; the block ends at the
// first non-blank line indented no deeper than "run:", or at the end of file.
function publishStepScript() {
const lines = readFileSync(RELEASE_YML, "utf8").split("\n");
const step = lines.findIndex((l) => /^\s*- name: Publish latest\.json to package registry\s*$/.test(l));
expect(step).toBeGreaterThan(-1);
const runAt = lines.findIndex((l, i) => i > step && /^\s*run: \|\s*$/.test(l));
expect(runAt).toBeGreaterThan(step);
const runIndent = lines[runAt].search(/\S/);
const block = [];
for (const line of lines.slice(runAt + 1)) {
if (line.trim() !== "" && line.search(/\S/) <= runIndent) break;
block.push(line);
}
while (block.length > 0 && block[block.length - 1].trim() === "") block.pop();
const indent = block[0].search(/\S/);
return block.map((l) => l.slice(indent)).join("\n") + "\n";
}
describe("release.yml Publish step", () => {
const SERVER = "https://git.example.test";
const DELETE_URL = `${SERVER}/api/v1/packages/owner/generic/simpl-resultat/latest`;
const PUT_URL = `${SERVER}/api/packages/owner/generic/simpl-resultat/latest/latest.json`;
let curlLog;
// A curl that records "METHOD URL" (and the uploaded file for a PUT) and
// prints the status code the step reads through -w: 204 to the DELETE, 201
// to the PUT. It never touches the network.
function writeFakeCurl(bin) {
const curl = join(bin, "curl");
writeFileSync(
curl,
`#!/usr/bin/env bash
method=GET url= data=
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift ;;
-H|-o|-w) shift ;;
--data-binary) data="$2"; shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
if [ "$method" = PUT ]; then
[ -f "\${data#@}" ] || { echo "fake curl: no file \${data#@}" >&2; exit 2; }
echo "PUT $url \${data#@}" >> "$CURL_LOG"
printf 201
else
echo "$method $url" >> "$CURL_LOG"
printf 204
fi
`,
);
chmodSync(curl, 0o755);
}
// Runs the step's text as the runner's default "shell: bash" does, in a
// directory laid out like the checkout: scripts/check-latest-json.jq and
// release-assets/latest.json.
function publish(manifest, tag = "v0.17.1") {
const bin = join(work, "bin");
mkdirSync(bin);
writeFakeCurl(bin);
mkdirSync(join(work, "scripts"));
copyFileSync(CHECK, join(work, "scripts", "check-latest-json.jq"));
writeFileSync(
join(assets, "latest.json"),
typeof manifest === "string" ? manifest : JSON.stringify(manifest),
);
curlLog = join(work, "curl.log");
return spawnSync("bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", publishStepScript()], {
cwd: work,
encoding: "utf8",
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
CURL_LOG: curlLog,
GITHUB_REF_NAME: tag,
GITHUB_SERVER_URL: SERVER,
GITHUB_REPOSITORY_OWNER: "owner",
FORGEJO_TOKEN: "fake-token",
},
});
}
function curlCalls() {
return existsSync(curlLog) ? readFileSync(curlLog, "utf8").trim().split("\n") : [];
}
it("is extracted whole, without workflow expressions", () => {
const script = publishStepScript();
expect(script).toContain("-f scripts/check-latest-json.jq");
expect(script).toContain("-X DELETE");
expect(script).toContain("-X PUT");
// The runner would substitute them before bash runs; this test would not.
expect(script).not.toContain("${{");
});
it("deletes the published manifest, then uploads the new one", () => {
const result = publish(builtManifest());
expect(result.status).toBe(0);
expect(curlCalls()).toStrictEqual([
`DELETE ${DELETE_URL}`,
`PUT ${PUT_URL} release-assets/latest.json`,
]);
});
it.each([
["the generic linux-x86_64 key", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])],
["a version that is not the tag's", (m) => (m.version = "0.18.0")],
])("stops before any request on %s", (_, mutate) => {
const manifest = builtManifest();
mutate(manifest);
const result = publish(manifest);
expect(result.status).toBe(1);
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
expect(curlCalls()).toStrictEqual([]);
});
it.each([
["an empty file", () => ""],
["two documents, the last one valid", () => `{"version": "0.0.0"}\n${JSON.stringify(builtManifest())}\n`],
])("stops before any request on %s", (_, manifest) => {
const result = publish(manifest());
expect(result.status).toBe(1);
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
expect(curlCalls()).toStrictEqual([]);
});
});

View file

@ -0,0 +1,34 @@
# Check of latest.json, the updater manifest, before release.yml deletes the
# published one from the package registry (#320). Once that version is
# deleted, installed apps find no update until a valid one is uploaded, so a
# manifest that fails this check stops the job and leaves the registry as is.
#
# Run as: jq -e -s --arg version <X.Y.Z> -f scripts/check-latest-json.jq latest.json
# -e turns false, null and any jq error (missing field, wrong type, invalid
# JSON) into a non-zero exit status: the check fails closed.
# -s reads the whole file as one array of documents, and the filter wants
# exactly one (#419). Without it, jq 1.6 runs the filter once per document: an
# empty file gives no output, which -e lets through with status 0, and two
# documents are judged on the last one alone. jq 1.7 refuses the empty file
# (status 4) but still passes the two documents.
#
# The key set must stay the one scripts/build-latest-json.sh writes and
# .claude/skills/release/scripts/verify-release.sh expects.
# scripts/build-latest-json.test.mjs runs this file on the script's output and
# on broken manifests, and compares the three lists, on every pull request
# through check-frontend: a misaligned list shows before the tag, not at it.
# It also runs the text of release.yml's Publish step, which calls this file.
# Written for jq 1.6, the version ubuntu:22.04 ships.
#
# Not checked here: that each signature belongs to its own file. The script's
# test checks it before the merge, and verify-release.sh after publication
# (the file: field of the minisign trusted comment).
length == 1
and (.[0]
| .version == $version
and (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"]
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
and (.platforms["windows-x86_64"].url | endswith(".exe")))

393
scripts/review-levels.py Executable file
View file

@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Replay the review levels of REVIEW.md on merged pull requests (#456).
Usage:
python3 scripts/review-levels.py [FIRST LAST] (default: 344 452)
python3 scripts/review-levels.py --check (no network)
The first form reads the Forgejo API (token in $FORGEJO_TOKEN, else
~/.forgejo-token; read-only calls) and prints each merged PR of the range
with its trial level (pilot document, section 4.3) and its REVIEW.md level,
then the distributions for the 39 reference PRs and for the whole range.
--check compares the patterns of this script with the first column of the
Haut table of REVIEW.md and exits 1 if they differ: a PR that changes the
Niveaux section updates this script in the same diff.
The content conditions of the Haut table are approximated from the diff:
lib.rs (only generate_handler![] entries are exempt), Tauri configuration
and manifests (only the app's version is exempt), lock files (a package
added or removed, an origin changed), check workflows (a change that can
turn a failing check green, read against the PR's merge base), network
calls (reqwest in Rust, fetch to an absolute URL in TS), consent files.
"""
import itertools
import json
import os
import re
import sys
import urllib.request
API = "https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
REVIEW_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "REVIEW.md")
# The 39 reference PRs of the pilot document, frozen by number: the listing
# that produced them (closed PRs, limit=40, sort=recentupdate) is not stable.
REF39 = [344, 375, 377, 378, 379, 380, 381, 382, 383, 386, 387, 388, 394, 402, 403,
407, 412, 413, 414, 415, 416, 417, 418, 420, 428, 433, 434, 435, 436, 438,
439, 440, 441, 442, 443, 444, 445, 448, 452]
TRIAL_HIGH = [
"src-tauri/src/commands/{auth_commands,token_store,license_commands,entitlements,account_cache,backup_commands,fs_commands,export_import_commands}.rs",
"src-tauri/src/lib.rs", "src-tauri/src/database/**", "src-tauri/tauri.conf.json",
"src-tauri/capabilities/**", ".forgejo/workflows/**", ".cargo/**", "src-tauri/Cargo.{toml,lock}",
"package.json", "package-lock.json",
"src/services/{authService,licenseService,db,feedbackService,dataExportService}.ts",
"src/services/entitlements*", "scripts/build-latest-json*",
]
TRIAL_LOW = ["**/*.md", "docs/**", "reports/**", "tasks/**", "public/CHANGELOG*", "LICENSE"]
# REVIEW.md, table Haut: rows without a condition.
HIGH_ALWAYS = [
"src-tauri/src/database/**",
"src-tauri/src/commands/profile_commands.rs",
"src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs",
"src/services/{licenseService,authService}.ts",
".claude/skills/issue-license/**",
"src-tauri/src/commands/export_import_commands.rs",
"src-tauri/src/commands/feedback_commands.rs", "src/services/feedbackService.ts",
"src-tauri/capabilities/**",
".forgejo/workflows/release.yml", "scripts/build-latest-json*", "scripts/check-latest-json.jq",
".claude/skills/release/**",
"src-tauri/build.rs", "{vite,vitest,postcss}.config.*", ".postcssrc*", ".npmrc", "**/rust-toolchain*",
"Cargo.{toml,lock}",
"**/.cargo/**",
".claude/settings.json", ".mcp.json",
]
CONSENT_FILES = {"src/components/balance/PriceFetchControl.tsx",
"src/components/settings/PriceFetchConsentToggle.tsx"}
TAURI_CONFIG = ["src-tauri/{tauri*.conf.json*,Tauri*.toml}"]
CHECK_WORKFLOWS = {".forgejo/workflows/check-rust.yml", ".forgejo/workflows/check-frontend.yml"}
SHIPPED = ["src/**", "src-tauri/src/**", "index.html", "public/**"]
LOW = ["**/*.md", "docs/**", "LICENSE", "tasks/**", "reports/**"]
NEVER_LOW = ["CLAUDE.md", "REVIEW.md", "docs/adr/**", "docs/architecture.md", "docs/qa-update-cycle.md", ".claude/**"]
def token():
return os.environ.get("FORGEJO_TOKEN") or open(os.path.expanduser("~/.forgejo-token")).read().strip()
def get(path, raw=False):
req = urllib.request.Request(API + path, headers={"Authorization": "token " + token()})
data = urllib.request.urlopen(req).read()
return data if raw else json.loads(data)
def expand_braces(pat):
m = re.search(r"\{([^{}]*)\}", pat)
if not m:
return [pat]
return list(itertools.chain.from_iterable(
expand_braces(pat[:m.start()] + alt + pat[m.end():]) for alt in m.group(1).split(",")))
def glob_re(pat):
# '**/' spans zero or more directories, '*' and '?' never cross '/'
i, out = 0, ""
while i < len(pat):
if pat.startswith("**/", i):
out += "(?:.*/)?"; i += 3; continue
if pat.startswith("**", i):
out += ".*"; i += 2; continue
c = pat[i]
out += "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
i += 1
return re.compile("^" + out + "$")
def match(pats, path):
# '{a,b}' alternates, wildcards allowed inside
return any(glob_re(p).match(path) for pat in pats for p in expand_braces(pat))
def file_blocks(diff):
out = {}
for block in re.split(r"(?m)^diff --git ", diff)[1:]:
out[block.split("\n", 1)[0].split(" b/")[-1]] = block
return out
def meaningful(text):
t = text.strip()
return t != "" and not t.startswith(("#", "//"))
def changed(block):
return [l for l in block.split("\n")
if l[:1] in "+-" and not l.startswith(("+++", "---")) and meaningful(l[1:])]
def lock_adds_or_removes(name, block):
cargo = name.endswith("Cargo.lock")
lines = block.split("\n")
if cargo and any(re.match(r'^[+-]name = ', l) for l in lines):
return True
if not cargo and any(re.match(r'^[+-]\s+"node_modules/[^"]+": \{', l) for l in lines):
return True
source = r'^([+-])source = "([^"]+)"' if cargo else r'^([+-])\s+"resolved": "([^"]+)"'
digest = r'^\+(checksum) = ' if cargo else r'^\+\s+"(integrity)": '
version = r'^\+version = ' if cargo else r'^\+\s+"version": '
for hunk in re.split(r"(?m)^@@", block)[1:]:
hl = hunk.split("\n")
# an origin whose host or scheme changes, with or without a new version
before, after = set(), set()
for l in hl:
m = re.match(source, l)
if m:
(before if m.group(1) == "-" else after).add(origin_of(m.group(2)))
if after - before:
return True
# a checksum or integrity that changes without a new version
if any(re.match(digest, l) for l in hl) and not any(re.match(version, l) for l in hl):
return True
return False
def origin_of(url):
m = re.match(r"^([a-z+]+)://([^/]+)", url)
return m.group(1) + "://" + m.group(2) if m else url
STEP = re.compile(r"^\s*- (name|uses):")
VERDICT = re.compile(r"npm ci|npm run build|npm test|cargo check|cargo test|cargo audit|cargo-audit|Verify", re.I)
def regions(base):
lines = base.split("\n")
region, top, job_key = {}, None, None
for i, l in enumerate(lines, 1):
m = re.match(r"^([A-Za-z_][\w-]*):", l)
if m:
top = m.group(1)
m = re.match(r"^ ([A-Za-z_][\w-]*):", l)
if m:
job_key = m.group(1)
elif re.match(r"^\S", l) or re.match(r"^ \S", l):
job_key = None
if top in ("on", "true"):
region[i] = "on"
elif top == "permissions" or job_key in ("env", "container", "permissions"):
region[i] = "jobcfg"
else:
region[i] = "other"
starts = [i for i, l in enumerate(lines, 1) if STEP.match(l)]
for k, s in enumerate(starts):
e = starts[k + 1] - 1 if k + 1 < len(starts) else len(lines)
if VERDICT.search("\n".join(lines[s - 1:e])):
for i in range(s, e + 1):
if region[i] == "other":
region[i] = "verdict"
return region, lines
def weakening(block, base):
"""Ways a check-workflow diff could turn a failing check green (empty = none)."""
region, lines = regions(base)
reasons, old, plus = set(), 0, []
def flush(pos):
texts = [p[1:] for p in plus]
active = [t for t in texts if meaningful(t)] # comments never count
if any(re.search(r"continue-on-error|\|\|\s*true", t) for t in active):
reasons.add("continue-on-error or || true added")
if any(re.search(r"GITHUB_ENV|GITHUB_PATH|npm pkg set|git checkout|git restore", t) for t in active):
reasons.add("writes the job environment or the repository")
if any(re.search(r"^\s*(- )?uses:", t) and not re.search(r"Swatinem/rust-cache|actions/cache", t) for t in active):
reasons.add("action added (not a cache)")
if any(re.search(r"rustup|toolchain|\bPATH\b|/bin/", t) for t in active):
reasons.add("install step changes the toolchain or the PATH")
if any(re.match(r"^(\s{4})?(if|defaults):", t) for t in active):
reasons.add("job-level if/defaults changed")
first = next((t for t in texts if meaningful(t)), None)
if first is None or STEP.match(first):
return
prev, nxt = region.get(pos - 1, "other"), region.get(pos, "other")
if "on" in (prev, nxt):
if any(re.search(r"branches|paths-ignore", t) for t in active):
reasons.add("trigger narrowed")
j = pos - 1
while j > 0 and not re.match(r"^\s*[\w-]+:", lines[j - 1]):
j -= 1
if j > 0 and "paths-ignore" in lines[j - 1] and any(re.match(r"^\s*- ", t) for t in active):
reasons.add("paths-ignore entry added")
elif prev == nxt == "verdict":
reasons.add("verdict step modified")
elif "jobcfg" in (prev, nxt):
reasons.add("job env/container/permissions modified")
for line in block.split("\n"):
m = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", line)
if m:
flush(old); plus = []
old = int(m.group(1)); continue
if line.startswith("+") and not line.startswith("+++"):
plus.append(line); continue
flush(old); plus = []
if line.startswith("-") and not line.startswith("---"):
if meaningful(line[1:]):
if re.match(r"^(\s{4})?(if|defaults):", line[1:]):
reasons.add("job-level if/defaults changed")
if re.search(r"rustup|toolchain|\bPATH\b|/bin/", line[1:]):
reasons.add("install step changes the toolchain or the PATH")
r = region.get(old, "other")
if r == "on":
reasons.add("trigger removed or changed")
elif r == "verdict":
reasons.add("verdict step modified or removed")
elif r == "jobcfg":
reasons.add("job env/container/permissions modified")
old += 1
elif line.startswith(" "):
old += 1
flush(old)
return sorted(reasons)
def adds_network_call(path, block):
if not match(SHIPPED, path) or re.search(r"test", path):
return False
for l in block.split("\n"):
if l.startswith("+") and not l.startswith("+++"):
if path.endswith(".rs") and "reqwest" in l:
return True
if re.search(r"\.(ts|tsx)$", path) and re.search(r"fetch\(\s*[`'\"]https?://", l):
return True
return False
def trial_level(paths):
if any(match(TRIAL_HIGH, p) for p in paths):
return "haut"
return "bas" if all(match(TRIAL_LOW, p) for p in paths) else "normal"
def review_level(number, files, diff, merge_base):
blocks = file_blocks(diff)
triggers = []
for f in files:
b = blocks.get(f["filename"], "")
for path in filter(None, {f["filename"], f.get("previous_filename")}):
if match(HIGH_ALWAYS, path):
triggers.append(path)
elif path == "src-tauri/src/lib.rs":
if [l for l in changed(b) if not re.match(r"^[+-]\s*commands::\w+,?\s*$", l)]:
triggers.append(path)
elif path == "src-tauri/src/commands/balance_commands.rs":
hunks = re.split(r"(?m)^(?=@@)", b)[1:]
if any(re.search(r"fetch_price|activation", h.split("\n")[0]) or
any(re.search(r"fetch_price|activation", l) for l in changed(h)) for h in hunks):
triggers.append(path)
elif match(TAURI_CONFIG, path) or path in ("src-tauri/Cargo.toml", "package.json"):
if [l for l in changed(b) if not re.match(r'^[+-]\s*"?version"?\s*[:=]', l)]:
triggers.append(path)
elif path in ("src-tauri/Cargo.lock", "package-lock.json"):
if lock_adds_or_removes(path, b):
triggers.append(path)
elif path == ".forgejo/workflows/audit.yml" and changed(b):
triggers.append(path)
elif path in CHECK_WORKFLOWS:
wf = path.split("/")[-1]
base = get(f"/raw/.forgejo/workflows/{wf}?ref={merge_base}", raw=True).decode()
if weakening(b, base):
triggers.append(path)
elif path.startswith(".forgejo/workflows/") and f["status"] == "added":
triggers.append(path + " (new workflow)")
elif path == "REVIEW.md":
triggers.append(path + " (check the « Niveaux » section by hand)")
if adds_network_call(path, b):
triggers.append(path + " (network call)")
added = [l for l in b.split("\n") if l.startswith("+") and not l.startswith("+++")]
if path.startswith(".forgejo/workflows/") and any(re.search(r"\bsecrets\b|pull_request_target", l) for l in added):
triggers.append(path + " (secrets or pull_request_target added)")
if path in CONSENT_FILES and any(re.search(r"consent", l, re.I) for l in changed(b)):
triggers.append(path + " (consent)")
parts = path.split("/")
top_level = len(parts) == 1 or (parts[0] == "src-tauri" and len(parts) == 2)
new_hidden = f["status"] == "added" and any(
x.startswith(".") for x in parts[:-1]) and parts[0] not in (".forgejo", ".claude", ".cargo", ".github")
# Forgejo reports an edited file as "changed"
if (f["status"] not in ("changed", "modified") and top_level and not path.endswith(".md")) or new_hidden:
triggers.append(path + " (" + f["status"] + " at top level)")
paths = [f["filename"] for f in files]
if triggers:
return "haut", sorted(set(triggers))
if all(match(LOW, p) and not match(NEVER_LOW, p) for p in paths):
return "bas", []
return "normal", []
CONDITIONAL = {
"src-tauri/src/lib.rs", "src-tauri/src/commands/balance_commands.rs",
"src-tauri/Cargo.toml", "package.json", "src-tauri/Cargo.lock", "package-lock.json",
".forgejo/workflows/**", ".forgejo/workflows/audit.yml",
".forgejo/workflows/{check-rust,check-frontend}.yml", "REVIEW.md",
}
def check():
"""Compare this script's patterns with the first column of the Haut table."""
text = open(REVIEW_MD, encoding="utf-8").read()
table = text.split("### Haut", 1)[1].split("\n\n", 2)[1]
documented = set()
for row in table.split("\n"):
if not row.startswith("| ") or row.startswith("| Motif") or row.startswith("| fichier à la racine"):
continue
documented.update(re.findall(r"`([^`]+)`", row.split(" | ")[0]))
coded = set(HIGH_ALWAYS) | set(TAURI_CONFIG) | set(SHIPPED) | CONDITIONAL
for label, extra in (("REVIEW.md only", documented - coded), ("script only", coded - documented)):
for pattern in sorted(extra):
print(f"{label}: {pattern}")
if documented != coded:
return 1
print(f"ok: {len(coded)} patterns, identical to the Haut table of REVIEW.md")
return 0
def main():
if sys.argv[1:] == ["--check"]:
sys.exit(check())
first, last = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) == 3 else (344, 452)
merged, page = [], 1
while True:
batch = get(f"/pulls?state=closed&limit=50&page={page}")
if not batch:
break
merged += [p for p in batch if p.get("merged") and first <= p["number"] <= last]
page += 1
merged.sort(key=lambda p: p["number"])
rows = []
for p in merged:
n = p["number"]
files, fpage = [], 1
while True:
# the instance caps a page at 50 items: read until an empty page
fb = get(f"/pulls/{n}/files?limit=50&page={fpage}")
if not fb:
break
files += fb
fpage += 1
diff = get(f"/pulls/{n}.diff", raw=True).decode("utf-8", "replace")
merge_base = get(f"/pulls/{n}")["merge_base"]
trial = trial_level([f["filename"] for f in files])
level, triggers = review_level(n, files, diff, merge_base)
rows.append((n, trial, level, triggers))
print(f"#{n}\t{'ref' if n in REF39 else ' '}\t{trial}\t{level}\t{'; '.join(triggers)}")
for label, keep in (("39 reference PRs", lambda n: n in REF39), (f"{len(rows)} PRs #{first}-#{last}", lambda n: True)):
sel = [r for r in rows if keep(r[0])]
for col, name in ((1, "trial"), (2, "REVIEW.md")):
counts = {lvl: sum(1 for r in sel if r[col] == lvl) for lvl in ("bas", "normal", "haut")}
share = 100 * counts["haut"] / len(sel)
print(f"{label}, {name}: {counts} -> haut {share:.1f} %")
if __name__ == "__main__":
main()

39
src-tauri/Cargo.lock generated
View file

@ -100,15 +100,6 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
@ -935,17 +926,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@ -4049,9 +4029,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"once_cell",
"ring",
@ -4509,7 +4489,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simpl-result"
version = "0.16.0"
version = "0.17.2"
dependencies = [
"aes-gcm",
"argon2",
@ -5363,7 +5343,6 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
@ -7183,18 +7162,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.19"

View file

@ -1,6 +1,6 @@
[package]
name = "simpl-result"
version = "0.16.0"
version = "0.17.2"
description = "Personal finance management app"
license = "GPL-3.0-only"
authors = ["you"]
@ -23,7 +23,12 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
# Built without its default `zip` feature (#455): that feature only pulls
# `zip` on Windows and `tar` on Linux to unpack archived update packages, and
# ours are never archives (`createUpdaterArtifacts: true` serves the NSIS
# `.exe`, the `.deb` and the `.rpm` as they are). Restore it only if
# `createUpdaterArtifacts` becomes "v1Compatible" (docs/qa-update-cycle.md).
tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls"] }
tauri-plugin-process = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
@ -66,6 +71,9 @@ hmac = "0.12"
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
mockito = "1.6"
# MockRuntime for the profile database folder test (#390). The `test`
# feature adds no crate (`test = []` in tauri's manifest).
tauri = { version = "2", features = ["test"] }
[features]
# Dev-only escape hatch: when enabled, the SR_DEV_EDITION env var forces the

View file

@ -9,8 +9,9 @@
//! `Authorization`, `Accept`, and `User-Agent` headers.
//!
//! Database access pattern:
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
//! matching the existing `repair_migrations` helper in `profile_commands.rs`.
//! - All reads open the file `profile_db_path` resolves (`profile_commands.rs`):
//! the folder where tauri-plugin-sql keeps the databases, which is NOT
//! `app_data_dir` on Linux (#390).
//! - The frontend passes `db_filename` (the active profile DB), exactly
//! like it does for `repair_migrations` and `delete_profile_db`. Keeps
//! the active-profile resolution where it already lives (in TS) and
@ -22,8 +23,10 @@
use chrono::NaiveDate;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::Manager;
use crate::commands::license_commands::{remove_activation_token_if_unchanged_in, ACTIVATION_FILE};
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
// ---------------------------------------------------------------------------
@ -57,7 +60,8 @@ pub enum FetchPriceError {
/// 403 `license_revoked`: the license the activation token stands for was revoked.
/// Named for the user, never recorded as the state of the stored key: the token names
/// a license the client cannot tie to that key, and a data directory written by 0.15.0
/// or earlier can hold a valid key next to the token of a previous license (#365).
/// or earlier can hold a valid key next to the token of a previous license (#365). The
/// refused token is dropped instead (#367, [`fetch_price_in`]).
LicenseRevoked,
SymbolNotFound,
RateLimit { retry_after_s: u64 },
@ -97,34 +101,43 @@ fn base_url() -> String {
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}
/// Read the stored activation token from disk (raw JWT string).
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable.
fn read_stored_activation_token(app: &tauri::AppHandle) -> Result<String, FetchPriceError> {
let app_dir = app
.path()
.app_data_dir()
.map_err(|_| FetchPriceError::Auth)?;
let token_path = app_dir.join("activation.token");
std::fs::read_to_string(&token_path)
/// Read the activation token stored in `dir` (raw JWT string, trimmed).
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable: nothing is sent
/// then, and the message invites the user to activate this machine.
fn read_stored_activation_token_in(dir: &Path) -> Result<String, FetchPriceError> {
std::fs::read_to_string(dir.join(ACTIVATION_FILE))
.map(|s| s.trim().to_string())
.map_err(|_| FetchPriceError::Auth)
}
/// Core implementation — separated from the Tauri command so tests can inject
/// an arbitrary token string and base URL without touching the file system.
/// The price call as the command performs it, without `AppHandle` so tests run it in a
/// temporary directory and against a mock server: the stored activation token is read, then
/// sent to `api_base`.
///
/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price`
/// command is a thin wrapper that loads the token then delegates here. Tests
/// call this inner function directly with an explicit `api_base` to avoid
/// env-var races between concurrent test threads.
async fn fetch_price_with_token(
token: &str,
/// A 403 `license_revoked` condemns that token (#367): the license it was issued for is
/// revoked, so it will never fetch a price again, whatever key is stored. It is dropped and
/// the License card offers "Activate" again, but only while the file still holds it: an
/// activation that lands while the refused request is in flight writes a new token, which
/// stays (CWE-367). Best effort: the refusal reaches the user whatever happens to the file.
/// The revoked state of the stored key is never written from here: this route judges the
/// license of the token, which the client cannot tie to the key (#365).
pub(crate) async fn fetch_price_in(
dir: &Path,
symbol: &str,
date: &str,
api_base: &str,
) -> Result<PriceResponse, FetchPriceError> {
fetch_price_inner(token, symbol, date, &base_url()).await
let token = read_stored_activation_token_in(dir)?;
let result = fetch_price_inner(&token, symbol, date, api_base).await;
if matches!(result, Err(FetchPriceError::LicenseRevoked)) {
let _ = remove_activation_token_if_unchanged_in(dir, &token);
}
result
}
/// The HTTP call alone, separated so tests can inject an arbitrary token string and base URL
/// without touching the file system. Tests pass an explicit `api_base` to avoid env-var races
/// between concurrent test threads.
async fn fetch_price_inner(
token: &str,
symbol: &str,
@ -201,9 +214,10 @@ async fn fetch_price_inner(
/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
///
/// Reads the stored activation token, then calls `GET /v1/prices`. Returns a
/// serialized `FetchPriceError` JSON string on error so the JS layer can
/// `JSON.parse` and branch on `code`.
/// Reads the stored activation token, then calls `GET /v1/prices`; a token refused as issued
/// for a revoked license is dropped ([`fetch_price_in`], #367). Returns a serialized
/// `FetchPriceError` JSON string on error so the JS layer can `JSON.parse` and branch on
/// `code`.
///
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
@ -214,8 +228,11 @@ pub async fn fetch_price(
symbol: String,
date: String,
) -> Result<PriceResponse, String> {
let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?;
fetch_price_with_token(&token, &symbol, &date)
let dir = app
.path()
.app_data_dir()
.map_err(|_| price_error_to_string(&FetchPriceError::Auth))?;
fetch_price_in(&dir, &symbol, &date, &base_url())
.await
.map_err(|e| price_error_to_string(&e))
}
@ -242,16 +259,10 @@ pub fn compute_account_return(
let start_date = parse_iso_date(&period_start, "period_start")?;
let end_date = parse_iso_date(&period_end, "period_end")?;
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
let db_path = super::profile_commands::profile_db_path(&app, &db_filename)?;
if !db_path.exists() {
return Err(format!(
"Profile database not found: {}",
db_path.display()
));
// The file name only: the folder holds the OS user name.
return Err(format!("Profile database not found: {}", db_filename));
}
let conn = Connection::open(&db_path)
@ -385,7 +396,8 @@ fn read_cash_flows(
// Each test calls `fetch_price_inner` directly, passing the mock server URL
// as `api_base`. This avoids env-var races between concurrent test threads
// (all tokio tests share the same process) and bypasses the file-system
// activation token loading.
// activation token loading. The tests of the stored token (#367) call
// `fetch_price_in` instead, with a temporary data directory.
#[cfg(test)]
mod tests {
use super::*;
@ -590,4 +602,172 @@ mod tests {
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
assert_eq!(result.unwrap().symbol, "BTC");
}
// === The stored token, and a token refused as issued for a revoked license (#367) ==========
/// A data directory of its own for each test (test name + pid: cargo runs tests on parallel
/// threads), removed when the test ends, failed assertion included.
struct TestDir(std::path::PathBuf);
impl TestDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"simpl-resultat-price-test-{}-{}",
name,
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn write_token(&self, contents: &str) {
std::fs::write(self.0.join(ACTIVATION_FILE), contents).unwrap();
}
fn token(&self) -> Option<String> {
std::fs::read_to_string(self.0.join(ACTIVATION_FILE)).ok()
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
const REVOKED: &str = r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#;
/// `ok`, or the code the front reads from the error.
fn outcome(result: &Result<PriceResponse, FetchPriceError>) -> String {
match result {
Ok(_) => "ok".to_string(),
Err(e) => serde_json::from_str::<serde_json::Value>(&price_error_to_string(e)).unwrap()
["code"]
.as_str()
.unwrap()
.to_string(),
}
}
#[tokio::test]
async fn a_revoked_license_drops_the_token_it_refused() {
// The license of this token is revoked: it will never fetch a price again, and the card
// must offer "Activate". Written with a newline, as an older version may have left it,
// read from `ACTIVATION_FILE` and sent trimmed. Nothing is written in its place: the
// key's state is not this route's to record (#365).
let dir = TestDir::new("drops_revoked_token");
dir.write_token("token-of-a-revoked-license\n");
let mut server = mockito::Server::new_async().await;
let route = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.match_header("authorization", "Bearer token-of-a-revoked-license")
.with_status(403)
.with_header("content-type", "application/json")
.with_body(REVOKED)
.expect(1)
.create_async()
.await;
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
assert_eq!(outcome(&result), "license_revoked");
route.assert_async().await;
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 0, "the token gone, nothing written");
}
#[tokio::test]
async fn a_token_written_while_the_refused_request_was_in_flight_stays() {
// An activation lands while the refused request is in flight (CWE-367): the refusal is
// about the token that was sent, not the one written meanwhile.
let dir = TestDir::new("token_replaced_in_flight");
dir.write_token("refused-token");
let token_path = dir.0.join(ACTIVATION_FILE);
let mut server = mockito::Server::new_async().await;
let route = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.match_request(move |_request| {
std::fs::write(&token_path, "fresh-token").unwrap();
true
})
.with_status(403)
.with_header("content-type", "application/json")
.with_body(REVOKED)
.expect(1)
.create_async()
.await;
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
assert_eq!(outcome(&result), "license_revoked");
route.assert_async().await;
assert_eq!(dir.token().as_deref(), Some("fresh-token"));
}
#[tokio::test]
async fn no_other_answer_drops_the_token() {
// Only a revoked license condemns the token: a Base license, a token the server does
// not accept (the user is asked to activate), a missing symbol and every outage leave
// it as it was.
let rows: Vec<(usize, &str, &str)> = vec![
(
200,
r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
"ok",
),
(401, r#"{"error":{"code":"invalid_token","message":"x"}}"#, "auth"),
(403, r#"{"error":{"code":"premium_required","message":"x"}}"#, "premium_required"),
(403, "<html>Forbidden</html>", "premium_required"),
(404, r#"{"error":{"code":"symbol_not_found","message":"x"}}"#, "symbol_not_found"),
(
429,
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
"rate_limit",
),
(502, r#"{"error":{"code":"provider_unavailable","message":"x"}}"#, "provider_unavailable"),
];
for (status, body, expected) in rows {
let dir = TestDir::new("keeps_token");
dir.write_token("token");
let mut server = mockito::Server::new_async().await;
let route = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await;
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
assert_eq!(outcome(&result), expected, "{} {}", status, body);
route.assert_async().await;
assert_eq!(dir.token().as_deref(), Some("token"), "{} {}", status, body);
}
// Offline. Port 1 on loopback: nothing listens.
let dir = TestDir::new("keeps_token_offline");
dir.write_token("token");
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", "http://127.0.0.1:1").await;
assert_eq!(outcome(&result), "network");
assert_eq!(dir.token().as_deref(), Some("token"));
}
#[tokio::test]
async fn without_a_token_nothing_is_sent_and_the_answer_is_auth() {
// The rest of a batch once the token is gone: the message invites to activate.
let dir = TestDir::new("no_token");
let mut server = mockito::Server::new_async().await;
let any_request = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
assert_eq!(outcome(&result), "auth");
any_request.assert_async().await;
}
}

View file

@ -1,3 +1,4 @@
use chrono::{Datelike, Months, NaiveDate};
use encoding_rs::{UTF_8, WINDOWS_1252, ISO_8859_15};
use serde::Serialize;
use sha2::{Digest, Sha256};
@ -21,14 +22,39 @@ pub struct ScannedSource {
pub files: Vec<ScannedFile>,
}
/// Result of a scan of the import folder: the sources (one per direct
/// subfolder) and the names of the statement files placed directly at the
/// root, which no source reads and the Import page names in a warning (#427).
#[derive(Debug, Serialize, Clone)]
pub struct ImportFolderScan {
pub sources: Vec<ScannedSource>,
pub root_files: Vec<String>,
}
/// True when the file name has a statement extension (`csv` or `txt`, any
/// case), the same rule for the root and for the subfolders.
fn has_statement_extension(path: &Path) -> bool {
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
ext == "csv" || ext == "txt"
}
/// Scans the import folder: each non-hidden direct subfolder is a source with
/// its direct CSV/TXT files, sorted by name. CSV/TXT files placed directly at
/// the root are read by no source; their names (non-hidden, sorted) are
/// returned in `root_files` so the Import page can warn about them (#427).
#[tauri::command]
pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, String> {
pub fn scan_import_folder(folder_path: String) -> Result<ImportFolderScan, String> {
let root = Path::new(&folder_path);
if !root.is_dir() {
return Err(format!("Folder does not exist: {}", folder_path));
}
let mut sources: Vec<ScannedSource> = Vec::new();
let mut root_files: Vec<String> = Vec::new();
let entries = fs::read_dir(root).map_err(|e| format!("Cannot read folder: {}", e))?;
@ -37,6 +63,16 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
let path = entry.path();
if !path.is_dir() {
if path.is_file() && has_statement_extension(&path) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !name.starts_with('.') {
root_files.push(name);
}
}
continue;
}
@ -59,13 +95,7 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
continue;
}
let ext = file_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if ext != "csv" && ext != "txt" {
if !has_statement_extension(file_path) {
continue;
}
@ -104,8 +134,12 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
}
sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name));
root_files.sort();
Ok(sources)
Ok(ImportFolderScan {
sources,
root_files,
})
}
#[tauri::command]
@ -172,6 +206,91 @@ pub async fn pick_folder(app: tauri::AppHandle) -> Result<Option<String>, String
Ok(folder.map(|f| f.to_string()))
}
/// Folder names the onboarding samples are written under — the same in FR and
/// EN (decided in planning), so the scan shows one source per bank whatever the
/// UI language.
const SAMPLE_DESJARDINS_FOLDER: &str = "Exemple Desjardins";
const SAMPLE_TANGERINE_FOLDER: &str = "Exemple Tangerine";
/// Sample statements embedded at compile time. The paths are relative to
/// `src-tauri/src/commands/`; the same files are read by vitest, so the Rust
/// command and the detection test cannot drift apart.
const SAMPLE_DESJARDINS_TEMPLATE: &str =
include_str!("../../../src/__fixtures__/samples/exemple-desjardins.csv");
const SAMPLE_TANGERINE_TEMPLATE: &str =
include_str!("../../../src/__fixtures__/samples/exemple-tangerine.csv");
/// Write the two onboarding sample statements (a Desjardins chequing account and
/// a Tangerine credit card, both synthetic) into `import_folder`, one sub-folder
/// each, so that the very next scan of the import folder shows two sources a
/// new user can import right away.
///
/// `today` is the webview's local date as `YYYY-MM-DD`; chrono is compiled
/// without `clock`, so the command never reads the system clock and stays
/// deterministic. The templates carry `{{M1}}` / `{{M2}}` tokens, rendered as
/// `MM/YYYY` of the two complete months before `today` (`{{M1}}` the most
/// recent), and each file is named `releve-YYYY-MM.csv` after `{{M1}}`.
///
/// Refuses to run when `import_folder` is not a directory, and when either
/// sample folder already exists — checked BEFORE anything is written, so an
/// existing folder is never touched. The command only ever receives the import
/// folder and a date: the names of what it writes are fixed here. Returns the
/// two folder NAMES (not paths).
#[tauri::command]
pub fn create_sample_import_folders(
import_folder: String,
today: String,
) -> Result<Vec<String>, String> {
let today = NaiveDate::parse_from_str(&today, "%Y-%m-%d")
.map_err(|e| format!("Invalid date '{}': {}", today, e))?;
let first_of_month = today
.with_day(1)
.ok_or_else(|| "Cannot compute the first day of the month".to_string())?;
let m1 = first_of_month
.checked_sub_months(Months::new(1))
.ok_or_else(|| "Cannot compute the previous month".to_string())?;
let m2 = first_of_month
.checked_sub_months(Months::new(2))
.ok_or_else(|| "Cannot compute the month before the previous one".to_string())?;
let root = Path::new(&import_folder);
if !root.is_dir() {
return Err(format!("Folder does not exist: {}", import_folder));
}
let samples: [(&str, &str); 2] = [
(SAMPLE_DESJARDINS_FOLDER, SAMPLE_DESJARDINS_TEMPLATE),
(SAMPLE_TANGERINE_FOLDER, SAMPLE_TANGERINE_TEMPLATE),
];
// Both existence checks run before the first write: a user who already has
// one of the folders keeps it untouched and gets neither.
for (folder_name, _) in &samples {
if root.join(folder_name).exists() {
return Err(format!("Folder already exists: {}", folder_name));
}
}
let m1_token = m1.format("%m/%Y").to_string();
let m2_token = m2.format("%m/%Y").to_string();
let filename = format!("releve-{}.csv", m1.format("%Y-%m"));
let mut created: Vec<String> = Vec::with_capacity(samples.len());
for (folder_name, template) in &samples {
let folder = root.join(folder_name);
fs::create_dir(&folder)
.map_err(|e| format!("Cannot create folder {}: {}", folder_name, e))?;
let content = template
.replace("{{M1}}", &m1_token)
.replace("{{M2}}", &m2_token);
fs::write(folder.join(&filename), content)
.map_err(|e| format!("Cannot write {}/{}: {}", folder_name, filename, e))?;
created.push((*folder_name).to_string());
}
Ok(created)
}
fn decode_bytes(bytes: &[u8], encoding: &str) -> Result<String, String> {
// Strip BOM if present
let bytes = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
@ -207,3 +326,228 @@ fn decode_bytes(bytes: &[u8], encoding: &str) -> Result<String, String> {
}
}
}
#[cfg(test)]
mod sample_folder_tests {
use super::*;
use std::path::PathBuf;
/// One scratch directory per CASE: the cases of this binary run in parallel
/// inside a single process, so `process::id()` alone would make them share
/// — and trip over — the same folder.
fn sandbox(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"simpl-resultat-samples-{}-{}",
std::process::id(),
name
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("sandbox created");
dir
}
fn cleanup(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
fn as_arg(path: &Path) -> String {
path.to_string_lossy().to_string()
}
/// Every `DD/MM/YYYY` date of a rendered file must fall in one of the two
/// expected months — no token left, no third month.
fn assert_dates_in(content: &str, months: &[&str]) {
assert!(!content.contains("{{"), "token left in {}", content);
for line in content.lines().skip(1) {
let date = line.split([';', ',']).next().unwrap();
assert_eq!(date.len(), 10, "unexpected date {}", date);
let month_year = &date[3..];
assert!(
months.contains(&month_year),
"date {} is outside {:?}",
date,
months
);
}
}
#[test]
fn refuses_a_missing_import_folder() {
let dir = sandbox("missing");
let missing = dir.join("nope");
let result = create_sample_import_folders(as_arg(&missing), "2026-09-25".to_string());
cleanup(&dir);
let err = result.unwrap_err();
assert!(err.contains("does not exist"), "{}", err);
}
#[test]
fn refuses_when_desjardins_folder_exists_and_writes_nothing() {
let dir = sandbox("existing");
fs::create_dir(dir.join(SAMPLE_DESJARDINS_FOLDER)).unwrap();
let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string());
let tangerine_created = dir.join(SAMPLE_TANGERINE_FOLDER).exists();
let desjardins_empty = fs::read_dir(dir.join(SAMPLE_DESJARDINS_FOLDER))
.unwrap()
.next()
.is_none();
cleanup(&dir);
let err = result.unwrap_err();
assert!(err.contains(SAMPLE_DESJARDINS_FOLDER), "{}", err);
assert!(!tangerine_created, "Exemple Tangerine must not be created");
assert!(desjardins_empty, "the existing folder must stay untouched");
}
#[test]
fn writes_both_folders_for_the_two_previous_months() {
let dir = sandbox("success");
let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string());
let read = |folder: &str| {
fs::read_to_string(dir.join(folder).join("releve-2026-08.csv"))
};
let desjardins = read(SAMPLE_DESJARDINS_FOLDER);
let tangerine = read(SAMPLE_TANGERINE_FOLDER);
let desjardins_count = fs::read_dir(dir.join(SAMPLE_DESJARDINS_FOLDER)).unwrap().count();
let tangerine_count = fs::read_dir(dir.join(SAMPLE_TANGERINE_FOLDER)).unwrap().count();
cleanup(&dir);
assert_eq!(
result.unwrap(),
vec![
SAMPLE_DESJARDINS_FOLDER.to_string(),
SAMPLE_TANGERINE_FOLDER.to_string()
]
);
let desjardins = desjardins.expect("Desjardins file written");
let tangerine = tangerine.expect("Tangerine file written");
assert_eq!(desjardins_count, 1);
assert_eq!(tangerine_count, 1);
assert_eq!(desjardins.lines().count(), 41);
assert_eq!(tangerine.lines().count(), 41);
assert_dates_in(&desjardins, &["08/2026", "07/2026"]);
assert_dates_in(&tangerine, &["08/2026", "07/2026"]);
assert!(desjardins.contains("08/2026") && desjardins.contains("07/2026"));
assert!(tangerine.contains("08/2026") && tangerine.contains("07/2026"));
}
#[test]
fn refuses_an_unparseable_date_before_writing() {
let dir = sandbox("bad-date");
let result = create_sample_import_folders(as_arg(&dir), "pas-une-date".to_string());
let anything_written = fs::read_dir(&dir).unwrap().next().is_some();
cleanup(&dir);
let err = result.unwrap_err();
assert!(err.contains("Invalid date"), "{}", err);
assert!(!anything_written);
}
#[test]
fn crosses_the_year_boundary() {
let dir = sandbox("year-boundary");
let result = create_sample_import_folders(as_arg(&dir), "2027-01-15".to_string());
let desjardins =
fs::read_to_string(dir.join(SAMPLE_DESJARDINS_FOLDER).join("releve-2026-12.csv"));
let tangerine =
fs::read_to_string(dir.join(SAMPLE_TANGERINE_FOLDER).join("releve-2026-12.csv"));
cleanup(&dir);
assert!(result.is_ok());
let desjardins = desjardins.expect("Desjardins file named after December");
let tangerine = tangerine.expect("Tangerine file named after December");
assert_dates_in(&desjardins, &["12/2026", "11/2026"]);
assert_dates_in(&tangerine, &["12/2026", "11/2026"]);
assert!(desjardins.contains("12/2026") && desjardins.contains("11/2026"));
assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026"));
}
}
#[cfg(test)]
mod scan_import_folder_tests {
use super::*;
use std::path::PathBuf;
/// One scratch directory per case: the cases run in parallel in one process.
fn sandbox(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"simpl-resultat-scan-{}-{}",
std::process::id(),
name
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("sandbox created");
dir
}
fn touch(path: &Path) {
fs::write(path, "Date;Montant\n").expect("file written");
}
fn scan(dir: &Path) -> ImportFolderScan {
scan_import_folder(dir.to_string_lossy().to_string()).expect("scan succeeds")
}
#[test]
fn lists_the_root_statement_files_sorted_by_name() {
let dir = sandbox("root-sorted");
touch(&dir.join("b.csv"));
touch(&dir.join("a.csv"));
touch(&dir.join("c.TXT"));
fs::create_dir_all(dir.join("visa")).unwrap();
touch(&dir.join("visa").join("releve.csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["a.csv", "b.csv", "c.TXT"]);
assert_eq!(result.sources.len(), 1);
assert_eq!(result.sources[0].folder_name, "visa");
assert_eq!(result.sources[0].files.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn keeps_subfolder_files_out_of_root_files() {
let dir = sandbox("subfolder-only");
fs::create_dir_all(dir.join("cheques")).unwrap();
touch(&dir.join("cheques").join("a.csv"));
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources[0].files[0].filename, "a.csv");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn ignores_other_extensions_and_hidden_files_at_the_root() {
let dir = sandbox("ignored");
touch(&dir.join("notes.pdf"));
touch(&dir.join("noextension"));
touch(&dir.join(".hidden.csv"));
touch(&dir.join("kept.Csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["kept.Csv"]);
assert!(result.sources.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn returns_no_root_file_for_a_root_without_files() {
let dir = sandbox("empty-root");
fs::create_dir_all(dir.join("visa")).unwrap();
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn serializes_root_files_in_snake_case() {
let dir = sandbox("serialize");
touch(&dir.join("a.csv"));
let json = serde_json::to_value(scan(&dir)).unwrap();
assert_eq!(json["root_files"], serde_json::json!(["a.csv"]));
assert!(json["sources"].is_array());
let _ = fs::remove_dir_all(&dir);
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,10 +3,12 @@ use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384};
use std::fs;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq;
use tauri::Manager;
use crate::database;
use crate::database::profile_db;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
@ -32,6 +34,49 @@ fn get_profiles_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, Strin
Ok(app_dir.join("profiles.json"))
}
/// Folder where tauri-plugin-sql keeps every profile database.
///
/// The plugin opens `sqlite:<file>` under `app_config_dir()` (tauri-plugin-sql
/// 2.3.2, `wrapper.rs`: `DbPool::connect` then `path_mapper`), not under
/// `app_data_dir()`, where `profiles.json` and the licence files live. Both
/// are the same folder on Windows and macOS, not on Linux (`~/.config/<id>`
/// and `~/.local/share/<id>`): until #390, every command that looked for a
/// profile database under `app_data_dir()` found nothing on Linux.
pub(crate) fn profile_db_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Result<PathBuf, String> {
app.path()
.app_config_dir()
.map_err(|e| format!("Cannot get app config dir: {}", e))
}
/// Path of a profile database, from the bare file name the webview passes.
pub(crate) fn profile_db_path<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
db_filename: &str,
) -> Result<PathBuf, String> {
profile_db_path_in(&profile_db_dir(app)?, db_filename)
}
/// Accept only the two shapes the app ever gives a profile database:
/// `simpl_resultat.db` and `profile_<hex>.db` (ProfileContext, since v0.3.0).
/// `Path::join` with an absolute value would replace the folder, and these
/// commands write and delete.
pub(crate) fn profile_db_path_in(dir: &Path, db_filename: &str) -> Result<PathBuf, String> {
if !is_profile_db_filename(db_filename) {
return Err("invalid_profile_db_filename".to_string());
}
Ok(dir.join(db_filename))
}
fn is_profile_db_filename(name: &str) -> bool {
if name == database::DEFAULT_PROFILE_DB {
return true;
}
match name.strip_prefix("profile_").and_then(|rest| rest.strip_suffix(".db")) {
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit()),
None => false,
}
}
fn make_default_config() -> ProfilesConfig {
let now = chrono_now();
let default_id = "default".to_string();
@ -92,35 +137,64 @@ pub fn save_profiles(app: tauri::AppHandle, config: ProfilesConfig) -> Result<()
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))
}
/// Delete a secondary profile's database, with its SQLite side files and the
/// copies a catch-up took (#390): the guide promises that deleting a profile
/// deletes all its data.
#[tauri::command]
pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(), String> {
if db_filename == "simpl_resultat.db" {
if db_filename == database::DEFAULT_PROFILE_DB {
return Err("Cannot delete the default profile database".to_string());
}
let db_path = profile_db_path(&app, &db_filename)?;
delete_profile_files(&db_path, &db_filename)
}
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
if db_path.exists() {
fs::remove_file(&db_path)
.map_err(|e| format!("Cannot delete database file: {}", e))?;
fn delete_profile_files(db_path: &Path, db_filename: &str) -> Result<(), String> {
for suffix in ["", "-wal", "-shm", "-journal"] {
let path = db_path.with_file_name(format!("{}{}", db_filename, suffix));
if path.exists() {
fs::remove_file(&path).map_err(|e| format!("Cannot delete database file: {}", e))?;
}
}
if let Some(dir) = db_path.parent() {
profile_db::remove_backups(
&dir.join(profile_db::MIGRATION_BACKUP_DIR),
db_filename.trim_end_matches(".db"),
None,
)?;
}
Ok(())
}
/// SQL that creates a secondary profile, run by the webview through the
/// plugin (`initializeNewProfileDb`).
#[tauri::command]
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
// Brand-new profiles ship with the v1 IPC-aligned category taxonomy: the
// consolidated schema bakes the v1 seed (categories + keywords + the
// categories_schema_version='v1' preference) directly. The legacy v2 seed
// migration still runs first because tauri-plugin-sql applies every
// declared migration on `Database.load`; the consolidated script then
// deletes the v2 rows and re-inserts the v1 rows in the 1000+ id range.
Ok(vec![database::CONSOLIDATED_SCHEMA.to_string()])
// The consolidated schema is the whole database: tauri-plugin-sql runs no
// migration for a `profile_<id>.db`, only the default profile's URL is
// registered (lib.rs). It carries the v1 IPC-aligned taxonomy (categories,
// keywords, categories_schema_version='v1'). The second statement writes
// the complete migration history, so later versions reach the profile
// through `prepare_profile_db` without inferring anything (#390).
Ok(vec![
database::CONSOLIDATED_SCHEMA.to_string(),
profile_db::history_sql(&crate::migrations()),
])
}
/// Bring a profile database up to date and repair legacy values before the
/// webview opens it (#390, ADR 0021). Secondary profiles catch up on the
/// migrations they missed; every profile gets its text manual-categorization
/// flags repaired. Sync on purpose: Tauri runs sync commands on the main
/// thread, which serializes two opens of the same profile (React StrictMode
/// mounts twice in development).
#[tauri::command]
pub fn prepare_profile_db(
app: tauri::AppHandle,
db_filename: String,
) -> Result<profile_db::ProfilePreparation, String> {
let db_path = profile_db_path(&app, &db_filename)?;
profile_db::prepare_at(&db_path, &db_filename, &crate::migrations())
}
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
@ -225,11 +299,7 @@ fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
#[tauri::command]
pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> {
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
let db_path = profile_db_path(&app, &db_filename)?;
if !db_path.exists() {
return Ok(false);
@ -375,4 +445,91 @@ mod tests {
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(original, decoded);
}
// -- profile database location (#390) ------------------------------------
#[test]
fn profile_databases_live_where_the_sql_plugin_opens_them() {
// tauri-plugin-sql resolves `sqlite:<file>` under app_config_dir().
// On Linux (the CI platform) that differs from app_data_dir(), so
// going back to the data folder turns this red.
let app = tauri::test::mock_app();
let path = profile_db_path(app.handle(), "profile_0a1b2c3d.db").unwrap();
assert_eq!(path.parent(), Some(app.path().app_config_dir().unwrap().as_path()));
}
#[test]
fn profile_db_path_accepts_the_two_shapes_the_app_uses() {
let dir = Path::new("/profiles");
assert_eq!(
profile_db_path_in(dir, "simpl_resultat.db"),
Ok(dir.join("simpl_resultat.db"))
);
assert_eq!(
profile_db_path_in(dir, "profile_0a1b2c3d.db"),
Ok(dir.join("profile_0a1b2c3d.db"))
);
}
#[test]
fn profile_db_path_refuses_anything_else() {
let dir = Path::new("/profiles");
for name in [
"",
"/etc/passwd",
"../simpl_resultat.db",
"profile_.db",
"profile_0a1b/../x.db",
"profile_0a1b2c3d.sqlite",
"profile_0a1b2c3d.db.bak",
"PROFILE_0a1b2c3d.db",
"profile_xyz.db",
"C:\\Users\\x\\profile_0a1b2c3d.db",
] {
assert_eq!(
profile_db_path_in(dir, name),
Err("invalid_profile_db_filename".to_string()),
"{:?}",
name
);
}
}
#[test]
fn deleting_a_profile_removes_its_files_and_only_its_files() {
let dir = profile_db::TestDir::new("delete");
let backups = dir.join(profile_db::MIGRATION_BACKUP_DIR);
fs::create_dir_all(&backups).unwrap();
for name in [
"profile_0a1b2c3d.db",
// The only side file this app produces: sqlx leaves SQLite's
// rollback journal by default, `-wal`/`-shm` come with WAL.
"profile_0a1b2c3d.db-journal",
"profile_0a1b2c3d.db-wal",
"profile_0a1b2c3d.db-shm",
"profile_99999999.db",
] {
fs::write(dir.join(name), b"x").unwrap();
}
for name in ["profile_0a1b2c3d-before-v7.db", "profile_99999999-before-v17.db"] {
fs::write(backups.join(name), b"x").unwrap();
}
delete_profile_files(&dir.join("profile_0a1b2c3d.db"), "profile_0a1b2c3d.db").unwrap();
let mut left: Vec<String> = fs::read_dir(&dir)
.unwrap()
.chain(fs::read_dir(&backups).unwrap())
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
left.sort();
assert_eq!(
left,
vec![
"migration-backups".to_string(),
"profile_99999999-before-v17.db".to_string(),
"profile_99999999.db".to_string(),
]
);
}
}

View file

@ -15,12 +15,11 @@ CREATE TABLE IF NOT EXISTS import_sources (
column_mapping TEXT NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
-- Full import format (migration v17). These four columns are INERT on the
-- production path: this script runs AFTER tauri-plugin-sql has applied every
-- migration and only uses CREATE TABLE IF NOT EXISTS, so a brand-new profile
-- actually receives them from v17. They are mirrored here to keep this file
-- the tested reference definition -- a parity test compares it against the
-- v1->v17 chain so the DEFAULT and CHECK of the two cannot drift apart.
-- Full import format (migration v17). A secondary profile receives these
-- four columns from this script, not from v17: tauri-plugin-sql runs no
-- migration on a `profile_<id>.db` (#390, ADR 0021). A parity test compares
-- this definition with the v1->v17 chain so the DEFAULT and CHECK of the
-- two cannot drift apart.
-- `amount_mode` admits 'absolute_indicator' from the start so the third mode
-- can ship without another migration. `template_id` is a provenance tag
-- only, never re-read as format: the eight format columns above are
@ -374,9 +373,9 @@ INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_pro
-- ============================================================================
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
-- ----------------------------------------------------------------------------
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
-- the historical v2 seed migration on this fresh DB before this script
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
-- Reset any pre-existing category data. Defensive only: tauri-plugin-sql runs
-- no migration on a secondary profile's database, so no v2 seed precedes this
-- script (#390). Keywords/categories are wiped and re-inserted with the v1 IDs
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
-- ============================================================================
DELETE FROM keywords;

View file

@ -0,0 +1,824 @@
-- Consolidated schema for new profile databases
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
-- use the new standard. Existing profiles keep their v2 seed and are only
-- tagged via the migration (categories_schema_version = 'v2').
-- Used ONLY for initializing new profile databases (not for the default profile).
CREATE TABLE IF NOT EXISTS import_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
column_mapping TEXT NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS imported_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
UNIQUE(source_id, filename)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
color TEXT,
icon TEXT,
type TEXT NOT NULL DEFAULT 'expense',
is_active INTEGER NOT NULL DEFAULT 1,
is_inputable INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
i18n_key TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
normalized_name TEXT NOT NULL,
category_id INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
category_id INTEGER NOT NULL,
supplier_id INTEGER,
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
UNIQUE(keyword, category_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
category_id INTEGER,
supplier_id INTEGER,
source_id INTEGER,
file_id INTEGER,
original_description TEXT,
notes TEXT,
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
is_split INTEGER NOT NULL DEFAULT 0,
parent_transaction_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS adjustment_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
adjustment_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
description TEXT,
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
amount REAL NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(category_id, year, month)
);
CREATE TABLE IF NOT EXISTS budget_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS budget_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(template_id, category_id)
);
CREATE TABLE IF NOT EXISTS import_config_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
column_mapping TEXT NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single',
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- ============================================================================
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
-- ============================================================================
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
-- New profiles created from this consolidated schema get the balance feature
-- preinstalled without needing to replay v9 separately.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
i18n_key TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0,
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')),
-- User-facing override for the category label (migration v12). When set,
-- the UI shows this verbatim; otherwise it falls back to t(i18n_key).
custom_label TEXT
);
CREATE TABLE IF NOT EXISTS balance_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
balance_category_id INTEGER NOT NULL,
name TEXT NOT NULL,
symbol TEXT,
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME,
-- Fiscal envelope / tax shelter (migration v12). NULL = no envelope (e.g.
-- a chequing account or crypto wallet). NOT an automobile type.
vehicle_type TEXT CHECK(vehicle_type IS NULL OR vehicle_type IN ('unregistered','tfsa','rrsp','rrif','fhsa','resp')),
-- Entry mode (migration v15). 'simple' = one denormalized value per
-- snapshot line; 'detailed' = a basket of per-security holdings (see
-- balance_snapshot_holdings). Accounts under a priced category are backfilled
-- to 'detailed' below. `detailed_since` is the authoritative pivot date from
-- which detailed entry is expected — NULL until the conversion flow sets it.
kind TEXT NOT NULL DEFAULT 'simple' CHECK (kind IN ('simple','detailed')),
detailed_since DATE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS balance_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date DATE NOT NULL UNIQUE,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
quantity REAL,
unit_price REAL,
value REAL NOT NULL,
price_source TEXT,
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
UNIQUE(snapshot_id, account_id),
CHECK (
(quantity IS NULL AND unit_price IS NULL)
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS balance_account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
UNIQUE(transaction_id, account_id)
);
-- Détail par titre (Bilan Étape 2, migration v14). Kept in sync with
-- `balance_holdings_schema.sql` (the source of truth applied by Migration v14
-- in lib.rs). balance_securities is the instrument catalogue (symbol normalized
-- upper/trim, COLLATE NOCASE UNIQUE); balance_snapshot_holdings is the per-title
-- breakdown of a detailed account's snapshot line.
CREATE TABLE IF NOT EXISTS balance_securities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL COLLATE NOCASE UNIQUE,
name TEXT,
currency TEXT NOT NULL DEFAULT 'CAD',
asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS balance_snapshot_holdings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE,
security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT,
quantity REAL NOT NULL,
unit_price REAL NOT NULL,
value REAL NOT NULL,
book_cost REAL,
price_source TEXT,
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (snapshot_line_id, security_id)
);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id);
-- Asset classes only (Bilan axe véhicule, Étape 1). The former `tfsa` / `rrsp`
-- seeds were fiscal envelopes, not asset classes — they now live on the account
-- as `vehicle_type` (see balance_accounts.vehicle_type). New profiles ship with
-- exactly 5 asset classes: Liquidités, Fonds/FNB, Actions, Crypto, Autres.
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
('other', 'balance.category.other', 'simple', 50, 1, NULL),
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so
-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on
-- balance_accounts) — once created they are indistinguishable from
-- user-created accounts and can be renamed/archived freely. Existing profiles
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
--
-- Bilan axe véhicule (Étape 1): the CELI / REER starters are no longer linked
-- to a `tfsa` / `rrsp` category (those seeds are gone). They now attach to the
-- `other` asset class and carry the envelope in `vehicle_type`. Linking them to
-- a deleted key would make the subselect return NULL → NOT NULL violation →
-- broken new-profile init, so the asset class MUST resolve to `other`.
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type) VALUES
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1, NULL),
((SELECT id FROM balance_categories WHERE key = 'other'), 'CELI', 'CAD', 1, 'tfsa'),
((SELECT id FROM balance_categories WHERE key = 'other'), 'REER', 'CAD', 1, 'rrsp'),
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1, NULL);
-- Détail par titre (migration v15 mirror): accounts under a priced category are
-- detailed-entry by default. The 4 starters above all sit under simple asset
-- classes (cash / other), so this is currently a no-op — kept at parity with the
-- v15 backfill so any future priced starter is stamped correctly.
UPDATE balance_accounts SET kind = 'detailed'
WHERE balance_category_id IN (SELECT id FROM balance_categories WHERE kind = 'priced');
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
-- Suppress StarterAccountsModal on first /balance visit for new profiles
-- (Issue #179). The 4 starter accounts are already seeded above, so the
-- modal would only show 4 collision rows with no actionable choice. Pre-
-- writing the pref skips that briefly-empty UX entirely. Suggestion S1
-- from PR #185 review (#187).
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_proposed', '{"shown_at":"seed","accepted":[]}');
-- ============================================================================
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
-- ----------------------------------------------------------------------------
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
-- the historical v2 seed migration on this fresh DB before this script
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
-- ============================================================================
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
-- 1000 — Revenus
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
-- 1100 — Alimentation
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
-- 1200 — Logement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
-- 1300 — Ménage & ameublement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
-- 1400 — Vêtements & chaussures
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
-- 1500 — Transport
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
-- 1600 — Santé & soins personnels
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
-- 1700 — Loisirs, formation & lecture
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
-- 1800 — Boissons, tabac & cannabis
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
-- 1900 — Finances & obligations
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
-- 1950 — Transferts & placements
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
-- ============================================================================
-- Keywords — Canadian suppliers (150+ entries)
-- ============================================================================
-- Alimentation > Épicerie régulière (1111)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
-- Épicerie > Boucherie (1112)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
-- Épicerie > Boulangerie (1113)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
-- Épicerie > Dépanneur (1114)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
-- Restauration > Restaurant (1121)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
-- Restauration > Café (1122)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
-- Restauration > Fast food (1123)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
-- Restauration > Livraison (1124)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
-- Logement > Hypothèque (1212)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
-- Logement > Taxes municipales (1213)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
-- Électricité (1221)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
-- Gaz (1222)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
-- Internet (1231)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
-- Mobile (1232)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
-- Entretien maison (1241)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
-- Matériaux & outils (1243)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
-- Assurance habitation (1250)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
-- Meubles (1311)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
-- Électroménagers (1312)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
-- Décoration (1313)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
-- Vêtements (1410)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
-- Transport — Essence (1512)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
-- Permis / SAAQ (1514)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
-- Transport public — autobus/métro (1521)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
-- Train de banlieue (1522)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
-- Taxi / Uber (1523)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
-- Avion (1531)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
-- Hébergement (1533)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
-- Pharmacie (1611)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
-- Thérapies (1615)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
-- Cinéma & spectacles (1711)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
-- Jeux vidéo (1712)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
-- Streaming vidéo (1713)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
-- Streaming musique (1714)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
-- Jouets & hobbies (1715)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
-- Équipement sportif (1722)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
-- Parcs & activités (1723)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
-- Lecture (1741)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
-- Animaux — nourriture (1751)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
-- Alcool (1810)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
-- Cannabis (1820)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
-- Frais bancaires (1911)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
-- Impôts (1921, 1922)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
-- Dons de charité (1931)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
-- Cotisations professionnelles (1932)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
-- Cadeaux (1940)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
-- Divers (1946) — catch-all marketplace
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
-- Placements (1964)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
-- Paie (1011)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);

View file

@ -0,0 +1,184 @@
-- Consolidated schema for new profile databases
-- This file bakes in the base schema + all migrations (v3-v6)
-- Used ONLY for initializing new profile databases (not for the default profile)
CREATE TABLE IF NOT EXISTS import_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
column_mapping TEXT NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS imported_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
UNIQUE(source_id, filename)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
color TEXT,
icon TEXT,
type TEXT NOT NULL DEFAULT 'expense',
is_active INTEGER NOT NULL DEFAULT 1,
is_inputable INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
normalized_name TEXT NOT NULL,
category_id INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
category_id INTEGER NOT NULL,
supplier_id INTEGER,
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
UNIQUE(keyword, category_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
category_id INTEGER,
supplier_id INTEGER,
source_id INTEGER,
file_id INTEGER,
original_description TEXT,
notes TEXT,
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
is_split INTEGER NOT NULL DEFAULT 0,
parent_transaction_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS adjustment_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
adjustment_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
description TEXT,
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
amount REAL NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(category_id, year, month)
);
CREATE TABLE IF NOT EXISTS budget_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS budget_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(template_id, category_id)
);
CREATE TABLE IF NOT EXISTS import_config_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
column_mapping TEXT NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single',
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- Default preferences
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');

View file

@ -0,0 +1,657 @@
-- Consolidated schema for new profile databases
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
-- use the new standard. Existing profiles keep their v2 seed and are only
-- tagged via the migration (categories_schema_version = 'v2').
-- Used ONLY for initializing new profile databases (not for the default profile).
CREATE TABLE IF NOT EXISTS import_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
column_mapping TEXT NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS imported_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
UNIQUE(source_id, filename)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
color TEXT,
icon TEXT,
type TEXT NOT NULL DEFAULT 'expense',
is_active INTEGER NOT NULL DEFAULT 1,
is_inputable INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
i18n_key TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
normalized_name TEXT NOT NULL,
category_id INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
category_id INTEGER NOT NULL,
supplier_id INTEGER,
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
UNIQUE(keyword, category_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
category_id INTEGER,
supplier_id INTEGER,
source_id INTEGER,
file_id INTEGER,
original_description TEXT,
notes TEXT,
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
is_split INTEGER NOT NULL DEFAULT 0,
parent_transaction_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS adjustment_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
adjustment_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
description TEXT,
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
amount REAL NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(category_id, year, month)
);
CREATE TABLE IF NOT EXISTS budget_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS budget_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(template_id, category_id)
);
CREATE TABLE IF NOT EXISTS import_config_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
column_mapping TEXT NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single',
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
-- ============================================================================
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
-- ----------------------------------------------------------------------------
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
-- the historical v2 seed migration on this fresh DB before this script
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
-- ============================================================================
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
-- 1000 — Revenus
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
-- 1100 — Alimentation
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
-- 1200 — Logement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
-- 1300 — Ménage & ameublement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
-- 1400 — Vêtements & chaussures
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
-- 1500 — Transport
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
-- 1600 — Santé & soins personnels
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
-- 1700 — Loisirs, formation & lecture
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
-- 1800 — Boissons, tabac & cannabis
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
-- 1900 — Finances & obligations
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
-- 1950 — Transferts & placements
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
-- ============================================================================
-- Keywords — Canadian suppliers (150+ entries)
-- ============================================================================
-- Alimentation > Épicerie régulière (1111)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
-- Épicerie > Boucherie (1112)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
-- Épicerie > Boulangerie (1113)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
-- Épicerie > Dépanneur (1114)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
-- Restauration > Restaurant (1121)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
-- Restauration > Café (1122)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
-- Restauration > Fast food (1123)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
-- Restauration > Livraison (1124)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
-- Logement > Hypothèque (1212)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
-- Logement > Taxes municipales (1213)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
-- Électricité (1221)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
-- Gaz (1222)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
-- Internet (1231)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
-- Mobile (1232)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
-- Entretien maison (1241)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
-- Matériaux & outils (1243)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
-- Assurance habitation (1250)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
-- Meubles (1311)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
-- Électroménagers (1312)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
-- Décoration (1313)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
-- Vêtements (1410)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
-- Transport — Essence (1512)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
-- Permis / SAAQ (1514)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
-- Transport public — autobus/métro (1521)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
-- Train de banlieue (1522)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
-- Taxi / Uber (1523)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
-- Avion (1531)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
-- Hébergement (1533)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
-- Pharmacie (1611)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
-- Thérapies (1615)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
-- Cinéma & spectacles (1711)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
-- Jeux vidéo (1712)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
-- Streaming vidéo (1713)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
-- Streaming musique (1714)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
-- Jouets & hobbies (1715)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
-- Équipement sportif (1722)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
-- Parcs & activités (1723)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
-- Lecture (1741)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
-- Animaux — nourriture (1751)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
-- Alcool (1810)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
-- Cannabis (1820)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
-- Frais bancaires (1911)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
-- Impôts (1921, 1922)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
-- Dons de charité (1931)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
-- Cotisations professionnelles (1932)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
-- Cadeaux (1940)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
-- Divers (1946) — catch-all marketplace
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
-- Placements (1964)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
-- Paie (1011)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);

View file

@ -0,0 +1,747 @@
-- Consolidated schema for new profile databases
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
-- use the new standard. Existing profiles keep their v2 seed and are only
-- tagged via the migration (categories_schema_version = 'v2').
-- Used ONLY for initializing new profile databases (not for the default profile).
CREATE TABLE IF NOT EXISTS import_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
column_mapping TEXT NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS imported_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
UNIQUE(source_id, filename)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
color TEXT,
icon TEXT,
type TEXT NOT NULL DEFAULT 'expense',
is_active INTEGER NOT NULL DEFAULT 1,
is_inputable INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
i18n_key TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
normalized_name TEXT NOT NULL,
category_id INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
category_id INTEGER NOT NULL,
supplier_id INTEGER,
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
UNIQUE(keyword, category_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
category_id INTEGER,
supplier_id INTEGER,
source_id INTEGER,
file_id INTEGER,
original_description TEXT,
notes TEXT,
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
is_split INTEGER NOT NULL DEFAULT 0,
parent_transaction_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS adjustment_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
adjustment_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
description TEXT,
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
amount REAL NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(category_id, year, month)
);
CREATE TABLE IF NOT EXISTS budget_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS budget_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(template_id, category_id)
);
CREATE TABLE IF NOT EXISTS import_config_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
column_mapping TEXT NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single',
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- ============================================================================
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
-- ============================================================================
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
-- New profiles created from this consolidated schema get the balance feature
-- preinstalled without needing to replay v9 separately.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
i18n_key TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0,
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto'))
);
CREATE TABLE IF NOT EXISTS balance_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
balance_category_id INTEGER NOT NULL,
name TEXT NOT NULL,
symbol TEXT,
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS balance_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date DATE NOT NULL UNIQUE,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
quantity REAL,
unit_price REAL,
value REAL NOT NULL,
price_source TEXT,
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
UNIQUE(snapshot_id, account_id),
CHECK (
(quantity IS NULL AND unit_price IS NULL)
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS balance_account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
UNIQUE(transaction_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL),
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
('other', 'balance.category.other', 'simple', 50, 1, NULL),
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
-- ============================================================================
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
-- ----------------------------------------------------------------------------
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
-- the historical v2 seed migration on this fresh DB before this script
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
-- ============================================================================
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
-- 1000 — Revenus
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
-- 1100 — Alimentation
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
-- 1200 — Logement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
-- 1300 — Ménage & ameublement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
-- 1400 — Vêtements & chaussures
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
-- 1500 — Transport
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
-- 1600 — Santé & soins personnels
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
-- 1700 — Loisirs, formation & lecture
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
-- 1800 — Boissons, tabac & cannabis
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
-- 1900 — Finances & obligations
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
-- 1950 — Transferts & placements
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
-- ============================================================================
-- Keywords — Canadian suppliers (150+ entries)
-- ============================================================================
-- Alimentation > Épicerie régulière (1111)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
-- Épicerie > Boucherie (1112)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
-- Épicerie > Boulangerie (1113)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
-- Épicerie > Dépanneur (1114)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
-- Restauration > Restaurant (1121)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
-- Restauration > Café (1122)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
-- Restauration > Fast food (1123)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
-- Restauration > Livraison (1124)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
-- Logement > Hypothèque (1212)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
-- Logement > Taxes municipales (1213)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
-- Électricité (1221)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
-- Gaz (1222)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
-- Internet (1231)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
-- Mobile (1232)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
-- Entretien maison (1241)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
-- Matériaux & outils (1243)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
-- Assurance habitation (1250)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
-- Meubles (1311)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
-- Électroménagers (1312)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
-- Décoration (1313)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
-- Vêtements (1410)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
-- Transport — Essence (1512)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
-- Permis / SAAQ (1514)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
-- Transport public — autobus/métro (1521)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
-- Train de banlieue (1522)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
-- Taxi / Uber (1523)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
-- Avion (1531)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
-- Hébergement (1533)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
-- Pharmacie (1611)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
-- Thérapies (1615)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
-- Cinéma & spectacles (1711)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
-- Jeux vidéo (1712)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
-- Streaming vidéo (1713)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
-- Streaming musique (1714)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
-- Jouets & hobbies (1715)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
-- Équipement sportif (1722)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
-- Parcs & activités (1723)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
-- Lecture (1741)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
-- Animaux — nourriture (1751)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
-- Alcool (1810)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
-- Cannabis (1820)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
-- Frais bancaires (1911)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
-- Impôts (1921, 1922)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
-- Dons de charité (1931)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
-- Cotisations professionnelles (1932)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
-- Cadeaux (1940)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
-- Divers (1946) — catch-all marketplace
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
-- Placements (1964)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
-- Paie (1011)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);

View file

@ -0,0 +1,764 @@
-- Consolidated schema for new profile databases
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
-- use the new standard. Existing profiles keep their v2 seed and are only
-- tagged via the migration (categories_schema_version = 'v2').
-- Used ONLY for initializing new profile databases (not for the default profile).
CREATE TABLE IF NOT EXISTS import_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
column_mapping TEXT NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS imported_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
UNIQUE(source_id, filename)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
color TEXT,
icon TEXT,
type TEXT NOT NULL DEFAULT 'expense',
is_active INTEGER NOT NULL DEFAULT 1,
is_inputable INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
i18n_key TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
normalized_name TEXT NOT NULL,
category_id INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
category_id INTEGER NOT NULL,
supplier_id INTEGER,
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
UNIQUE(keyword, category_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
category_id INTEGER,
supplier_id INTEGER,
source_id INTEGER,
file_id INTEGER,
original_description TEXT,
notes TEXT,
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
is_split INTEGER NOT NULL DEFAULT 0,
parent_transaction_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS adjustment_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
adjustment_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
description TEXT,
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
amount REAL NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(category_id, year, month)
);
CREATE TABLE IF NOT EXISTS budget_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS budget_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(template_id, category_id)
);
CREATE TABLE IF NOT EXISTS import_config_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
column_mapping TEXT NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single',
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- ============================================================================
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
-- ============================================================================
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
-- New profiles created from this consolidated schema get the balance feature
-- preinstalled without needing to replay v9 separately.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
i18n_key TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0,
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto'))
);
CREATE TABLE IF NOT EXISTS balance_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
balance_category_id INTEGER NOT NULL,
name TEXT NOT NULL,
symbol TEXT,
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS balance_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date DATE NOT NULL UNIQUE,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
quantity REAL,
unit_price REAL,
value REAL NOT NULL,
price_source TEXT,
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
UNIQUE(snapshot_id, account_id),
CHECK (
(quantity IS NULL AND unit_price IS NULL)
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS balance_account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
UNIQUE(transaction_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL),
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
('other', 'balance.category.other', 'simple', 50, 1, NULL),
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so
-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on
-- balance_accounts) — once created they are indistinguishable from
-- user-created accounts and can be renamed/archived freely. Existing profiles
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) VALUES
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1),
((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1),
((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1),
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1);
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
-- Suppress StarterAccountsModal on first /balance visit for new profiles
-- (Issue #179). The 4 starter accounts are already seeded above, so the
-- modal would only show 4 collision rows with no actionable choice. Pre-
-- writing the pref skips that briefly-empty UX entirely. Suggestion S1
-- from PR #185 review (#187).
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_proposed', '{"shown_at":"seed","accepted":[]}');
-- ============================================================================
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
-- ----------------------------------------------------------------------------
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
-- the historical v2 seed migration on this fresh DB before this script
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
-- ============================================================================
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
-- 1000 — Revenus
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
-- 1100 — Alimentation
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
-- 1200 — Logement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
-- 1300 — Ménage & ameublement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
-- 1400 — Vêtements & chaussures
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
-- 1500 — Transport
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
-- 1600 — Santé & soins personnels
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
-- 1700 — Loisirs, formation & lecture
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
-- 1800 — Boissons, tabac & cannabis
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
-- 1900 — Finances & obligations
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
-- 1950 — Transferts & placements
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
-- ============================================================================
-- Keywords — Canadian suppliers (150+ entries)
-- ============================================================================
-- Alimentation > Épicerie régulière (1111)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
-- Épicerie > Boucherie (1112)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
-- Épicerie > Boulangerie (1113)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
-- Épicerie > Dépanneur (1114)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
-- Restauration > Restaurant (1121)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
-- Restauration > Café (1122)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
-- Restauration > Fast food (1123)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
-- Restauration > Livraison (1124)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
-- Logement > Hypothèque (1212)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
-- Logement > Taxes municipales (1213)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
-- Électricité (1221)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
-- Gaz (1222)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
-- Internet (1231)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
-- Mobile (1232)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
-- Entretien maison (1241)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
-- Matériaux & outils (1243)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
-- Assurance habitation (1250)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
-- Meubles (1311)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
-- Électroménagers (1312)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
-- Décoration (1313)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
-- Vêtements (1410)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
-- Transport — Essence (1512)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
-- Permis / SAAQ (1514)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
-- Transport public — autobus/métro (1521)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
-- Train de banlieue (1522)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
-- Taxi / Uber (1523)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
-- Avion (1531)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
-- Hébergement (1533)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
-- Pharmacie (1611)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
-- Thérapies (1615)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
-- Cinéma & spectacles (1711)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
-- Jeux vidéo (1712)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
-- Streaming vidéo (1713)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
-- Streaming musique (1714)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
-- Jouets & hobbies (1715)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
-- Équipement sportif (1722)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
-- Parcs & activités (1723)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
-- Lecture (1741)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
-- Animaux — nourriture (1751)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
-- Alcool (1810)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
-- Cannabis (1820)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
-- Frais bancaires (1911)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
-- Impôts (1921, 1922)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
-- Dons de charité (1931)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
-- Cotisations professionnelles (1932)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
-- Cadeaux (1940)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
-- Divers (1946) — catch-all marketplace
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
-- Placements (1964)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
-- Paie (1011)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);

View file

@ -0,0 +1,232 @@
-- Migration v2: Seed categories and keywords from SommaireDepense.csv
-- Clear existing data (FK order: keywords -> categories)
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- ==========================================
-- Parent categories (Regroupement level)
-- ==========================================
INSERT INTO categories (id, name, type, sort_order) VALUES (1, 'Revenus', 'income', 1);
INSERT INTO categories (id, name, type, sort_order) VALUES (2, 'Dépenses récurrentes', 'expense', 2);
INSERT INTO categories (id, name, type, sort_order) VALUES (3, 'Dépenses ponctuelles', 'expense', 3);
INSERT INTO categories (id, name, type, sort_order) VALUES (4, 'Maison', 'expense', 4);
INSERT INTO categories (id, name, type, sort_order) VALUES (5, 'Placements', 'transfer', 5);
INSERT INTO categories (id, name, type, sort_order) VALUES (6, 'Autres', 'expense', 6);
-- ==========================================
-- Child categories
-- ==========================================
-- Revenus children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (10, 'Paie', 1, 'income', '#22c55e', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (11, 'Autres revenus', 1, 'income', '#4ade80', 2);
-- Dépenses récurrentes children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (20, 'Loyer', 2, 'expense', '#ef4444', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (21, 'Électricité', 2, 'expense', '#f59e0b', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (22, 'Épicerie', 2, 'expense', '#10b981', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (23, 'Dons', 2, 'expense', '#ec4899', 4);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (24, 'Restaurant', 2, 'expense', '#f97316', 5);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (25, 'Frais bancaires', 2, 'expense', '#6b7280', 6);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (26, 'Jeux, Films & Livres', 2, 'expense', '#8b5cf6', 7);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (27, 'Abonnements Musique', 2, 'expense', '#06b6d4', 8);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (28, 'Transport en commun', 2, 'expense', '#3b82f6', 9);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (29, 'Internet & Télécom', 2, 'expense', '#6366f1', 10);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (30, 'Animaux', 2, 'expense', '#a855f7', 11);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (32, 'Pharmacie', 2, 'expense', '#f43f5e', 13);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (33, 'Taxes municipales', 2, 'expense', '#78716c', 14);
-- Dépenses ponctuelles children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (40, 'Voiture', 3, 'expense', '#64748b', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (41, 'Amazon', 3, 'expense', '#f59e0b', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (42, 'Électroniques', 3, 'expense', '#3b82f6', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (43, 'Alcool', 3, 'expense', '#7c3aed', 4);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (44, 'Cadeaux', 3, 'expense', '#ec4899', 5);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (45, 'Vêtements', 3, 'expense', '#d946ef', 6);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (46, 'CPA', 3, 'expense', '#0ea5e9', 7);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (47, 'Voyage', 3, 'expense', '#f97316', 8);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (48, 'Sports & Plein air', 3, 'expense', '#22c55e', 9);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (49, 'Spectacles & sorties', 3, 'expense', '#e11d48', 10);
-- Maison children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (50, 'Hypothèque', 4, 'expense', '#dc2626', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (51, 'Achats maison', 4, 'expense', '#ea580c', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (52, 'Entretien maison', 4, 'expense', '#ca8a04', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (53, 'Électroménagers & Meubles', 4, 'expense', '#0d9488', 4);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (54, 'Outils', 4, 'expense', '#b45309', 5);
-- Placements children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (60, 'Placements', 5, 'transfer', '#2563eb', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (61, 'Transferts', 5, 'transfer', '#7c3aed', 2);
-- Autres children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (70, 'Impôts', 6, 'expense', '#dc2626', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (71, 'Paiement CC', 6, 'transfer', '#6b7280', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (72, 'Retrait cash', 6, 'expense', '#57534e', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (73, 'Projets', 6, 'expense', '#0ea5e9', 4);
-- ==========================================
-- Keywords
-- ==========================================
-- Paie (10)
INSERT INTO keywords (keyword, category_id) VALUES ('PAY/PAY', 10);
-- Électricité (21)
INSERT INTO keywords (keyword, category_id) VALUES ('HYDRO-QUEBEC', 21);
-- Épicerie (22)
INSERT INTO keywords (keyword, category_id) VALUES ('METRO', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('IGA', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('MAXI', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('SUPER C', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCHERIE LAFLECHE', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('BOULANGERIE JARRY', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('DOLLARAMA', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('WALMART', 22);
-- Dons (23)
INSERT INTO keywords (keyword, category_id) VALUES ('OXFAM', 23);
INSERT INTO keywords (keyword, category_id) VALUES ('CENTRAIDE', 23);
INSERT INTO keywords (keyword, category_id) VALUES ('FPA', 23);
-- Restaurant (24)
INSERT INTO keywords (keyword, category_id) VALUES ('SUBWAY', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('MCDONALD', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('A&W', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('DD/DOORDASH', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('DOORDASH', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('SUSHI', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('DOMINOS', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('BELLE PROVINCE', 24);
-- Frais bancaires (25)
INSERT INTO keywords (keyword, category_id) VALUES ('PROGRAMME PERFORMANCE', 25);
-- Jeux, Films & Livres (26)
INSERT INTO keywords (keyword, category_id) VALUES ('STEAMGAMES', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('PLAYSTATION', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('PRIMEVIDEO', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('NINTENDO', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('RENAUD-BRAY', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('CINEMA DU PARC', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('LEGO', 26);
-- Abonnements Musique (27)
INSERT INTO keywords (keyword, category_id) VALUES ('SPOTIFY', 27);
-- Transport en commun (28)
INSERT INTO keywords (keyword, category_id) VALUES ('STM', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('GARE MONT-SAINT', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('GARE SAINT-HUBERT', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('GARE CENTRALE', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('REM', 28);
-- Internet & Télécom (29)
INSERT INTO keywords (keyword, category_id) VALUES ('VIDEOTRON', 29);
INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29);
-- Animaux (30)
INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30);
-- Assurances (31)
INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 31);
INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 31);
INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 31);
-- Pharmacie (32)
INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32);
INSERT INTO keywords (keyword, category_id) VALUES ('FAMILIPRIX', 32);
INSERT INTO keywords (keyword, category_id) VALUES ('PHARMAPRIX', 32);
-- Taxes municipales (33)
INSERT INTO keywords (keyword, category_id) VALUES ('M-ST-HILAIRE TX', 33);
INSERT INTO keywords (keyword, category_id) VALUES ('CSS PATRIOT', 33);
-- Voiture (40)
INSERT INTO keywords (keyword, category_id) VALUES ('SHELL', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('ESSO', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('ULTRAMAR', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('PETRO-CANADA', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('SAAQ', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('CREVIER', 40);
-- Amazon (41)
INSERT INTO keywords (keyword, category_id) VALUES ('AMAZON', 41);
INSERT INTO keywords (keyword, category_id) VALUES ('AMZN', 41);
-- Électroniques (42)
INSERT INTO keywords (keyword, category_id) VALUES ('MICROSOFT', 42);
INSERT INTO keywords (keyword, category_id) VALUES ('ADDISON ELECTRONIQUE', 42);
-- Alcool (43)
INSERT INTO keywords (keyword, category_id) VALUES ('SAQ', 43);
INSERT INTO keywords (keyword, category_id) VALUES ('SQDC', 43);
-- Cadeaux (44)
INSERT INTO keywords (keyword, category_id) VALUES ('DANS UN JARDIN', 44);
-- Vêtements (45)
INSERT INTO keywords (keyword, category_id) VALUES ('UNIQLO', 45);
INSERT INTO keywords (keyword, category_id) VALUES ('WINNERS', 45);
INSERT INTO keywords (keyword, category_id) VALUES ('SIMONS', 45);
-- CPA (46)
INSERT INTO keywords (keyword, category_id) VALUES ('ORDRE DES COMPTABL', 46);
-- Voyage (47)
INSERT INTO keywords (keyword, category_id) VALUES ('NORWEGIAN CRUISE', 47);
INSERT INTO keywords (keyword, category_id) VALUES ('AEROPORTS DE MONTREAL', 47);
INSERT INTO keywords (keyword, category_id) VALUES ('HILTON', 47);
-- Sports & Plein air (48)
INSERT INTO keywords (keyword, category_id) VALUES ('BLOC SHOP', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('SEPAQ', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('LA CORDEE', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('MOUNTAIN EQUIPMENT', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('PHYSIOACTIF', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('DECATHLON', 48);
-- Spectacles & sorties (49)
INSERT INTO keywords (keyword, category_id) VALUES ('TICKETMASTER', 49);
INSERT INTO keywords (keyword, category_id) VALUES ('CLUB SODA', 49);
INSERT INTO keywords (keyword, category_id) VALUES ('LEPOINTDEVENTE', 49);
-- Hypothèque (50)
INSERT INTO keywords (keyword, category_id) VALUES ('MTG/HYP', 50);
-- Achats maison (51)
INSERT INTO keywords (keyword, category_id) VALUES ('CANADIAN TIRE', 51);
INSERT INTO keywords (keyword, category_id) VALUES ('CANAC', 51);
INSERT INTO keywords (keyword, category_id) VALUES ('RONA', 51);
-- Entretien maison (52)
INSERT INTO keywords (keyword, category_id) VALUES ('IKEA', 52);
-- Électroménagers & Meubles (53)
INSERT INTO keywords (keyword, category_id) VALUES ('TANGUAY', 53);
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCLAIR', 53);
-- Outils (54)
INSERT INTO keywords (keyword, category_id) VALUES ('BMR', 54);
INSERT INTO keywords (keyword, category_id) VALUES ('HOME DEPOT', 54);
INSERT INTO keywords (keyword, category_id) VALUES ('PRINCESS AUTO', 54);
-- Placements (60)
INSERT INTO keywords (keyword, category_id) VALUES ('DYNAMIC FUND', 60);
INSERT INTO keywords (keyword, category_id) VALUES ('FIDELITY', 60);
INSERT INTO keywords (keyword, category_id) VALUES ('AGF', 60);
-- Transferts (61)
INSERT INTO keywords (keyword, category_id) VALUES ('WS INVESTMENTS', 61);
INSERT INTO keywords (keyword, category_id) VALUES ('PEAK INVESTMENT', 61);
-- Impôts (70)
INSERT INTO keywords (keyword, category_id) VALUES ('GOUV. QUEBEC', 70);
-- Projets (73)
INSERT INTO keywords (keyword, category_id) VALUES ('CLAUDE.AI', 73);
INSERT INTO keywords (keyword, category_id) VALUES ('NAME-CHEAP', 73);

View file

@ -0,0 +1,241 @@
-- Migration v2: Seed categories and keywords from SommaireDepense.csv
-- Clear existing data (FK order: keywords -> categories)
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- ==========================================
-- Parent categories (Regroupement level)
-- ==========================================
INSERT INTO categories (id, name, type, sort_order) VALUES (1, 'Revenus', 'income', 1);
INSERT INTO categories (id, name, type, sort_order) VALUES (2, 'Dépenses récurrentes', 'expense', 2);
INSERT INTO categories (id, name, type, sort_order) VALUES (3, 'Dépenses ponctuelles', 'expense', 3);
INSERT INTO categories (id, name, type, sort_order) VALUES (4, 'Maison', 'expense', 4);
INSERT INTO categories (id, name, type, sort_order) VALUES (5, 'Placements', 'transfer', 5);
INSERT INTO categories (id, name, type, sort_order) VALUES (6, 'Autres', 'expense', 6);
-- ==========================================
-- Child categories
-- ==========================================
-- Revenus children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (10, 'Paie', 1, 'income', '#22c55e', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (11, 'Autres revenus', 1, 'income', '#4ade80', 2);
-- Dépenses récurrentes children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (20, 'Loyer', 2, 'expense', '#ef4444', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (21, 'Électricité', 2, 'expense', '#f59e0b', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (22, 'Épicerie', 2, 'expense', '#10b981', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (23, 'Dons', 2, 'expense', '#ec4899', 4);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (24, 'Restaurant', 2, 'expense', '#f97316', 5);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (25, 'Frais bancaires', 2, 'expense', '#6b7280', 6);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (26, 'Jeux, Films & Livres', 2, 'expense', '#8b5cf6', 7);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (27, 'Abonnements Musique', 2, 'expense', '#06b6d4', 8);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (28, 'Transport en commun', 2, 'expense', '#3b82f6', 9);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (29, 'Internet & Télécom', 2, 'expense', '#6366f1', 10);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (30, 'Animaux', 2, 'expense', '#a855f7', 11);
INSERT INTO categories (id, name, parent_id, type, color, sort_order, is_inputable) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12, 0);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (32, 'Pharmacie', 2, 'expense', '#f43f5e', 13);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (33, 'Taxes municipales', 2, 'expense', '#78716c', 14);
-- Dépenses ponctuelles children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (40, 'Voiture', 3, 'expense', '#64748b', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (41, 'Amazon', 3, 'expense', '#f59e0b', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (42, 'Électroniques', 3, 'expense', '#3b82f6', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (43, 'Alcool', 3, 'expense', '#7c3aed', 4);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (44, 'Cadeaux', 3, 'expense', '#ec4899', 5);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (45, 'Vêtements', 3, 'expense', '#d946ef', 6);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (46, 'CPA', 3, 'expense', '#0ea5e9', 7);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (47, 'Voyage', 3, 'expense', '#f97316', 8);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (48, 'Sports & Plein air', 3, 'expense', '#22c55e', 9);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (49, 'Spectacles & sorties', 3, 'expense', '#e11d48', 10);
-- Maison children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (50, 'Hypothèque', 4, 'expense', '#dc2626', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (51, 'Achats maison', 4, 'expense', '#ea580c', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (52, 'Entretien maison', 4, 'expense', '#ca8a04', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (53, 'Électroménagers & Meubles', 4, 'expense', '#0d9488', 4);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (54, 'Outils', 4, 'expense', '#b45309', 5);
-- Placements children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (60, 'Placements', 5, 'transfer', '#2563eb', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (61, 'Transferts', 5, 'transfer', '#7c3aed', 2);
-- Autres children
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (70, 'Impôts', 6, 'expense', '#dc2626', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (71, 'Paiement CC', 6, 'transfer', '#6b7280', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (72, 'Retrait cash', 6, 'expense', '#57534e', 3);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (73, 'Projets', 6, 'expense', '#0ea5e9', 4);
-- ==========================================
-- Grandchild categories (Level 3 — under Assurances)
-- ==========================================
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2);
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3);
-- ==========================================
-- Keywords
-- ==========================================
-- Paie (10)
INSERT INTO keywords (keyword, category_id) VALUES ('PAY/PAY', 10);
-- Électricité (21)
INSERT INTO keywords (keyword, category_id) VALUES ('HYDRO-QUEBEC', 21);
-- Épicerie (22)
INSERT INTO keywords (keyword, category_id) VALUES ('METRO', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('IGA', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('MAXI', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('SUPER C', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCHERIE LAFLECHE', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('BOULANGERIE JARRY', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('DOLLARAMA', 22);
INSERT INTO keywords (keyword, category_id) VALUES ('WALMART', 22);
-- Dons (23)
INSERT INTO keywords (keyword, category_id) VALUES ('OXFAM', 23);
INSERT INTO keywords (keyword, category_id) VALUES ('CENTRAIDE', 23);
INSERT INTO keywords (keyword, category_id) VALUES ('FPA', 23);
-- Restaurant (24)
INSERT INTO keywords (keyword, category_id) VALUES ('SUBWAY', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('MCDONALD', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('A&W', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('DD/DOORDASH', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('DOORDASH', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('SUSHI', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('DOMINOS', 24);
INSERT INTO keywords (keyword, category_id) VALUES ('BELLE PROVINCE', 24);
-- Frais bancaires (25)
INSERT INTO keywords (keyword, category_id) VALUES ('PROGRAMME PERFORMANCE', 25);
-- Jeux, Films & Livres (26)
INSERT INTO keywords (keyword, category_id) VALUES ('STEAMGAMES', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('PLAYSTATION', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('PRIMEVIDEO', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('NINTENDO', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('RENAUD-BRAY', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('CINEMA DU PARC', 26);
INSERT INTO keywords (keyword, category_id) VALUES ('LEGO', 26);
-- Abonnements Musique (27)
INSERT INTO keywords (keyword, category_id) VALUES ('SPOTIFY', 27);
-- Transport en commun (28)
INSERT INTO keywords (keyword, category_id) VALUES ('STM', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('GARE MONT-SAINT', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('GARE SAINT-HUBERT', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('GARE CENTRALE', 28);
INSERT INTO keywords (keyword, category_id) VALUES ('REM', 28);
-- Internet & Télécom (29)
INSERT INTO keywords (keyword, category_id) VALUES ('VIDEOTRON', 29);
INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29);
-- Animaux (30)
INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30);
-- Assurance-auto (310)
INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 310);
-- Assurance-habitation (311)
INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 311);
-- Assurance-vie (312)
INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 312);
-- Pharmacie (32)
INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32);
INSERT INTO keywords (keyword, category_id) VALUES ('FAMILIPRIX', 32);
INSERT INTO keywords (keyword, category_id) VALUES ('PHARMAPRIX', 32);
-- Taxes municipales (33)
INSERT INTO keywords (keyword, category_id) VALUES ('M-ST-HILAIRE TX', 33);
INSERT INTO keywords (keyword, category_id) VALUES ('CSS PATRIOT', 33);
-- Voiture (40)
INSERT INTO keywords (keyword, category_id) VALUES ('SHELL', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('ESSO', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('ULTRAMAR', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('PETRO-CANADA', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('SAAQ', 40);
INSERT INTO keywords (keyword, category_id) VALUES ('CREVIER', 40);
-- Amazon (41)
INSERT INTO keywords (keyword, category_id) VALUES ('AMAZON', 41);
INSERT INTO keywords (keyword, category_id) VALUES ('AMZN', 41);
-- Électroniques (42)
INSERT INTO keywords (keyword, category_id) VALUES ('MICROSOFT', 42);
INSERT INTO keywords (keyword, category_id) VALUES ('ADDISON ELECTRONIQUE', 42);
-- Alcool (43)
INSERT INTO keywords (keyword, category_id) VALUES ('SAQ', 43);
INSERT INTO keywords (keyword, category_id) VALUES ('SQDC', 43);
-- Cadeaux (44)
INSERT INTO keywords (keyword, category_id) VALUES ('DANS UN JARDIN', 44);
-- Vêtements (45)
INSERT INTO keywords (keyword, category_id) VALUES ('UNIQLO', 45);
INSERT INTO keywords (keyword, category_id) VALUES ('WINNERS', 45);
INSERT INTO keywords (keyword, category_id) VALUES ('SIMONS', 45);
-- CPA (46)
INSERT INTO keywords (keyword, category_id) VALUES ('ORDRE DES COMPTABL', 46);
-- Voyage (47)
INSERT INTO keywords (keyword, category_id) VALUES ('NORWEGIAN CRUISE', 47);
INSERT INTO keywords (keyword, category_id) VALUES ('AEROPORTS DE MONTREAL', 47);
INSERT INTO keywords (keyword, category_id) VALUES ('HILTON', 47);
-- Sports & Plein air (48)
INSERT INTO keywords (keyword, category_id) VALUES ('BLOC SHOP', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('SEPAQ', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('LA CORDEE', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('MOUNTAIN EQUIPMENT', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('PHYSIOACTIF', 48);
INSERT INTO keywords (keyword, category_id) VALUES ('DECATHLON', 48);
-- Spectacles & sorties (49)
INSERT INTO keywords (keyword, category_id) VALUES ('TICKETMASTER', 49);
INSERT INTO keywords (keyword, category_id) VALUES ('CLUB SODA', 49);
INSERT INTO keywords (keyword, category_id) VALUES ('LEPOINTDEVENTE', 49);
-- Hypothèque (50)
INSERT INTO keywords (keyword, category_id) VALUES ('MTG/HYP', 50);
-- Achats maison (51)
INSERT INTO keywords (keyword, category_id) VALUES ('CANADIAN TIRE', 51);
INSERT INTO keywords (keyword, category_id) VALUES ('CANAC', 51);
INSERT INTO keywords (keyword, category_id) VALUES ('RONA', 51);
-- Entretien maison (52)
INSERT INTO keywords (keyword, category_id) VALUES ('IKEA', 52);
-- Électroménagers & Meubles (53)
INSERT INTO keywords (keyword, category_id) VALUES ('TANGUAY', 53);
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCLAIR', 53);
-- Outils (54)
INSERT INTO keywords (keyword, category_id) VALUES ('BMR', 54);
INSERT INTO keywords (keyword, category_id) VALUES ('HOME DEPOT', 54);
INSERT INTO keywords (keyword, category_id) VALUES ('PRINCESS AUTO', 54);
-- Placements (60)
INSERT INTO keywords (keyword, category_id) VALUES ('DYNAMIC FUND', 60);
INSERT INTO keywords (keyword, category_id) VALUES ('FIDELITY', 60);
INSERT INTO keywords (keyword, category_id) VALUES ('AGF', 60);
-- Transferts (61)
INSERT INTO keywords (keyword, category_id) VALUES ('WS INVESTMENTS', 61);
INSERT INTO keywords (keyword, category_id) VALUES ('PEAK INVESTMENT', 61);
-- Impôts (70)
INSERT INTO keywords (keyword, category_id) VALUES ('GOUV. QUEBEC', 70);
-- Projets (73)
INSERT INTO keywords (keyword, category_id) VALUES ('CLAUDE.AI', 73);
INSERT INTO keywords (keyword, category_id) VALUES ('NAME-CHEAP', 73);

View file

@ -1,5 +1,11 @@
pub mod profile_db;
pub const SCHEMA: &str = include_str!("schema.sql");
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");
pub const BALANCE_HOLDINGS_SCHEMA: &str = include_str!("balance_holdings_schema.sql");
/// File name of the default profile's database: the only one tauri-plugin-sql
/// migrates itself (`add_migrations` in lib.rs).
pub const DEFAULT_PROFILE_DB: &str = "simpl_resultat.db";

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,12 @@ use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = vec![
/// Every migration, in order. tauri-plugin-sql applies them to the default
/// profile on load; `database::profile_db` catches the secondary profiles up
/// with the same list (#390). The SQL bytes are checksummed by sqlx and pinned
/// by a test: never edit an existing entry, append a new version.
pub(crate) fn migrations() -> Vec<Migration> {
vec![
Migration {
version: 1,
description: "create initial schema",
@ -365,7 +368,12 @@ pub fn run() {
WHERE column_mapping LIKE '%debitAmount%';",
kind: MigrationKind::Up,
},
];
]
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = migrations();
tauri::Builder::default()
// Single-instance plugin MUST be registered first. With the `deep-link`
@ -429,7 +437,7 @@ pub fn run() {
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:simpl_resultat.db", migrations)
.add_migrations(&format!("sqlite:{}", database::DEFAULT_PROFILE_DB), migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![
@ -439,6 +447,7 @@ pub fn run() {
commands::detect_encoding,
commands::get_file_preview,
commands::pick_folder,
commands::create_sample_import_folders,
commands::pick_save_file,
commands::pick_import_file,
commands::write_export_file,
@ -451,8 +460,10 @@ pub fn run() {
commands::hash_pin,
commands::verify_pin,
commands::repair_migrations,
commands::prepare_profile_db,
commands::validate_license_key,
commands::store_license,
commands::remove_license,
commands::store_activation_token,
commands::read_license,
commands::get_edition,
@ -3865,12 +3876,10 @@ mod tests {
#[test]
fn consolidated_schema_matches_v17_chain_on_import_sources_at_parity() {
// The consolidated schema is the tested reference definition for new
// profiles. Without this test the DEFAULT or the CHECK of the four v17
// columns could drift between the two definitions unnoticed — the
// columns are inert on the production path (this script runs after every
// migration and only uses CREATE TABLE IF NOT EXISTS), so nothing else
// would surface the divergence.
// The consolidated schema is the definition a secondary profile is
// created with: tauri-plugin-sql runs no migration on its database
// (#390). Without this test the DEFAULT or the CHECK of the four v17
// columns could drift between the two definitions unnoticed.
let chain = db_pre_v17();
chain.execute_batch(V17_SQL).expect("apply v17");
let consolidated = consolidated_db();

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.16.0",
"version": "0.17.2",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useProfile } from "./contexts/ProfileContext";
import AppShell from "./components/layout/AppShell";
@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage";
import RequireFeature from "./components/shared/RequireFeature";
const STARTUP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1_000;
import { startProfileConnection } from "./utils/startupConnection";
export default function App() {
const { t } = useTranslation();
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
const [dbReady, setDbReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
if (activeProfile && !isLoading) {
setDbReady(false);
setStartupError(null);
cancelledRef.current = false;
timeoutRef.current = setTimeout(() => {
setStartupError(t("error.startupTimeout"));
}, STARTUP_TIMEOUT_MS);
const attemptConnect = async (attempt: number): Promise<void> => {
try {
await connectActiveProfile();
if (cancelledRef.current) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setDbReady(true);
} catch (err) {
if (cancelledRef.current) return;
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
if (!cancelledRef.current) return attemptConnect(attempt + 1);
} else {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setStartupError(err instanceof Error ? err.message : String(err));
}
}
};
attemptConnect(1);
}
return () => {
cancelledRef.current = true;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
if (!activeProfile || isLoading) return;
return startProfileConnection({
connect: connectActiveProfile,
setDbReady,
setStartupError,
timeoutMessage: t("error.startupTimeout"),
});
}, [activeProfile, isLoading, connectActiveProfile, t]);
if (isLoading) {

View file

@ -0,0 +1,41 @@
Date;Description;Montant;Solde
01/{{M2}};DEPOT PAIE EMPLOYEUR;1842,50;4258,30
01/{{M2}};MTG/HYP PRET HYPOTHECAIRE;-1236,00;3022,30
02/{{M2}};HYDRO QUEBEC PREAUTORISE;-118,42;2903,88
03/{{M2}};METRO PLUS ST-LAMBERT;-96,18;2807,70
05/{{M2}};VIDEOTRON;-89,95;2717,75
06/{{M2}};STM OPUS EN LIGNE;-97,00;2620,75
08/{{M2}};IGA EXTRA;-132,47;2488,28
09/{{M2}};SHELL BOUL TASCHEREAU;-62,30;2425,98
10/{{M2}};FIZZ MOBILE;-34,49;2391,49
12/{{M2}};JEAN COUTU;-27,85;2363,64
13/{{M2}};GARDERIE LES PETITS PAS;-410,00;1953,64
15/{{M2}};DEPOT PAIE EMPLOYEUR;1842,50;3796,14
16/{{M2}};BELAIR DIRECT ASSURANCE;-94,60;3701,54
17/{{M2}};COSTCO WHOLESALE;-218,73;3482,81
19/{{M2}};VIREMENT INTERAC RECU;120,00;3602,81
20/{{M2}};PAIEMENT VISA TANGERINE;-650,00;2952,81
22/{{M2}};CLINIQUE DENTAIRE ST-LAMBERT;-185,00;2767,81
24/{{M2}};METRO PLUS ST-LAMBERT;-74,62;2693,19
27/{{M2}};RESTAURANT LA BANQUISE;-48,90;2644,29
28/{{M2}};FRAIS MENSUELS;-6,95;2637,34
01/{{M1}};DEPOT PAIE EMPLOYEUR;1842,50;4479,84
01/{{M1}};MTG/HYP PRET HYPOTHECAIRE;-1236,00;3243,84
02/{{M1}};HYDRO QUEBEC PREAUTORISE;-104,17;3139,67
04/{{M1}};METRO PLUS ST-LAMBERT;-112,36;3027,31
05/{{M1}};VIDEOTRON;-89,95;2937,36
06/{{M1}};STM OPUS EN LIGNE;-97,00;2840,36
07/{{M1}};MAXI ET CIE;-88,41;2751,95
09/{{M1}};ULTRAMAR;-58,75;2693,20
10/{{M1}};FIZZ MOBILE;-34,49;2658,71
11/{{M1}};FAMILIPRIX;-41,20;2617,51
13/{{M1}};GARDERIE LES PETITS PAS;-410,00;2207,51
15/{{M1}};DEPOT PAIE EMPLOYEUR;1842,50;4050,01
16/{{M1}};BELAIR DIRECT ASSURANCE;-94,60;3955,41
18/{{M1}};IGA EXTRA;-147,09;3808,32
19/{{M1}};RETRAITE QUEBEC ALLOC FAM;232,00;4040,32
20/{{M1}};PAIEMENT VISA TANGERINE;-712,00;3328,32
21/{{M1}};CANAC;-63,28;3265,04
23/{{M1}};METRO PLUS ST-LAMBERT;-69,84;3195,20
26/{{M1}};SUSHI SHOP;-32,45;3162,75
28/{{M1}};FRAIS MENSUELS;-6,95;3155,80
1 Date Description Montant Solde
2 01/{{M2}} DEPOT PAIE EMPLOYEUR 1842,50 4258,30
3 01/{{M2}} MTG/HYP PRET HYPOTHECAIRE -1236,00 3022,30
4 02/{{M2}} HYDRO QUEBEC PREAUTORISE -118,42 2903,88
5 03/{{M2}} METRO PLUS ST-LAMBERT -96,18 2807,70
6 05/{{M2}} VIDEOTRON -89,95 2717,75
7 06/{{M2}} STM OPUS EN LIGNE -97,00 2620,75
8 08/{{M2}} IGA EXTRA -132,47 2488,28
9 09/{{M2}} SHELL BOUL TASCHEREAU -62,30 2425,98
10 10/{{M2}} FIZZ MOBILE -34,49 2391,49
11 12/{{M2}} JEAN COUTU -27,85 2363,64
12 13/{{M2}} GARDERIE LES PETITS PAS -410,00 1953,64
13 15/{{M2}} DEPOT PAIE EMPLOYEUR 1842,50 3796,14
14 16/{{M2}} BELAIR DIRECT ASSURANCE -94,60 3701,54
15 17/{{M2}} COSTCO WHOLESALE -218,73 3482,81
16 19/{{M2}} VIREMENT INTERAC RECU 120,00 3602,81
17 20/{{M2}} PAIEMENT VISA TANGERINE -650,00 2952,81
18 22/{{M2}} CLINIQUE DENTAIRE ST-LAMBERT -185,00 2767,81
19 24/{{M2}} METRO PLUS ST-LAMBERT -74,62 2693,19
20 27/{{M2}} RESTAURANT LA BANQUISE -48,90 2644,29
21 28/{{M2}} FRAIS MENSUELS -6,95 2637,34
22 01/{{M1}} DEPOT PAIE EMPLOYEUR 1842,50 4479,84
23 01/{{M1}} MTG/HYP PRET HYPOTHECAIRE -1236,00 3243,84
24 02/{{M1}} HYDRO QUEBEC PREAUTORISE -104,17 3139,67
25 04/{{M1}} METRO PLUS ST-LAMBERT -112,36 3027,31
26 05/{{M1}} VIDEOTRON -89,95 2937,36
27 06/{{M1}} STM OPUS EN LIGNE -97,00 2840,36
28 07/{{M1}} MAXI ET CIE -88,41 2751,95
29 09/{{M1}} ULTRAMAR -58,75 2693,20
30 10/{{M1}} FIZZ MOBILE -34,49 2658,71
31 11/{{M1}} FAMILIPRIX -41,20 2617,51
32 13/{{M1}} GARDERIE LES PETITS PAS -410,00 2207,51
33 15/{{M1}} DEPOT PAIE EMPLOYEUR 1842,50 4050,01
34 16/{{M1}} BELAIR DIRECT ASSURANCE -94,60 3955,41
35 18/{{M1}} IGA EXTRA -147,09 3808,32
36 19/{{M1}} RETRAITE QUEBEC ALLOC FAM 232,00 4040,32
37 20/{{M1}} PAIEMENT VISA TANGERINE -712,00 3328,32
38 21/{{M1}} CANAC -63,28 3265,04
39 23/{{M1}} METRO PLUS ST-LAMBERT -69,84 3195,20
40 26/{{M1}} SUSHI SHOP -32,45 3162,75
41 28/{{M1}} FRAIS MENSUELS -6,95 3155,80

View file

@ -0,0 +1,41 @@
Date,Transaction,Name,Memo,Amount
02/{{M2}},DEBIT,COSTCO WHOLESALE,,-164.32
03/{{M2}},DEBIT,TIM HORTONS,,-6.45
04/{{M2}},DEBIT,NETFLIX.COM,,-20.99
05/{{M2}},DEBIT,AMAZON.CA,,-47.88
06/{{M2}},DEBIT,DOLLARAMA,,-18.35
08/{{M2}},DEBIT,STARBUCKS,,-7.90
09/{{M2}},DEBIT,DOORDASH,,-38.64
10/{{M2}},DEBIT,SPOTIFY,,-11.99
11/{{M2}},DEBIT,UNIQLO,,-89.95
12/{{M2}},DEBIT,SAQ,,-43.50
14/{{M2}},DEBIT,MCDONALD'S,,-14.27
15/{{M2}},DEBIT,PHARMAPRIX,,-31.18
16/{{M2}},DEBIT,CANADIAN TIRE,,-76.42
18/{{M2}},DEBIT,RESTAURANT LE PETIT ITALIEN,,-72.30
19/{{M2}},DEBIT,ESSO,,-61.05
20/{{M2}},CREDIT,PAIEMENT - MERCI,,650.00
21/{{M2}},DEBIT,SEPAQ,,-38.00
23/{{M2}},DEBIT,RENAUD-BRAY,,-29.95
25/{{M2}},DEBIT,UBER TRIP,,-17.60
27/{{M2}},DEBIT,CINEPLEX,,-28.50
01/{{M1}},DEBIT,COSTCO WHOLESALE,,-201.17
03/{{M1}},DEBIT,TIM HORTONS,,-5.85
04/{{M1}},DEBIT,NETFLIX.COM,,-20.99
05/{{M1}},DEBIT,AMAZON.CA,,-32.49
07/{{M1}},DEBIT,DOLLARAMA,,-12.40
08/{{M1}},DEBIT,STARBUCKS,,-8.35
09/{{M1}},DEBIT,DOORDASH,,-42.11
10/{{M1}},DEBIT,SPOTIFY,,-11.99
11/{{M1}},DEBIT,SIMONS,,-124.60
12/{{M1}},DEBIT,SAQ,,-38.75
13/{{M1}},DEBIT,A&W,,-16.90
15/{{M1}},DEBIT,PHARMAPRIX,,-24.66
16/{{M1}},DEBIT,RONA,,-58.12
17/{{M1}},DEBIT,BRASSERIE LES FRERES,,-64.80
19/{{M1}},DEBIT,PETRO-CANADA,,-57.40
20/{{M1}},CREDIT,PAIEMENT - MERCI,,712.00
22/{{M1}},DEBIT,LA CORDEE,,-89.99
24/{{M1}},DEBIT,INDIGO,,-34.50
26/{{M1}},DEBIT,UBER TRIP,,-21.15
27/{{M1}},DEBIT,MOUNTAIN EQUIPMENT CO,,-45.00
1 Date Transaction Name Memo Amount
2 02/{{M2}} DEBIT COSTCO WHOLESALE -164.32
3 03/{{M2}} DEBIT TIM HORTONS -6.45
4 04/{{M2}} DEBIT NETFLIX.COM -20.99
5 05/{{M2}} DEBIT AMAZON.CA -47.88
6 06/{{M2}} DEBIT DOLLARAMA -18.35
7 08/{{M2}} DEBIT STARBUCKS -7.90
8 09/{{M2}} DEBIT DOORDASH -38.64
9 10/{{M2}} DEBIT SPOTIFY -11.99
10 11/{{M2}} DEBIT UNIQLO -89.95
11 12/{{M2}} DEBIT SAQ -43.50
12 14/{{M2}} DEBIT MCDONALD'S -14.27
13 15/{{M2}} DEBIT PHARMAPRIX -31.18
14 16/{{M2}} DEBIT CANADIAN TIRE -76.42
15 18/{{M2}} DEBIT RESTAURANT LE PETIT ITALIEN -72.30
16 19/{{M2}} DEBIT ESSO -61.05
17 20/{{M2}} CREDIT PAIEMENT - MERCI 650.00
18 21/{{M2}} DEBIT SEPAQ -38.00
19 23/{{M2}} DEBIT RENAUD-BRAY -29.95
20 25/{{M2}} DEBIT UBER TRIP -17.60
21 27/{{M2}} DEBIT CINEPLEX -28.50
22 01/{{M1}} DEBIT COSTCO WHOLESALE -201.17
23 03/{{M1}} DEBIT TIM HORTONS -5.85
24 04/{{M1}} DEBIT NETFLIX.COM -20.99
25 05/{{M1}} DEBIT AMAZON.CA -32.49
26 07/{{M1}} DEBIT DOLLARAMA -12.40
27 08/{{M1}} DEBIT STARBUCKS -8.35
28 09/{{M1}} DEBIT DOORDASH -42.11
29 10/{{M1}} DEBIT SPOTIFY -11.99
30 11/{{M1}} DEBIT SIMONS -124.60
31 12/{{M1}} DEBIT SAQ -38.75
32 13/{{M1}} DEBIT A&W -16.90
33 15/{{M1}} DEBIT PHARMAPRIX -24.66
34 16/{{M1}} DEBIT RONA -58.12
35 17/{{M1}} DEBIT BRASSERIE LES FRERES -64.80
36 19/{{M1}} DEBIT PETRO-CANADA -57.40
37 20/{{M1}} CREDIT PAIEMENT - MERCI 712.00
38 22/{{M1}} DEBIT LA CORDEE -89.99
39 24/{{M1}} DEBIT INDIGO -34.50
40 26/{{M1}} DEBIT UBER TRIP -21.15
41 27/{{M1}} DEBIT MOUNTAIN EQUIPMENT CO -45.00

View file

@ -0,0 +1,39 @@
/**
* Sample statements the onboarding offers to a new user (issue #354).
*
* Both files are INVENTED, like the detection corpus in `../csv`: no real
* account, no real transaction, no real person. They are the two templates the
* Rust command `create_sample_import_folders` embeds with `include_str!` and
* writes into the import folder — one Desjardins chequing account, one
* Tangerine credit card — with the `{{M1}}` / `{{M2}}` tokens rendered as the
* two complete months preceding the day the user clicks.
*
* This module is deliberately SEPARATE from `../csv/index.ts`: that corpus is
* frozen at fifteen cases by `csvAutoDetect.test.ts`, and six loops pass every
* `CsvFixtureName` to detection. Adding these two there would silently widen
* the frozen contract.
*/
import { readFileSync } from "fs";
import { resolve } from "path";
/** The two sample statements, by bank. */
export type SampleTemplateName = "desjardins" | "tangerine";
const FILES: Record<SampleTemplateName, string> = {
desjardins: "exemple-desjardins.csv",
tangerine: "exemple-tangerine.csv",
};
/** Raw template text, tokens included — exactly what `include_str!` embeds. */
export function readSampleTemplate(name: SampleTemplateName): string {
return readFileSync(resolve(import.meta.dirname, FILES[name]), "utf-8");
}
/**
* Render a template the way the Rust command does: `{{M1}}` becomes the most
* recent complete month, `{{M2}}` the one before, both as `MM/YYYY`.
*/
export function renderSampleTemplate(raw: string, m1: string, m2: string): string {
return raw.replace(/\{\{M1\}\}/g, m1).replace(/\{\{M2\}\}/g, m2);
}

View file

@ -0,0 +1,112 @@
/**
* Guard: one fake `t` in the test suite, the marked one of `src/test/i18nMock.ts` (#389).
*
* A local fake `t` that returns the key as is hides a forgotten call to `t`
* (#387 review). The migration moved every test file to `i18nMock`; this guard
* keeps it that way. It reads the sources, as `transactions-transfer-icon.test.ts`
* does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the
* module itself that:
* 1. declares a `useTranslation:` property (a hand-written `react-i18next` module);
* 2. declares a hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a
* `t = (` / `t = vi.fn(` binding (`const t = (key) => key`, passed to a
* function that takes `t`);
* 3. mocks `react-i18next` without an actual call to `i18nMock(` (the name
* alone, in a comment for instance, is not enough: comments are dropped
* before the search).
*
* Keywords rather than an analysis of the factory: less fragile. The matches
* are anchored on a word boundary, so `useLicenseContext: () =>`,
* `get: (target, key)`, `const at = (` or `t === (` are not a fake `t`. This
* file names the patterns only inside regular expressions and excludes itself.
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "fs";
import { relative, resolve, sep } from "path";
const SRC = resolve(import.meta.dirname, "..");
const MODULE = resolve(SRC, "test", "i18nMock.ts");
const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts");
const LOCAL_MODULE = /\buseTranslation\s*:/;
const LOCAL_T = /\bt\s*[:=]\s*(?:\(|vi\.fn\()/;
const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/;
const CALLS_I18NMOCK = /\bi18nMock\s*\(/;
/** The source without its comments, so a mention of `i18nMock(` in one does not count as a call. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** The offending lines of one source, as `path:line: text`. */
function offences(path: string, source: string): string[] {
const name = relative(SRC, path).split(sep).join("/");
const found = source
.split("\n")
.flatMap((line, i) =>
LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [],
);
if (MOCKS_I18NEXT.test(source) && !CALLS_I18NMOCK.test(withoutComments(source))) {
found.push(`${name}: mocks react-i18next without i18nMock`);
}
return found;
}
const TEST_FILES = (readdirSync(SRC, { recursive: true }) as string[])
.filter((entry) => /\.test\.tsx?$/.test(entry))
.map((entry) => resolve(SRC, entry))
.filter((path) => path !== MODULE && path !== SELF);
describe("i18n mock guard (#389)", () => {
it("scans the test files of src, recursively", () => {
expect(TEST_FILES.length).toBeGreaterThan(100);
expect(TEST_FILES).toContain(resolve(SRC, "components", "settings", "LicenseCard.render.test.tsx"));
expect(TEST_FILES).toContain(resolve(SRC, "pages", "settings", "UsersSettingsPage.test.tsx"));
});
it("recognises a local fake, and only that", () => {
const local = [
'vi.mock("react-i18next", () => ({',
" useTranslation: () => ({ t: (k: string) => k }),",
"}));",
].join("\n");
expect(offences(resolve(SRC, "x.test.tsx"), local)).toHaveLength(2);
expect(offences(resolve(SRC, "x.test.tsx"), 'vi.mock("react-i18next", () => fake);')).toHaveLength(1);
const clean = [
'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());',
"const context = { useLicenseContext: () => ctx, get: (target: object) => target };",
"await view.removeKey({ confirm, t: markedT });",
"const at = (n: number) => n; const format = (v: string) => v;",
"if (t === (other as unknown)) return; const run = (t) => t;",
"const t = markedT; const set = { t: markedT };",
].join("\n");
expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]);
});
it("recognises a fake t bound to a name or built with vi.fn", () => {
const x = resolve(SRC, "x.test.ts");
expect(offences(x, "const t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "let t = (key: string, o?: object) => key;")).toHaveLength(1);
expect(offences(x, " t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "const t = vi.fn((key: string) => key);")).toHaveLength(1);
expect(offences(x, " useT: () => ({ t: vi.fn((k: string) => k) }),")).toHaveLength(1);
});
it("requires an actual call to i18nMock(, not its name in a comment", () => {
const x = resolve(SRC, "x.test.tsx");
const inComment = [
"// Not i18nMock(): a hand-written module.",
"/* i18nMock( would be the pattern */",
'vi.mock("react-i18next", () => fake);',
].join("\n");
expect(offences(x, inComment)).toEqual(["x.test.tsx: mocks react-i18next without i18nMock"]);
expect(offences(x, '// i18nMock is the rule\nvi.mock("react-i18next", () => fake);')).toHaveLength(1);
const called = 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock({ language: "en" }));';
expect(offences(x, called)).toEqual([]);
});
it("finds no other fake t than the one of src/test/i18nMock.ts", () => {
const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8")));
expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]);
});
});

View file

@ -0,0 +1,148 @@
/**
* Guard: no native `confirm` or `alert` in the app, and every confirmation awaited (#421).
*
* On desktop, tauri-plugin-dialog replaces `window.confirm` with an async function and
* `window.alert` with one that does not block. `if (!confirm(...)) return;` then tests a
* Promise, always truthy, and the deletion ran before the user answered. TypeScript does not
* see it: the DOM type of `confirm` stays `boolean`, and `!promise` is not an error.
*
* It reads the non-test sources of `src/`, comments removed, as `i18n-mock-guard.test.ts` does,
* and fails on:
* 1. a native `confirm` or `alert`: called bare or through `window`, `globalThis` or `self`
* (a member call such as `deps.confirm(` is not the native one), with `?.(` too, read by
* index (`window["confirm"]`) or destructured from one of those objects;
* 2. a call to `confirmAction(` or to an injected `confirm(` (`deps.confirm(`,
* `ui.confirm(`) that is not directly preceded by `await`;
* 3. any other mention of `confirmAction`: an alias (`const c = confirmAction`) or a renamed
* import would call it out of reach of rule 2 (#465 review). Allowed besides the awaited
* call: its declaration, a plain named import, and `confirm: confirmAction`, the value of
* an injected `confirm` whose calls rule 2 checks.
* `confirmAction` itself calls the plugin's `ask`, so `src/services/confirmService.ts` is
* scanned like any other file. Keywords rather than a parse: the samples below check that each
* rule flags what it targets and lets the right form through.
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "fs";
import { relative, resolve, sep } from "path";
const SRC = resolve(import.meta.dirname, "..");
const NATIVE_CALL = /(?:\b(?:window|globalThis|self)\s*\.\s*|(?<![.\w$]))(?:confirm|alert)\s*(?:\?\.\s*)?\(/;
const NATIVE_INDEX = /\b(?:window|globalThis|self)\s*(?:\?\.\s*)?\[\s*["'`](?:confirm|alert)["'`]\s*\]/;
const NATIVE_DESTRUCTURED = /\{[^}]*\b(?:confirm|alert)\b[^}]*\}\s*=\s*(?:window|globalThis|self)\b/;
// `confirmAction(` or `<receiver>.confirm(`, the receiver captured to skip the native ones.
const CONFIRM_CALL = /\b(?:confirmAction|(\w+)\s*\.\s*confirm)\s*\(/g;
const NATIVE_RECEIVER = /^(?:window|globalThis|self)$/;
const AWAITED = /\bawait\s+$/;
const DECLARED = /\bfunction\s+$/;
// Mentions of `confirmAction` that rule 3 allows; any other one is an offence.
const CONFIRM_ACTION = /\bconfirmAction\b/g;
const ALLOWED_MENTION = [
/\bawait\s+confirmAction\s*\(/g,
/\bfunction\s+confirmAction\s*\(/g,
/^\s*import\s*\{\s*confirmAction\s*\}\s*from\s/g,
/\bconfirm\s*:\s*confirmAction\s*[,}]/g,
];
/** The source without its comments, line breaks kept so that line numbers stay right. */
function withoutComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""))
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** The offences of one line: a native call, or a confirmation not awaited. */
function lineOffences(line: string): string[] {
const found: string[] = [];
if (NATIVE_CALL.test(line) || NATIVE_INDEX.test(line) || NATIVE_DESTRUCTURED.test(line)) {
found.push("native confirm/alert");
}
for (const match of line.matchAll(CONFIRM_CALL)) {
if (match[1] !== undefined && NATIVE_RECEIVER.test(match[1])) continue;
const before = line.slice(0, match.index);
if (!AWAITED.test(before) && !DECLARED.test(before)) found.push("confirmation not awaited");
}
const allowed = ALLOWED_MENTION.reduce((n, rule) => n + [...line.matchAll(rule)].length, 0);
if ([...line.matchAll(CONFIRM_ACTION)].length > allowed) found.push("confirmAction out of reach");
return found;
}
/** The offending lines of one source, as `path:line: kind: text`. */
function offences(path: string, source: string): string[] {
const name = relative(SRC, path).split(sep).join("/");
return withoutComments(source)
.split("\n")
.flatMap((line, i) => lineOffences(line).map((kind) => `${name}:${i + 1}: ${kind}: ${line.trim()}`));
}
const SOURCE_FILES = (readdirSync(SRC, { recursive: true }) as string[])
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) && !entry.endsWith(".d.ts"))
.map((entry) => resolve(SRC, entry));
describe("native dialog guard (#421)", () => {
it("scans the app's sources", () => {
const names = SOURCE_FILES.map((path) => relative(SRC, path).split(sep).join("/"));
expect(names.length).toBeGreaterThan(200);
expect(names).toContain("components/profile/ProfileFormModal.tsx");
expect(names).toContain("hooks/useLicenseMachines.ts");
expect(names).toContain("services/confirmService.ts");
});
it.each([
"if (!confirm(t(\"profile.deleteConfirm\"))) return;",
"if (confirm(message)) {",
"if (!window.confirm(message)) return;",
"onClick={() => removeKey({ confirm: (m) => window.confirm(m), t })}",
"globalThis.confirm(message);",
"self . alert(message);",
"alert(message);",
"if (!window[\"confirm\"](message)) return;",
"globalThis['alert'](message);",
"if (!confirm?.(message)) return;",
"const { confirm } = window;",
"const { alert: show, confirm: ask } = globalThis;",
])("flags the native call in %s", (line) => {
expect(lineOffences(line)).toContain("native confirm/alert");
});
it.each([
"if (!confirmAction(t(\"categories.deleteConfirm\"))) return;",
"if (confirmAction(message)) {",
"if (!deps.confirm(deps.t(key))) return;",
"const ok = ui.confirm(message);",
])("flags the confirmation not awaited in %s", (line) => {
expect(lineOffences(line)).toContain("confirmation not awaited");
});
it.each([
"const c = confirmAction;",
"import { confirmAction as ask } from \"../services/confirmService\";",
"const handlers = { remove: confirmAction };",
"items.map(confirmAction);",
"if (await confirmAction(a) && check(confirmAction)) {",
])("flags confirmAction out of reach in %s", (line) => {
expect(lineOffences(line)).toContain("confirmAction out of reach");
});
it.each([
"if (!(await confirmAction(t(\"profile.deleteConfirm\")))) return;",
"if (await confirmAction(message)) {",
"if (!(await deps.confirm(deps.t(key)))) return;",
"const ok = await confirmAction(message);",
"onClick={() => void removeKey({ confirm: confirmAction, t })}",
"import { confirmAction } from \"../../services/confirmService\";",
"export async function confirmAction(message: string): Promise<boolean> {",
"if ((await confirmAction(a)) && (await confirmAction(b))) {",
"deps.confirm.mockClear();",
"const confirmed = await ask(message, { kind: \"warning\" });",
"t(\"common.confirm\")",
])("lets %s through", (line) => {
expect(lineOffences(line)).toEqual([]);
});
it("finds no native dialog and no confirmation left unawaited", () => {
const found = SOURCE_FILES.flatMap((path) => offences(path, readFileSync(path, "utf8")));
expect(found).toEqual([]);
});
});

View file

@ -0,0 +1,265 @@
/**
* Onboarding samples — detection and categorisation contract (#354).
*
* `create_sample_import_folders` (Rust) writes two synthetic statements into the
* import folder so a new user has something to import on the first run. The
* command embeds the templates in `src/__fixtures__/samples/` with
* `include_str!`; this file reads the SAME files and proves what the wizard will
* do with them once the user clicks "Import":
*
* 1. detection recognises each file as the bank it imitates, with the
* configuration the templates were written for, and reads every row;
* 2. the full parse — the production `mapRow`, with the detected config —
* yields no row in error and the signed totals the templates were balanced
* to;
* 3. the 180 seed keywords of a fresh profile (`consolidated_schema.sql`)
* categorise the agreed share of each file, and leave the lines the
* onboarding wants the user to classify BY HAND uncategorised — that step
* of the checklist ("one transaction classified manually") needs something
* left to do.
*
* The counts are FROZEN. If a seed keyword changes and moves one of them, the
* expectation here is what tells the author the onboarding sample changed too.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import Papa from "papaparse";
import { detectImportFormat, preprocessQuotedCSV } from "../utils/csvAutoDetect";
import { detectAmountSeparators, mapRow } from "../utils/importFormat";
import type { ParsedRow } from "../shared/types";
import {
readSampleTemplate,
renderSampleTemplate,
type SampleTemplateName,
} from "../__fixtures__/samples";
vi.mock("../services/db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: unknown) => unknown) => fn(await getDb())),
};
});
import { getDb } from "../services/db";
import { categorizeBatch } from "../services/categorizationService";
// ---------------------------------------------------------------------------
// The two rendered files, as the command writes them for a click on 2026-09-25.
// ---------------------------------------------------------------------------
const M1 = "08/2026";
const M2 = "07/2026";
function renderedSample(name: SampleTemplateName): string {
return renderSampleTemplate(readSampleTemplate(name), M1, M2);
}
/** Detection outcome of a sample, or a failure the test can name. */
function detectSample(name: SampleTemplateName) {
const outcome = detectImportFormat(renderedSample(name));
if (outcome.status !== "ok") {
throw new Error(`${name}: detection returned ${outcome.status}`);
}
return outcome;
}
/**
* Full pipeline, as the wizard runs it: raw text -> Papa rows -> data rows ->
* column-level separator arbitration -> `mapRow` under the detected format.
*/
function parseSample(name: SampleTemplateName): ParsedRow[] {
const rawContent = renderedSample(name);
const { config } = detectSample(name);
const format = { ...config, encoding: "utf-8" };
const data = Papa.parse(preprocessQuotedCSV(rawContent), {
delimiter: config.delimiter,
skipEmptyLines: true,
}).data as string[][];
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
const dataRows: string[][] = [];
for (let i = startIdx; i < data.length; i++) {
const raw = data[i];
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
dataRows.push(raw);
}
const decimalSeparators = detectAmountSeparators(dataRows, format);
return dataRows.map((raw, rowIndex) =>
mapRow(raw, format, { rowIndex, decimalSeparators })
);
}
/** Sum of the signed amounts, rounded to the cent to absorb float noise. */
function sumOf(rows: ParsedRow[]): number {
const total = rows.reduce((acc, r) => acc + (r.parsed?.amount ?? 0), 0);
return Math.round(total * 100) / 100;
}
// ---------------------------------------------------------------------------
// 1 + 2 — detection and parse
// ---------------------------------------------------------------------------
const SAMPLES: {
name: SampleTemplateName;
bank: string;
total: number;
}[] = [
{ name: "desjardins", bank: "desjardins", total: 740.0 },
{ name: "tangerine", bank: "tangerine", total: -368.46 },
];
describe("onboarding samples — detection (#354)", () => {
it("renders every token: no `{{` survives in the files the user opens", () => {
for (const { name } of SAMPLES) {
const rendered = renderedSample(name);
expect(rendered, name).not.toContain("{{");
expect(rendered, name).toContain(M1);
expect(rendered, name).toContain(M2);
expect(rendered.trimEnd().split("\n"), name).toHaveLength(41);
}
});
it.each(SAMPLES)(
"$name: recognised as $bank, signed single amount, 40/40 rows read",
({ name, bank }) => {
const outcome = detectSample(name);
expect(outcome.status).toBe("ok");
expect(outcome.bank).toBe(bank);
expect(outcome.config.signConvention).toBe("negative_expense");
expect(outcome.config.amountMode).toBe("single");
expect(outcome.config.dateFormat).toBe("DD/MM/YYYY");
expect(outcome.score.totalRows).toBe(40);
expect(outcome.score.readRows).toBe(40);
expect(outcome.score.ratio).toBe(1);
expect(outcome.score.confident).toBe(true);
}
);
it.each(SAMPLES)(
"$name: the full parse has no row in error and sums to $total",
({ name, total }) => {
const rows = parseSample(name);
expect(rows).toHaveLength(40);
expect(rows.filter((r) => r.error !== undefined)).toEqual([]);
expect(rows.every((r) => r.parsed !== null && r.parsed !== undefined)).toBe(
true
);
expect(sumOf(rows)).toBe(total);
}
);
it("desjardins: every date is in 08/2026 or 07/2026 after rendering", () => {
const rows = parseSample("desjardins");
const months = new Set(rows.map((r) => r.parsed!.date.slice(0, 7)));
expect([...months].sort()).toEqual(["2026-07", "2026-08"]);
});
});
// ---------------------------------------------------------------------------
// 3 — categorisation by the seed keywords of a fresh profile
// ---------------------------------------------------------------------------
/**
* The 180 keywords `consolidated_schema.sql` seeds into a new profile, read
* from the SQL itself so this test follows the seed rather than a copy of it.
* Every INSERT has the same three-value shape; a line that does not match the
* pattern would be a seed change this regex has to learn about, so the count is
* asserted.
*/
function seedKeywords() {
const sql = readFileSync(
resolve(
import.meta.dirname,
"../../src-tauri/src/database/consolidated_schema.sql"
),
"utf-8"
);
const pattern =
/INSERT INTO keywords \(keyword, category_id, priority\) VALUES \('((?:[^']|'')*)', (\d+), (\d+)\);/g;
return [...sql.matchAll(pattern)].map((m, i) => ({
id: i + 1,
keyword: m[1].replace(/''/g, "'"),
category_id: Number(m[2]),
supplier_id: null,
priority: Number(m[3]),
is_active: 1,
}));
}
/** Lines the onboarding wants left for the user to classify by hand. */
const OFF_SEED: Record<SampleTemplateName, string[]> = {
desjardins: [
"GARDERIE LES PETITS PAS",
"PAIEMENT VISA TANGERINE",
"VIREMENT INTERAC RECU",
"CLINIQUE DENTAIRE ST-LAMBERT",
"RETRAITE QUEBEC ALLOC FAM",
],
tangerine: ["DOLLARAMA", "PAIEMENT - MERCI"],
};
const CATEGORISED: Record<SampleTemplateName, number> = {
desjardins: 33,
tangerine: 36,
};
describe("onboarding samples — seed categorisation (#354)", () => {
const select = vi.fn();
beforeEach(() => {
select.mockReset();
vi.mocked(getDb).mockResolvedValue({ select, execute: vi.fn() } as never);
});
it("reads the 180 seed keywords from the consolidated schema", () => {
const keywords = seedKeywords();
expect(keywords).toHaveLength(180);
expect(keywords[0]).toMatchObject({ keyword: "METRO", category_id: 1111 });
});
it.each(Object.keys(CATEGORISED) as SampleTemplateName[])(
"%s: the seed categorises the frozen share and leaves the hand-classified lines alone",
async (name) => {
const keywords = seedKeywords();
// `categorizeBatch` asks for active keywords by descending priority; the
// seed is all priority 0, so the SQL order is the file order.
select.mockResolvedValue(
[...keywords].sort((a, b) => b.priority - a.priority)
);
const rows = parseSample(name);
const descriptions = rows.map((r) => r.parsed!.description);
const results = await categorizeBatch(descriptions);
expect(select).toHaveBeenCalledWith(
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
);
expect(results).toHaveLength(40);
const categorised = results.filter((r) => r.category_id !== null).length;
expect(categorised).toBe(CATEGORISED[name]);
expect(categorised / results.length).toBeGreaterThanOrEqual(0.8);
// The off-seed lines are present in the file AND left uncategorised.
for (const description of OFF_SEED[name]) {
const indexes = descriptions
.map((d, i) => (d === description ? i : -1))
.filter((i) => i >= 0);
expect(indexes.length, description).toBeGreaterThan(0);
for (const i of indexes) {
expect(results[i].category_id, description).toBeNull();
}
}
// And they are the ONLY uncategorised lines: nothing else slipped.
const uncategorised = descriptions.filter(
(_, i) => results[i].category_id === null
);
expect(new Set(uncategorised)).toEqual(new Set(OFF_SEED[name]));
}
);
});

View file

@ -3,6 +3,7 @@ import { Pencil, RefreshCw } from "lucide-react";
import type { Adjustment, Category } from "../../shared/types";
import type { AdjustmentEntryWithCategory } from "../../services/adjustmentService";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
import { confirmAction } from "../../services/confirmService";
import AdjustmentForm from "./AdjustmentForm";
interface Props {
@ -34,9 +35,9 @@ export default function AdjustmentDetailPanel({
}: Props) {
const { t } = useTranslation();
const handleDelete = () => {
const handleDelete = async () => {
if (!selectedAdjustment) return;
if (!confirm(t("adjustments.deleteConfirm"))) return;
if (!(await confirmAction(t("adjustments.deleteConfirm")))) return;
onDelete(selectedAdjustment.id);
};

View file

@ -27,19 +27,8 @@ vi.mock("../../services/userPreferenceService", () => ({
setPreference: vi.fn(),
}));
// react-i18next: return the key as-is for tests
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string, opts?: Record<string, unknown>) => {
// Include interpolation values in the returned string for assertions
if (opts) {
return `${key}(${JSON.stringify(opts)})`;
}
return key;
},
i18n: { language: "fr" },
})),
}));
// react-i18next: the shared marked `t` (#389), so a text that skipped `t` fails
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// lucide-react: return simple stubs
vi.mock("lucide-react", () => ({
@ -58,7 +47,8 @@ import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
import {
import { renderToStaticMarkup } from "react-dom/server";
import PriceFetchControl, {
__resetBestEffortDismissForTests,
} from "./PriceFetchControl";
@ -363,3 +353,33 @@ describe("PriceFetchControl — fetchPrice invocation args", () => {
expect(mockSetPreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: what the control displays (#389)
// ---------------------------------------------------------------------------
describe("PriceFetchControl — displayed texts", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
});
it("names the fetch button and the stock best-effort notice through t", () => {
const html = renderToStaticMarkup(
<PriceFetchControl
symbol="AAPL"
date="2026-04-25"
categoryKind="priced"
assetType="stock"
onPriceFetched={vi.fn()}
/>,
);
expect(html).toContain('aria-label="t(balance.priceFetching.button)"');
expect(html).toContain(">t(balance.priceFetching.button)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortBadge)<");
expect(html).not.toContain(">best-effort<");
expect(html).toContain('aria-label="t(common.close)"');
});
});

View file

@ -201,7 +201,7 @@ export default function PriceFetchControl({
{/* Best-effort badge (stock only) */}
{assetType === "stock" && (
<span className="ml-0.5 text-[9px] uppercase tracking-wide px-1 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
best-effort
{t("balance.priceFetching.bestEffortBadge")}
</span>
)}
</button>

View file

@ -0,0 +1,217 @@
// BudgetTable and the getting-started card's target (#400). No jsdom:
// react-dom/server renders in vitest's node environment. The collapse hook is
// replaced by the real pure helpers over a state the test sets (the real hook
// hydrates in an effect, which a static render never runs), and the
// subtotal-position preference is read from a stubbed localStorage. What is
// pinned is where the one highlight lands: "Expand all" or this month's cell
// of the first editable row shown.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
const harness = vi.hoisted(() => ({
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
expanded: new Set<string>(),
}));
vi.mock("../../hooks/useCollapsibleGroups", async () => {
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false);
return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed,
toggle: () => undefined,
expandAll: () => undefined,
collapseAll: () => undefined,
allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((key) => harness.expanded.has(key));
},
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
};
},
};
});
import BudgetTable, { BUDGET_CELL_TARGET, BUDGET_EXPAND_ALL_TARGET } from "./BudgetTable";
const leaf = (
category_id: number,
category_name: string,
category_type: BudgetYearRow["category_type"],
parent_id: number | null = null,
): BudgetYearRow => ({
category_id,
category_name,
category_color: "#9ca3af",
category_type,
parent_id,
is_parent: false,
depth: parent_id === null ? 0 : 1,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
});
const group = (
category_id: number,
category_name: string,
category_type: BudgetYearRow["category_type"],
): BudgetYearRow => ({ ...leaf(category_id, category_name, category_type), is_parent: true });
/** Two groups, in the order `buildBudgetYearRows` emits them: income first. */
const GROUPED: BudgetYearRow[] = [
group(1, "Revenus", "income"),
leaf(2, "Salaire", "income", 1),
group(10, "Logement", "expense"),
leaf(11, "Loyer", "expense", 10),
leaf(12, "Électricité", "expense", 10),
];
/** A category without sub-category stays on screen while the groups are collapsed. */
const WITH_STANDALONE: BudgetYearRow[] = [...GROUPED, leaf(30, "Divers", "expense")];
/** No group at all. */
const FLAT: BudgetYearRow[] = [leaf(30, "Divers", "expense"), leaf(31, "Cadeaux", "expense")];
const MARCH = 2;
const DECEMBER = 11;
const render = (rows: BudgetYearRow[], onboardingTarget?: boolean) =>
renderToStaticMarkup(
<BudgetTable
rows={rows}
onUpdatePlanned={() => undefined}
onSplitEvenly={() => undefined}
onboardingTarget={onboardingTarget}
/>,
);
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
/** The one highlighted control: its target key and its inner markup. */
function highlighted(html: string): { target: string; inner: string } {
expect(count(html, HIGHLIGHT)).toBe(1);
const match = html.match(/<button [^>]*data-onboarding-target="([^"]+)" data-onboarding-highlight="true"[^>]*>(.*?)<\/button>/);
expect(match).not.toBeNull();
return { target: match![1], inner: match![2] };
}
/** The markup of the row named `name`, from its `<tr` to its `</tr>`. */
function rowOf(html: string, name: string): string {
const at = html.indexOf(`>${name}<`);
expect(at).toBeGreaterThan(-1);
return html.slice(html.lastIndexOf("<tr", at), html.indexOf("</tr>", at));
}
/** The month (0-11) of the highlighted cell in a row, -1 for none: name, last year and annual come first. */
function ringedMonth(row: string): number {
const cell = row
.split("<td")
.slice(1)
.findIndex((td) => td.includes(HIGHLIGHT));
return cell === -1 ? -1 : cell - 3;
}
beforeEach(() => {
harness.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, MARCH, 15));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("BudgetTable — the getting-started card's target (#400)", () => {
it("groups collapsed: 'Expand all' is the one target, no cell", () => {
const html = render(GROUPED, true);
const target = highlighted(html);
expect(target.target).toBe(BUDGET_EXPAND_ALL_TARGET);
expect(target.inner).toContain("t(reports.collapse.expandAll)");
expect(html).not.toContain(BUDGET_CELL_TARGET);
});
it("a group open: this month's cell of its first editable row is the one target", () => {
harness.expanded = new Set(["p:10"]);
const html = render(GROUPED, true);
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
expect(ringedMonth(rowOf(html, "Électricité"))).toBe(-1);
// The group's subtotal row has no button to point at.
expect(ringedMonth(rowOf(html, "Logement"))).toBe(-1);
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
});
it("every group open: the first editable row of the grid, income first", () => {
harness.expanded = new Set(["p:1", "p:10"]);
const html = render(GROUPED, true);
expect(ringedMonth(rowOf(html, "Salaire"))).toBe(MARCH);
expect(count(html, HIGHLIGHT)).toBe(1);
});
it("a category without sub-category shown beside collapsed groups: its cell, 'Expand all' stays plain", () => {
const html = render(WITH_STANDALONE, true);
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
expect(html).toContain("t(reports.collapse.expandAll)");
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
});
it("no groups: no 'Expand all' at all, the first row's cell", () => {
const html = render(FLAT, true);
expect(html).not.toContain("t(reports.collapse.expandAll)");
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
expect(count(html, HIGHLIGHT)).toBe(1);
});
describe("in a timezone west of Greenwich", () => {
// Under TZ=UTC (the CI runner), 23:00 on December 31 is December both in
// local time and in UTC, so a month read in UTC would pass unnoticed. The
// zone is pinned as in useImportWizard.test.ts (#356).
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = "America/Toronto";
});
afterAll(() => {
if (originalTz === undefined) delete process.env.TZ;
else process.env.TZ = originalTz;
});
it("the cell follows the clock's month", () => {
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
});
});
it("subtotals at the bottom: still the first editable row shown", () => {
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });
harness.expanded = new Set(["p:10"]);
const html = render(GROUPED, true);
expect(html).toContain("t(reports.subtotalsOnBottom)");
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
expect(count(html, HIGHLIGHT)).toBe(1);
});
it("without `onboardingTarget`, nothing is highlighted", () => {
for (const expanded of [[], ["p:10"]]) {
harness.expanded = new Set(expanded);
for (const onboardingTarget of [false, undefined]) {
const html = render(GROUPED, onboardingTarget);
expect(count(html, HIGHLIGHT)).toBe(0);
expect(html).not.toContain("data-onboarding-target");
}
}
});
it("no row: the grid's 'no category' box, nothing highlighted", () => {
const html = render([], true);
expect(html).toContain("t(budget.noCategories)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
});

View file

@ -1,11 +1,13 @@
import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types";
import type { BudgetOnboardingTargetKind, BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
const fmt = new Intl.NumberFormat("en-CA", {
style: "currency",
@ -37,13 +39,35 @@ const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
/** `data-onboarding-target` of the highlighted month cell. */
export const BUDGET_CELL_TARGET = "budget-cell";
/** `data-onboarding-target` of the highlighted "Expand all" button. */
export const BUDGET_EXPAND_ALL_TARGET = "budget-expand-all";
interface BudgetTableProps {
rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
/**
* Highlight the control the getting-started card points at (#400): this
* month's cell of the first editable row shown, or "Expand all" while none
* is shown (`budgetOnboardingTarget`). One target at a time.
*/
onboardingTarget?: boolean;
/**
* Told which control that is, at mount and whenever it changes: the
* collapse state lives here, and the card's sentence depends on it.
*/
onOnboardingTargetChange?: (kind: BudgetOnboardingTargetKind | null) => void;
}
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
export default function BudgetTable({
rows,
onUpdatePlanned,
onSplitEvenly,
onboardingTarget,
onOnboardingTargetChange,
}: BudgetTableProps) {
const { t } = useTranslation();
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
@ -177,6 +201,30 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
const totalCols = 15; // category + prev year + annual + 12 months
// The rows each section shows, in the order it shows them: collapse-filtered
// (issue #289), then subtotals moved to the chosen side.
const shownByType: Record<string, BudgetYearRow[]> = {};
for (const type of typeOrder) {
shownByType[type] = reorderRows(groups.visible(grouped[type] ?? []), subtotalsOnTop);
}
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
// The control the getting-started card points at (#400, D12): this month's
// cell of the first editable row shown, else "Expand all". Its kind goes up
// to the page, which picks the card's sentence; the grid stays as the user
// left it (the card never unfolds it).
const target = budgetOnboardingTarget(
typeOrder.flatMap((type) => shownByType[type]),
hasGroups,
new Date().getMonth(),
);
const targetKind = target?.kind ?? null;
useEffect(() => {
onOnboardingTargetChange?.(targetKind);
}, [targetKind, onOnboardingTargetChange]);
const targetCell = onboardingTarget && target?.kind === "cell" ? target : null;
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
@ -321,6 +369,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
title={t("budget.clickToEdit")}
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
{...onboardingTargetAttributes(
BUDGET_CELL_TARGET,
targetCell !== null &&
targetCell.categoryId === row.category_id &&
targetCell.monthIdx === mIdx,
)}
>
{formatSigned(val * sign)}
</button>
@ -352,7 +406,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{t(typeLabelKeys[type])}
</td>
</tr>
{reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))}
{shownByType[type].map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])}
@ -395,9 +449,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
);
};
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
@ -406,6 +457,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
type="button"
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
{...onboardingTargetAttributes(
BUDGET_EXPAND_ALL_TARGET,
onboardingTarget && target?.kind === "expandAll",
)}
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}

View file

@ -0,0 +1,129 @@
// BudgetTable — how the grid tells the page its target (#400, #409).
//
// renderToStaticMarkup runs no effect, so the report would stay unguarded.
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// grid runs as a plain function, its state and refs survive from one call to
// the next as between two renders of a mounted component, and `commit()` runs
// the effects as React does, an effect only when one of its dependencies
// changed since its last run. The collapse hook is replaced by the real pure
// helpers over a state the test sets.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
expanded: new Set<string>(),
}));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("../../hooks/useCollapsibleGroups", async () => {
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
const isCollapsed = (row: T) => isCollapsedFor(ctx.expanded, acc.keyOf(row), false);
return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed,
toggle: () => undefined,
expandAll: () => undefined,
collapseAll: () => undefined,
allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((key) => ctx.expanded.has(key));
},
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
};
},
};
});
import { hookHarness } from "../../test/hookHarness";
import BudgetTable from "./BudgetTable";
import type { BudgetOnboardingTargetKind } from "../../shared/types";
const leaf = (category_id: number, parent_id: number | null = null): BudgetYearRow => ({
category_id,
category_name: `Category ${category_id}`,
category_color: "#9ca3af",
category_type: "expense",
parent_id,
is_parent: false,
depth: parent_id === null ? 0 : 1,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
});
const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)];
const harness = hookHarness();
/** One render of the mounted grid: its hooks are called in the same order each time. */
function render(
rows: BudgetYearRow[],
onChange: (kind: BudgetOnboardingTargetKind | null) => void,
onboardingTarget?: boolean,
) {
harness.render(() =>
BudgetTable({
rows,
onUpdatePlanned: () => undefined,
onSplitEvenly: () => undefined,
onboardingTarget,
onOnboardingTargetChange: onChange,
}),
);
}
/** After a render: run each effect whose dependencies changed since its last run, as React does. */
const commit = () => harness.commit();
beforeEach(() => {
harness.clear();
ctx.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("BudgetTable — the target told to the page (#400)", () => {
it("tells it once mounted, then only when it changes", () => {
const onChange = vi.fn();
render(GROUPED, onChange, true);
commit();
expect(onChange.mock.calls).toEqual([["expandAll"]]);
// Another render with nothing changed: not told again.
render(GROUPED, onChange, true);
commit();
expect(onChange).toHaveBeenCalledTimes(1);
// The user opens the group: an editable row shows.
ctx.expanded = new Set(["p:10"]);
render(GROUPED, onChange, true);
commit();
render(GROUPED, onChange, true);
commit();
expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"]]);
// The rows are gone: nothing to point at.
render([], onChange, true);
commit();
expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"], [null]]);
});
it("tells it before any highlight is asked: the page needs it for the card's sentence", () => {
const onChange = vi.fn();
render([leaf(30)], onChange);
commit();
expect(onChange.mock.calls).toEqual([["cell"]]);
});
it("tells nothing during a render", () => {
const onChange = vi.fn();
render(GROUPED, onChange, true);
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BookTemplate, Save, Trash2 } from "lucide-react";
import type { BudgetTemplate } from "../../shared/types";
import { confirmAction } from "../../services/confirmService";
interface TemplateActionsProps {
templates: BudgetTemplate[];
@ -56,9 +57,9 @@ export default function TemplateActions({
setShowSave(false);
};
const handleDelete = (e: React.MouseEvent, templateId: number) => {
const handleDelete = async (e: React.MouseEvent, templateId: number) => {
e.stopPropagation();
if (confirm(t("budget.deleteTemplateConfirm"))) {
if (await confirmAction(t("budget.deleteTemplateConfirm"))) {
onDelete(templateId);
}
};

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
import type { BudgetYearRow } from "../../shared/types";
/** A grid row; a leaf at the top level unless `overrides` says otherwise. */
const row = (category_id: number, overrides: Partial<BudgetYearRow> = {}): BudgetYearRow => ({
category_id,
category_name: `Category ${category_id}`,
category_color: "#9ca3af",
category_type: "expense",
parent_id: null,
is_parent: false,
depth: 0,
months: Array(12).fill(0) as number[],
annual: 0,
previousYearTotal: 0,
...overrides,
});
/** A group's subtotal row: read-only. */
const group = (category_id: number, overrides: Partial<BudgetYearRow> = {}) =>
row(category_id, { is_parent: true, ...overrides });
/** A leaf under `parent_id`. */
const child = (category_id: number, parent_id: number, depth = 1) =>
row(category_id, { parent_id, depth });
const OCTOBER = 9;
describe("budgetOnboardingTarget (#400, D12)", () => {
it("groups collapsed: 'Expand all', the editable rows being folded away", () => {
// Collapsed, the grid shows its top-level subtotals only.
expect(budgetOnboardingTarget([group(10), group(20)], true, OCTOBER)).toEqual({
kind: "expandAll",
});
// A group open on a sub-group still folded: still no editable row shown.
expect(
budgetOnboardingTarget([group(10), group(11, { parent_id: 10, depth: 1 })], true, OCTOBER),
).toEqual({ kind: "expandAll" });
});
it("the first leaf shown: its cell in the given month, past the subtotal rows above it", () => {
expect(budgetOnboardingTarget([group(10), child(13, 10), child(14, 10)], true, OCTOBER)).toEqual({
kind: "cell",
categoryId: 13,
monthIdx: OCTOBER,
});
// A sub-group's subtotal before the leaves is skipped too.
expect(
budgetOnboardingTarget(
[group(10), group(11, { parent_id: 10, depth: 1 }), child(15, 11, 2)],
true,
OCTOBER,
),
).toEqual({ kind: "cell", categoryId: 15, monthIdx: OCTOBER });
});
it("subtotals at the bottom: the first leaf, not the subtotal under it", () => {
expect(budgetOnboardingTarget([child(13, 10), child(14, 10), group(10)], true, OCTOBER)).toEqual({
kind: "cell",
categoryId: 13,
monthIdx: OCTOBER,
});
});
it("a '(direct)' leaf is a leaf: its cell, though it shares its group's category id", () => {
expect(
budgetOnboardingTarget([group(10), row(10, { parent_id: 10, depth: 1 }), child(13, 10)], true, 0),
).toEqual({ kind: "cell", categoryId: 10, monthIdx: 0 });
});
it("a leaf shown while the groups are collapsed (a category without sub-category): already a cell", () => {
expect(budgetOnboardingTarget([group(10), row(30), group(20)], true, OCTOBER)).toEqual({
kind: "cell",
categoryId: 30,
monthIdx: OCTOBER,
});
});
it("without groups: the first row's cell", () => {
expect(budgetOnboardingTarget([row(30), row(31)], false, OCTOBER)).toEqual({
kind: "cell",
categoryId: 30,
monthIdx: OCTOBER,
});
});
it("no row: nothing to point at", () => {
expect(budgetOnboardingTarget([], false, OCTOBER)).toBeNull();
expect(budgetOnboardingTarget([], false, 0)).toBeNull();
});
it("keeps the month it is given, January to December", () => {
for (const monthIdx of [0, 5, 11]) {
expect(budgetOnboardingTarget([row(30)], false, monthIdx)).toEqual({
kind: "cell",
categoryId: 30,
monthIdx,
});
}
});
});

View file

@ -0,0 +1,33 @@
import type { BudgetYearRow } from "../../shared/types";
/**
* The control of the budget grid that the getting-started card points at
* (#400): the month cell of an editable row, or "Expand all".
*/
export type BudgetOnboardingTarget =
| { kind: "cell"; categoryId: number; monthIdx: number }
| { kind: "expandAll" };
/**
* Which control of the budget grid the Budget step card points at (#400, D12).
*
* `visibleRows` are the rows the grid shows, in the order it shows them. A
* subtotal row is read-only and a row folded under a collapsed group is not on
* screen, so the target is:
* - the `monthIdx` cell of the first leaf row shown;
* - otherwise "Expand all" when the grid has groups: its editable rows are
* folded away, and the card never unfolds them for the user;
* - otherwise null: no row at all, nothing to point at.
*
* Pure (no React, no storage), like `budgetTableResults.ts`, so the rule is
* tested without rendering the grid and its collapse hook.
*/
export function budgetOnboardingTarget(
visibleRows: readonly BudgetYearRow[],
hasGroups: boolean,
monthIdx: number,
): BudgetOnboardingTarget | null {
const leaf = visibleRows.find((row) => !row.is_parent);
if (leaf) return { kind: "cell", categoryId: leaf.category_id, monthIdx };
return hasGroups ? { kind: "expandAll" } : null;
}

View file

@ -0,0 +1,66 @@
// AddKeywordDialog — the category field is a combobox placed outside the
// scrolling block (#423). No jsdom: react-dom/server renders the initial
// markup, before any effect runs, in vitest's node environment.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("./keywordCategory", () => ({
loadKeywordCategories: vi.fn(() => new Promise(() => undefined)),
reconcileInitialCategory: vi.fn(() => null),
}));
vi.mock("../../services/categorizationService", () => ({
KEYWORD_MAX_LENGTH: 50,
KEYWORD_MIN_LENGTH: 3,
KEYWORD_PREVIEW_LIMIT: 50,
applyKeywordWithReassignment: vi.fn(),
previewKeywordMatches: vi.fn(() => new Promise(() => undefined)),
validateKeyword: (raw: string) => {
const value = raw.trim();
return value.length >= 3 ? { ok: true, value } : { ok: false, reason: "tooShort" };
},
}));
import AddKeywordDialog from "./AddKeywordDialog";
const markup = (initialCategoryId: number | null = null) =>
renderToStaticMarkup(
<AddKeywordDialog
initialKeyword="METRO"
initialCategoryId={initialCategoryId}
onClose={vi.fn()}
onApplied={vi.fn()}
/>,
);
describe("AddKeywordDialog — category field (#423)", () => {
it("renders the category as a combobox and no select", () => {
const html = markup();
expect(html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1);
expect(html).not.toContain("<select");
});
it("starts empty, with the 'no category' placeholder, until the list is in", () => {
const [input] = markup(7).match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(input).toContain('value=""');
expect(input).toContain('placeholder="t(transactions.table.noCategory)"');
});
it("keeps the apply button disabled before the list is in", () => {
const html = markup(7);
const [apply] = html.match(/<button [^>]*>t\(reports\.keyword\.applyAndRecategorize\)<\/button>/g) ?? [];
expect(apply).toBeDefined();
expect(apply).toContain("disabled");
});
it("places the combobox before any scrolling block, so its list is not clipped", () => {
const html = markup();
const at = html.indexOf('role="combobox"');
expect(at).toBeGreaterThan(0);
expect(html.slice(0, at)).not.toContain("overflow-y-auto");
// The preview block below still scrolls on its own.
expect(html.slice(at)).toContain("overflow-y-auto");
});
});

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
import type { Category, RecentTransaction } from "../../shared/types";
import {
KEYWORD_MAX_LENGTH,
KEYWORD_MIN_LENGTH,
@ -9,12 +9,8 @@ import {
previewKeywordMatches,
validateKeyword,
} from "../../services/categorizationService";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
}
import CategoryCombobox from "../shared/CategoryCombobox";
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
export interface AddKeywordDialogProps {
initialKeyword: string;
@ -37,8 +33,11 @@ export default function AddKeywordDialog({
}: AddKeywordDialogProps) {
const { t } = useTranslation();
const [keyword, setKeyword] = useState(initialKeyword);
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId);
const [categories, setCategories] = useState<CategoryOption[]>([]);
// Held at null until the list is in, which keeps the apply button disabled
// meanwhile: the pre-filled category is only kept when it is among the
// categories the dialog offers (`reconcileInitialCategory`).
const [categoryId, setCategoryId] = useState<number | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [applyToHidden, setApplyToHidden] = useState(false);
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
@ -50,10 +49,22 @@ export default function AddKeywordDialog({
const validation = useMemo(() => validateKeyword(keyword), [keyword]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name }))))
.catch(() => setCategories([]));
}, []);
let cancelled = false;
loadKeywordCategories()
.then((rows) => {
if (cancelled) return;
setCategories(rows);
setCategoryId(reconcileInitialCategory(initialCategoryId, rows));
})
.catch(() => {
if (cancelled) return;
setCategories([]);
setCategoryId(null);
});
return () => {
cancelled = true;
};
}, [initialCategoryId]);
useEffect(() => {
if (!validation.ok) {
@ -135,7 +146,10 @@ export default function AddKeywordDialog({
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header>
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
{/* The two fields sit outside the scrolling block, so the open category
list is drawn over the preview below instead of being cut by its
scrollport. */}
<div className="px-5 pt-4 flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.keyword.dialogTitle")}
@ -160,19 +174,16 @@ export default function AddKeywordDialog({
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.category")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value="">—</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<CategoryCombobox
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t("transactions.table.noCategory")}
/>
</label>
</div>
<div className="px-5 pb-4 pt-4 flex flex-col gap-4 overflow-y-auto min-h-0">
<section>
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">

View file

@ -0,0 +1,89 @@
// CategoryDetailPanel — the edited category's id reaches the parent options (#423).
// No jsdom: react-dom/server renders in vitest's node environment.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import CategoryDetailPanel from "./CategoryDetailPanel";
import { buildParentOptions } from "./parentOptions";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
const node = (id: number, parent_id: number | null, name: string): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
const HOUSING = node(1, null, "Logement");
const RENT = node(10, 1, "Loyer");
const CATEGORIES = [HOUSING, RENT];
const formOf = (c: CategoryTreeNode): CategoryFormData => ({
name: c.name,
type: c.type,
color: "#4A90A4",
parent_id: c.parent_id,
is_inputable: c.is_inputable,
sort_order: c.sort_order,
});
const markup = (selected: CategoryTreeNode | null, editing: CategoryFormData | null, isCreating: boolean) =>
renderToStaticMarkup(
<CategoryDetailPanel
categories={CATEGORIES}
selectedCategory={selected}
keywords={[]}
editingCategory={editing}
isCreating={isCreating}
isSaving={false}
onStartEditing={vi.fn()}
onCancelEditing={vi.fn()}
onSave={vi.fn()}
onDelete={vi.fn(async () => ({ blocked: false, count: 0 }))}
onAddKeyword={vi.fn()}
onUpdateKeyword={vi.fn()}
onRemoveKeyword={vi.fn()}
/>,
);
describe("CategoryDetailPanel — editingId wiring (#423)", () => {
it("passes the selected category's id when editing", () => {
vi.mocked(buildParentOptions).mockClear();
const html = markup(RENT, formOf(RENT), false);
expect(html).toContain('role="combobox"');
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
});
it("passes no id when creating", () => {
vi.mocked(buildParentOptions).mockClear();
const html = markup(null, formOf(node(0, null, "")), true);
expect(html).toContain('role="combobox"');
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
});
});
describe("CategoryDetailPanel — displayed texts (#389)", () => {
it("titles the edit view and the create view through t", () => {
expect(markup(RENT, formOf(RENT), false)).toContain(">t(categories.editCategory)<");
expect(markup(null, formOf(node(0, null, "")), true)).toContain(">t(categories.addCategory)<");
});
it("asks for a selection through t when nothing is selected", () => {
expect(markup(null, null, false)).toContain(">t(categories.selectCategory)<");
});
});

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil } from "lucide-react";
import type { CategoryTreeNode, CategoryFormData, Keyword } from "../../shared/types";
import { confirmAction } from "../../services/confirmService";
import CategoryForm from "./CategoryForm";
import KeywordList from "./KeywordList";
@ -41,7 +42,7 @@ export default function CategoryDetailPanel({
const handleDelete = async () => {
if (!selectedCategory) return;
if (!confirm(t("categories.deleteConfirm"))) return;
if (!(await confirmAction(t("categories.deleteConfirm")))) return;
setDeleteError(null);
const result = await onDelete(selectedCategory.id);
if (result.blocked) {
@ -90,6 +91,7 @@ export default function CategoryDetailPanel({
<CategoryForm
initialData={editingCategory}
categories={categories}
editingId={selectedCategory.id}
isCreating={false}
isSaving={isSaving}
onSave={onSave}

View file

@ -0,0 +1,108 @@
// CategoryForm — the parent field is a combobox, not a select (#423). No jsdom:
// react-dom/server renders in vitest's node environment; the markup says what
// the closed combobox shows as its value.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// Pass-through spy on the pure module, to pin what the form hands it.
vi.mock("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import CategoryForm from "./CategoryForm";
import { buildParentOptions } from "./parentOptions";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
const node = (
id: number,
parent_id: number | null,
name: string,
i18n_key: string | null = null,
): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key,
children: [],
});
const HOUSING = node(1, null, "Logement", "categoriesSeed.housing");
const RENT = node(10, 1, "Loyer");
const CATEGORIES = [HOUSING, RENT];
const form = (parent_id: number | null): CategoryFormData => ({
name: "Taxes",
type: "expense",
color: "#4A90A4",
parent_id,
is_inputable: true,
sort_order: 0,
});
const markup = (data: CategoryFormData, editingId?: number) =>
renderToStaticMarkup(
<CategoryForm
initialData={data}
categories={CATEGORIES}
editingId={editingId}
isCreating={editingId === undefined}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);
/** The parent combobox input (the only `role="combobox"` in the form). */
const comboboxInput = (html: string) => {
const inputs = html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(inputs).toHaveLength(1);
return inputs[0];
};
describe("CategoryForm — parent field (#423)", () => {
it("renders the parent as a combobox labelled by the field name, with no parent select left", () => {
const html = markup(form(null));
const input = comboboxInput(html);
expect(input).toContain('aria-label="t(categories.parent)"');
// Only the type select remains.
expect(html.match(/<select/g) ?? []).toHaveLength(1);
expect(html).not.toContain("categories.noParent</option>");
});
it("shows the 'no parent' option as the value when creating without a parent", () => {
expect(comboboxInput(markup(form(null)))).toContain('value="t(categories.noParent)"');
});
it("shows the parent's translated name as the value when editing a category with a parent", () => {
// Seed rows translate through their i18n_key with the name as default value.
expect(comboboxInput(markup(form(1), 10))).toContain(
'value="t(categoriesSeed.housing[defaultValue=Logement])"',
);
});
it("shows a user-created parent by its name", () => {
expect(comboboxInput(markup(form(10), 42))).toContain('value="Loyer"');
});
it("hands the edited id and the stored parent to buildParentOptions", () => {
vi.mocked(buildParentOptions).mockClear();
markup(form(1), 10);
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
});
it("hands no edited id when creating", () => {
vi.mocked(buildParentOptions).mockClear();
markup(form(null));
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
});
});

View file

@ -1,7 +1,9 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
const PRESET_COLORS = [
"#4A90A4",
@ -18,6 +20,8 @@ const PRESET_COLORS = [
interface Props {
initialData: CategoryFormData;
categories: CategoryTreeNode[];
/** Id of the category being edited; omitted when creating. */
editingId?: number | null;
isCreating: boolean;
isSaving: boolean;
onSave: (data: CategoryFormData) => void;
@ -28,6 +32,7 @@ interface Props {
export default function CategoryForm({
initialData,
categories,
editingId = null,
isCreating,
isSaving,
onSave,
@ -41,36 +46,16 @@ export default function CategoryForm({
setForm(initialData);
}, [initialData]);
// Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level)
// Also build indentation info
const parentOptions: Array<CategoryTreeNode & { indent: number }> = [];
for (const cat of categories) {
if (cat.parent_id === null) {
// Level 0 — always allowed as parent
parentOptions.push({ ...cat, indent: 0 });
}
}
for (const cat of categories) {
if (cat.parent_id !== null) {
// Check if this category's parent is a root (making this level 1)
const parent = categories.find((c) => c.id === cat.parent_id);
if (parent && parent.parent_id === null) {
// Level 1 — allowed as parent (would create level 3 children)
parentOptions.push({ ...cat, indent: 1 });
}
// Level 2 categories are NOT shown (would create level 4)
}
}
// Sort to keep hierarchy order: group by root parent sort_order
parentOptions.sort((a, b) => {
const rootA = a.indent === 0 ? a : categories.find((c) => c.id === a.parent_id);
const rootB = b.indent === 0 ? b : categories.find((c) => c.id === b.parent_id);
const orderA = rootA?.sort_order ?? 999;
const orderB = rootB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
if (a.indent !== b.indent) return a.indent - b.indent;
return a.sort_order - b.sort_order;
});
// Roots and level-1 categories, minus the edited category and its descent;
// the combobox orders them hierarchically. See `buildParentOptions`.
const parentOptions = useMemo(
() => buildParentOptions(categories, { editingId, currentParentId: form.parent_id }),
[categories, editingId, form.parent_id],
);
// "No parent" sits first in the list and is how the field goes back to null:
// the combobox itself never emits null.
const noParentExtra = useMemo(() => [{ value: "", label: t("categories.noParent") }], [t]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -130,20 +115,15 @@ export default function CategoryForm({
<div>
<label className="block text-sm font-medium mb-1">{t("categories.parent")}</label>
<select
value={form.parent_id ?? ""}
onChange={(e) =>
setForm({ ...form, parent_id: e.target.value ? Number(e.target.value) : null })
}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("categories.noParent")}</option>
{parentOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
</option>
))}
</select>
<CategoryCombobox
categories={parentOptions}
value={form.parent_id}
onChange={(id) => setForm({ ...form, parent_id: id })}
extraOptions={noParentExtra}
activeExtra={form.parent_id === null ? "" : null}
onExtraSelect={() => setForm({ ...form, parent_id: null })}
ariaLabel={t("categories.parent")}
/>
</div>
<div className="flex items-center gap-2">

View file

@ -0,0 +1,50 @@
// The category list of the "add a keyword" dialog and its pre-filled value (#423, D2).
import { describe, it, expect, vi, beforeEach } from "vitest";
// `vi.mock` is hoisted above the imports: the mocks must come from `vi.hoisted`.
const { getAllCategories, getAllCategoriesWithCounts } = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllCategoriesWithCounts: vi.fn(),
}));
vi.mock("../../services/transactionService", () => ({ getAllCategories }));
vi.mock("../../services/categoryService", () => ({ getAllCategoriesWithCounts }));
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
const cats = [
{ id: 1, name: "Logement", sort_order: 1 },
{ id: 7, name: "Épicerie", sort_order: 2 },
];
describe("loadKeywordCategories", () => {
beforeEach(() => {
getAllCategories.mockReset();
getAllCategoriesWithCounts.mockReset();
});
it("reads the Transactions list (active and inputable), not the full category list", async () => {
getAllCategories.mockResolvedValue(cats);
await expect(loadKeywordCategories()).resolves.toBe(cats);
expect(getAllCategories).toHaveBeenCalledTimes(1);
expect(getAllCategoriesWithCounts).not.toHaveBeenCalled();
});
});
describe("reconcileInitialCategory", () => {
it("keeps the pre-filled category when it is in the list", () => {
expect(reconcileInitialCategory(7, cats)).toBe(7);
});
it("drops a pre-filled category that is not in the list", () => {
expect(reconcileInitialCategory(42, cats)).toBeNull();
});
it("returns null without a pre-filled category", () => {
expect(reconcileInitialCategory(null, cats)).toBeNull();
expect(reconcileInitialCategory(undefined, cats)).toBeNull();
});
it("returns null when the list is empty", () => {
expect(reconcileInitialCategory(7, [])).toBeNull();
});
});

View file

@ -0,0 +1,25 @@
import type { Category } from "../../shared/types";
import { getAllCategories } from "../../services/transactionService";
import type { CategoryLike } from "../shared/CategoryCombobox";
/**
* Categories offered by the "add a keyword" dialog: the same list as the
* category cell of the Transactions page (active and inputable), so a keyword
* can only target a category the user can also pick by hand (#423, D2).
*/
export function loadKeywordCategories(): Promise<Category[]> {
return getAllCategories();
}
/**
* The category pre-filled by the right-click menu is kept only when it is in
* the list; a category the user cannot see (hidden from input, inactive) is
* dropped so the dialog never applies an id that is not shown.
*/
export function reconcileInitialCategory(
initialId: number | null | undefined,
categories: CategoryLike[],
): number | null {
if (initialId == null) return null;
return categories.some((c) => c.id === initialId) ? initialId : null;
}

View file

@ -0,0 +1,106 @@
// buildParentOptions — which categories the category form offers as a parent (#423).
import { describe, it, expect } from "vitest";
import { buildParentOptions } from "./parentOptions";
import type { CategoryTreeNode } from "../../shared/types";
const node = (id: number, parent_id: number | null, name = `c${id}`): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
// Two roots. Root 1 has a child (10) with its own child (100), and a leaf child (11).
// Root 2 has a leaf child (20).
const TREE = [node(1, null), node(10, 1), node(100, 10), node(11, 1), node(2, null), node(20, 2)];
const ids = (list: CategoryTreeNode[]) => list.map((c) => c.id).sort((a, b) => a - b);
describe("buildParentOptions — creating a category", () => {
it("offers roots and level-1 categories, never level 2", () => {
expect(ids(buildParentOptions(TREE))).toEqual([1, 2, 10, 11, 20]);
});
it("offers roots alone when there is nothing deeper", () => {
expect(ids(buildParentOptions([node(1, null), node(2, null)]))).toEqual([1, 2]);
});
it("treats a category whose parent is missing as a root", () => {
// 10's parent (1) is not in the list: 10 is a root, 100 a level-1 child.
expect(ids(buildParentOptions([node(10, 1), node(100, 10), node(1000, 100)]))).toEqual([10, 100]);
});
it("returns [] for an empty list", () => {
expect(buildParentOptions([])).toEqual([]);
});
});
describe("buildParentOptions — editing a category", () => {
it("leaves out the edited category itself", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11 }))).toEqual([1, 2, 10, 20]);
});
it("offers nothing when the edited category has grandchildren", () => {
// Root 1 carries 10 -> 100: under any parent, 100 would sit at level 3.
// Probe of the review of PR #428: `[1, 10->1, 100->10, 2]`, editing 1.
expect(ids(buildParentOptions([node(1, null), node(10, 1), node(100, 10), node(2, null)], { editingId: 1 }))).toEqual([]);
expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([]);
});
it("leaves out the whole descent of the edited category", () => {
// Editing 10 (child 100): a level-1 category with a child; 100 stays out.
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100);
// Editing root 2 (child 20): only roots remain, so 20 stays out.
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).not.toContain(20);
});
it("keeps only roots when the edited category has children but no grandchildren", () => {
// 10 has a child (100): under a level-1 parent its child would sit at level 3.
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).toEqual([1, 2]);
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).toEqual([1]);
});
it("keeps level-1 categories when the edited category is a leaf", () => {
expect(ids(buildParentOptions(TREE, { editingId: 20 }))).toEqual([1, 2, 10, 11]);
});
it("always keeps the parent currently set on the form, even one the rules would drop", () => {
// Legacy data: 1000 sits under 100 (level 3). Editing 1000, its parent 100 is
// level 2 and would be dropped; it stays so the field shows what is stored.
const deep = [...TREE, node(1000, 100)];
expect(ids(buildParentOptions(deep, { editingId: 1000, currentParentId: 100 }))).toEqual([
1, 2, 10, 11, 20, 100,
]);
});
it("does not re-add the edited category as its own current parent", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 11 }))).toEqual([1, 2, 10, 20]);
});
it("ignores a current parent that is not in the list", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 999 }))).toEqual([1, 2, 10, 20]);
});
it("terminates on a cycle and never offers the edited category", () => {
// Which members of the cycle are offered is not specified (#393 repairs
// cycles); the walk must end, and 3 must stay out.
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
const offered = ids(buildParentOptions(cyclic, { editingId: 3 }));
expect(offered).not.toContain(3);
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
});
it("terminates when the edited category is itself a member of a cycle", () => {
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
// unbounded). Only roots remain, so the root outside the cycle is offered.
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
expect(ids(buildParentOptions(cyclic, { editingId: 1 }))).toEqual([3]);
});
});

View file

@ -0,0 +1,109 @@
import type { CategoryTreeNode } from "../../shared/types";
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
const MAX_PARENT_DEPTH = 1;
export interface ParentOptionsContext {
/** Id of the category being edited; absent or null when creating. */
editingId?: number | null;
/**
* Parent currently stored on the form. Always kept in the options when it
* exists, so a legacy or inconsistent parent never leaves the field blank
* while `parent_id` is set.
*/
currentParentId?: number | null;
}
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
const byId = new Map<number, CategoryTreeNode>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
const depthOf = (id: number, seen: Set<number>): number => {
const known = depths.get(id);
if (known !== undefined) return known;
if (seen.has(id)) return 0; // cycle: treat as a root rather than loop
seen.add(id);
const cat = byId.get(id);
const parentId = cat?.parent_id ?? null;
// A missing parent (orphan) counts as a root, like the tree and the combobox do.
const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1;
depths.set(id, d);
return d;
};
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
/**
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
* walk can never take more steps than there are categories.
*/
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
const childrenOf = new Map<number, number[]>();
for (const c of categories) {
if (c.parent_id === null) continue;
const bucket = childrenOf.get(c.parent_id) ?? [];
bucket.push(c.id);
childrenOf.set(c.parent_id, bucket);
}
let height = 0;
const seen = new Set<number>([rootId]);
let frontier = [rootId];
while (frontier.length > 0 && height < categories.length) {
const next: number[] = [];
for (const id of frontier) {
for (const child of childrenOf.get(id) ?? []) {
if (seen.has(child)) continue;
seen.add(child);
next.push(child);
}
}
if (next.length === 0) break;
height += 1;
frontier = next;
}
return height;
}
/**
* Categories that may be chosen as the parent in the category form.
*
* - Roots and level-1 categories only: a level-2 parent would create a fourth
* level, which nothing else in the app knows how to display.
* - When editing, the edited category is left out, and the allowed parent depth
* shrinks by the height of its sub-tree: with children, only roots remain
* (the children would otherwise land at level 3); with grandchildren, no
* parent at all (they would land at level 4). This alone also leaves out its
* whole descent, which would create a cycle that hides the branch everywhere
* (#393): a descendant sits at least one level below the edited category,
* deeper than any depth still allowed.
* - The parent currently set on the form is always kept when it exists.
*
* This list is the only guard: `updateCategory` writes any `parent_id` it is
* given, without a depth or cycle check (#393 will add one). The form therefore
* never offers a new parent that the rules above reject.
*
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
*/
export function buildParentOptions(
categories: CategoryTreeNode[],
ctx: ParentOptionsContext = {},
): CategoryTreeNode[] {
const editingId = ctx.editingId ?? null;
const currentParentId = ctx.currentParentId ?? null;
const depths = depthsOf(categories);
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
// Negative when the edited category has grandchildren: only the current parent survives.
const maxDepth = MAX_PARENT_DEPTH - height;
return categories.filter((c) => {
if (c.id === editingId) return false;
// Kept even when the depth rule would drop it: the field must show what is
// stored. Keeping it changes nothing on save (it is already the parent);
// repairing such data is the job of #393.
if (c.id === currentParentId) return true;
return (depths.get(c.id) ?? 0) <= maxDepth;
});
}

View file

@ -0,0 +1,72 @@
// DashboardEmptyState — the welcome card of a profile without transactions
// (#352). #397 leaves it as it was: the card keeps the detailed list, which
// now also takes the "Resume" handler. No jsdom: react-dom/server for the
// markup, a function call for the wiring.
import { describe, it, expect, vi } from "vitest";
import type { ReactElement, ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import DashboardEmptyState from "./DashboardEmptyState";
import OnboardingChecklist from "./OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
const checklist = computeChecklist(
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
{ importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true },
);
function cardProps(hidden = false) {
return {
checklist,
budgetReady: true,
visitStepsInitialized: true,
hidden,
onHide: vi.fn(),
onResume: vi.fn(),
};
}
const markup = (hidden = false) =>
renderToStaticMarkup(
<MemoryRouter>
<DashboardEmptyState {...cardProps(hidden)} />
</MemoryRouter>,
);
describe("DashboardEmptyState (#352, #397)", () => {
it("shows the welcome card, its action towards Import and the detailed list", () => {
const html = markup();
expect(html).toContain("t(dashboard.empty.title)");
expect(html).toContain("t(dashboard.empty.description)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=8])");
});
it("keeps the card and drops the list once the path is hidden", () => {
const html = markup(true);
expect(html).toContain("t(dashboard.empty.title)");
expect(html).not.toContain("onboarding.checklist.title");
});
it("hands its list the path's handlers", () => {
const props = cardProps();
const card = DashboardEmptyState(props) as ReactElement<{ children?: ReactNode }>;
const list = card.props.children as ReactElement<Record<string, unknown>>;
expect(list.type).toBe(OnboardingChecklist);
expect(list.props.items).toBe(checklist.items);
expect(list.props.budgetReady).toBe(true);
expect(list.props.visitStepsInitialized).toBe(true);
const waiting = DashboardEmptyState({ ...props, visitStepsInitialized: false }) as ReactElement<{
children?: ReactNode;
}>;
expect(
(waiting.props.children as ReactElement<Record<string, unknown>>).props.visitStepsInitialized,
).toBe(false);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
});
});

View file

@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import { LayoutDashboard } from "lucide-react";
import EmptyState from "../shared/EmptyState";
import OnboardingChecklist from "./OnboardingChecklist";
import type { Checklist } from "../../utils/onboardingChecklist";
import type { ChecklistItemKey } from "../../shared/types";
interface DashboardEmptyStateProps {
checklist: Checklist;
budgetReady: boolean;
/** The visit steps are initialized (#425): their rows stay "pending" until then. */
visitStepsInitialized: boolean;
/** The user dismissed the checklist: the welcome card stays, the list goes. */
hidden: boolean;
onHide: () => void;
/** "Resume" on a skipped row of the list (#397). */
onResume: (key: ChecklistItemKey) => void;
}
/**
* Dashboard of a profile without any transaction (#352): a welcome card with
* the detailed getting-started checklist and one primary action towards the
* import page, in place of widgets showing 0 $. The getting-started banner
* stays off this page (D11): the card already holds the list.
*/
export default function DashboardEmptyState({
checklist,
budgetReady,
visitStepsInitialized,
hidden,
onHide,
onResume,
}: DashboardEmptyStateProps) {
const { t } = useTranslation();
return (
<EmptyState
icon={<LayoutDashboard size={40} />}
title={t("dashboard.empty.title")}
description={t("dashboard.empty.description")}
primaryAction={{ label: t("dashboard.empty.cta"), to: "/import" }}
>
{!hidden && (
<OnboardingChecklist
items={checklist.items}
done={checklist.done}
total={checklist.total}
budgetReady={budgetReady}
visitStepsInitialized={visitStepsInitialized}
onHide={onHide}
onResume={onResume}
/>
)}
</EmptyState>
);
}

View file

@ -0,0 +1,318 @@
// OnboardingChecklist — unit tests (#352, #397).
//
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
// mocked, the component called as a function and its element tree walked.
// `t` returns the key, with the interpolation values appended, so the asserts
// read the i18n keys and never a hard-coded string.
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("lucide-react", () => ({
CheckCircle2: () => null,
Circle: () => null,
Lock: () => null,
SkipForward: () => null,
}));
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import type { ChecklistItemKey, OnboardingCounters } from "../../shared/types";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock, SkipForward]);
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
function expand(node: ReactNode): ReactNode {
if (Array.isArray(node)) return node.map(expand);
if (!isValidElement(node)) return node;
const el = node as AnyElement;
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
const render = el.type as (props: Record<string, unknown>) => ReactNode;
return expand(render(el.props));
}
const children = el.props.children;
return { ...el, props: { ...el.props, children: expand(children) } } as ReactNode;
}
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
function render(
overrides: Partial<OnboardingChecklistProps> = {},
counters = ZERO,
budgetAllowed = true,
skipped: ChecklistItemKey[] = [],
visitStepsInitialized = true,
) {
const checklist = computeChecklist(counters, {
importFolderSet: false,
budgetAllowed,
skipped,
visitStepsInitialized,
});
const onHide = vi.fn();
const onResume = vi.fn();
const props: OnboardingChecklistProps = {
items: checklist.items,
done: checklist.done,
total: checklist.total,
budgetReady: true,
visitStepsInitialized,
onHide,
onResume,
...overrides,
};
return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist };
}
function rowOf(element: ReactNode, key: string): AnyElement {
const row = find(element, (e) => e.type === "li" && e.key === key)[0];
expect(row, `row ${key}`).toBeDefined();
return row;
}
/** The visually hidden texts of a row: what assistive technologies read after the label. */
function stateTextsOf(row: AnyElement): string[] {
return find(row, (e) => e.type === "span" && e.props.className === "sr-only").flatMap((e) =>
texts(e.props.children).map((s) => s.trim()).filter(Boolean),
);
}
describe("OnboardingChecklist", () => {
it("renders the title, the counter 1 / 8 and the hide button", () => {
const { element, onHide } = render();
const text = texts(element);
expect(text).toContain("t(onboarding.checklist.title)");
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=8])");
const [hide] = find(element, (e) => e.type === "button");
expect(hide.props.type).toBe("button");
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
(hide.props.onClick as () => void)();
expect(onHide).toHaveBeenCalledTimes(1);
});
it("renders eight rows, in order, with their i18n labels (#425)", () => {
const { element } = render();
const rows = find(element, (e) => e.type === "li");
const order = [
"profile",
"importFolder",
"source",
"import",
"categories",
"manualCategory",
"budget",
"reports",
];
expect(rows.map((r) => r.key)).toEqual(order);
const text = texts(element);
for (const key of order) {
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
}
});
it("links every undone row to its `to`, and never a done row", () => {
const { element, checklist } = render();
const links = find(element, (e) => e.type === Link);
const undone = checklist.items.filter((i) => !i.done);
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
const profile = rowOf(element, "profile");
expect(find(profile, (e) => e.type === Link)).toHaveLength(0);
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
});
it("gives the link of a row to do the step's navigation state, guided on the manual category", () => {
const { element } = render();
const [folder] = find(rowOf(element, "importFolder"), (e) => e.type === Link);
expect(folder.props.state).toEqual({ onboarding: { focus: true } });
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
expect(manual.props.to).toBe("/transactions");
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
});
it("marks a row done when its counter is reached", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
});
it("in Free, the budget row shows a lock, the 'Base and up' label and links to the license card", () => {
const { element } = render({}, ZERO, false);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(texts(budget)).toContain("t(onboarding.checklist.lockedBudget)");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
// The license card carries no step card: nothing to focus there.
expect(link.props.state).toBeUndefined();
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=7])");
});
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
const { element } = render({ budgetReady: false }, ZERO, false);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(0);
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
expect(texts(budget)).not.toContain("t(onboarding.checklist.lockedBudget)");
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
});
});
describe("OnboardingChecklist — states read out (#384, #397)", () => {
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
const { element } = render({}, ZERO, false, ["source"]);
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["t(onboarding.state.done)"]);
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["t(onboarding.state.skipped)"]);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
});
it("reads the budget row as pending while the entitlement is unknown", () => {
const { element } = render({ budgetReady: false }, ZERO, false);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
});
it("puts the state text right after the label, inside the same element", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const [labelled] = find(
rowOf(element, "source"),
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.items.source)"),
);
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
"t(onboarding.checklist.items.source)",
"t(onboarding.state.done)",
]);
});
});
describe("OnboardingChecklist — skipped rows (#397)", () => {
it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => {
const { element } = render({}, ZERO, true, ["source"]);
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1);
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
const [badge] = find(
source,
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.skippedBadge)"),
);
// The hidden "(skipped)" text already says it: the badge is not read twice.
expect(badge.props["aria-hidden"]).toBe("true");
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
});
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]);
const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link);
// The label is no link: only "Resume" leads to the page, after taking the step back.
expect(sourceLinks).toHaveLength(1);
const [resume] = sourceLinks;
expect(texts(resume.props.children)).toEqual(["t(onboarding.checklist.resume)"]);
expect(resume.props.to).toBe("/import");
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
(resume.props.onClick as () => void)();
expect(onResume).toHaveBeenCalledTimes(1);
expect(onResume).toHaveBeenCalledWith("source");
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
(manual.props.onClick as () => void)();
expect(onResume).toHaveBeenLastCalledWith("manualCategory");
});
it("a row done and skipped shows as done, without 'Resume'", () => {
const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]);
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
expect(texts(source)).not.toContain("t(onboarding.checklist.resume)");
});
it("a locked row stays locked even when skipped", () => {
const { element } = render({}, ZERO, false, ["budget"]);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
expect(texts(budget)).not.toContain("t(onboarding.checklist.resume)");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
});
});
describe("OnboardingChecklist — visit steps (#425)", () => {
it("a visit row to do links to its page with the step's navigation state", () => {
const { element } = render();
const [categories] = find(rowOf(element, "categories"), (e) => e.type === Link);
expect(categories.props.to).toBe("/categories");
expect(categories.props.state).toEqual({ onboarding: { focus: true } });
const [reports] = find(rowOf(element, "reports"), (e) => e.type === Link);
expect(reports.props.to).toBe("/reports");
});
it("before the initialization, both visit rows are pending, without the edition badge, and the counter waits", () => {
const { element } = render({}, ZERO, false, [], false);
for (const key of ["categories", "reports"]) {
const row = rowOf(element, key);
expect(find(row, (e) => e.type === Lock), key).toHaveLength(0);
expect(find(row, (e) => e.type === Circle), key).toHaveLength(1);
expect(find(row, (e) => e.type === Link), key).toHaveLength(0);
expect(texts(row), key).not.toContain("t(onboarding.checklist.lockedBudget)");
expect(stateTextsOf(row), key).toEqual(["t(onboarding.state.pending)"]);
}
// The Budget row keeps its own rule: entitlement known, Free → locked.
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
});
it("the Budget row's pending state follows budgetReady alone", () => {
const { element } = render({ budgetReady: false }, ZERO, true, [], true);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
expect(stateTextsOf(rowOf(element, "categories"))).toEqual([]);
const initializedOnly = render({ budgetReady: true }, ZERO, true, [], false).element;
expect(stateTextsOf(rowOf(initializedOnly, "budget"))).toEqual([]);
});
it("an acknowledged visit row shows as done", () => {
const checklist = computeChecklist(ZERO, {
importFolderSet: false,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["reports"],
});
const { element } = render({ items: checklist.items, done: checklist.done, total: checklist.total });
const reports = rowOf(element, "reports");
expect(find(reports, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(reports, (e) => e.type === Link)).toHaveLength(0);
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
});
});

View file

@ -0,0 +1,187 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
import { stepNavState } from "../../utils/onboardingPath";
import { VISIT_STEP_KEYS, type ChecklistItem, type ChecklistItemKey } from "../../shared/types";
export interface OnboardingChecklistProps {
items: ChecklistItem[];
/** Number of unlocked rows that are done or skipped. */
done: number;
/** Number of unlocked rows (8, or 7 when Budget is locked). */
total: number;
/**
* False until the Budget entitlement is known. The budget row is then
* neutral (neither done nor locked) and the counter is hidden, because
* `total` depends on the lock.
*/
budgetReady: boolean;
/**
* False until the profile's visit steps are initialized (#425). Their two
* rows are then neutral ("pending", never "locked", which would show the
* Base edition badge) and the counter is hidden, `total` depending on them.
*/
visitStepsInitialized: boolean;
onHide: () => void;
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
onResume: (key: ChecklistItemKey) => void;
}
type RowState = "done" | "skipped" | "todo" | "locked" | "pending";
/**
* A locked row stays locked whatever else it is, and a row the data says is
* done shows as done even if it was skipped too: "Resume" would bring back
* nothing to do.
*/
function rowState(
item: ChecklistItem,
budgetReady: boolean,
visitStepsInitialized: boolean,
): RowState {
if (item.key === "budget" && !budgetReady) return "pending";
if (VISIT_STEP_KEYS.includes(item.key) && !visitStepsInitialized) return "pending";
if (item.locked) return "locked";
if (item.done) return "done";
return item.skipped ? "skipped" : "todo";
}
const ICON_CLASS: Record<RowState, string> = {
done: "text-[var(--positive)]",
skipped: "text-[var(--muted-foreground)]",
todo: "text-[var(--muted-foreground)]",
locked: "text-[var(--muted-foreground)]",
pending: "text-[var(--muted-foreground)] opacity-50",
};
function RowIcon({ state }: { state: RowState }) {
const className = `shrink-0 ${ICON_CLASS[state]}`;
if (state === "done") return <CheckCircle2 size={20} className={className} aria-hidden="true" />;
if (state === "skipped") return <SkipForward size={20} className={className} aria-hidden="true" />;
if (state === "locked") return <Lock size={20} className={className} aria-hidden="true" />;
return <Circle size={20} className={className} aria-hidden="true" />;
}
const BADGE_CLASS =
"rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]";
/**
* Getting-started checklist (#352, #397): the welcome card of the empty
* dashboard and the list that the banner unfolds. Pure: the only hook is
* `useTranslation`, so the component is tested by calling it as a function.
*
* - A row still to do links to its `to` route with the step's navigation
* state (`stepNavState`); a done row has no link.
* - A skipped row shows a skip icon, the "Skipped" badge and "Resume", which
* takes the step back, then opens its page like the link of a row to do.
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
* label and links to the license card (`/settings/users`), which carries no
* step card: no navigation state.
* - The icons are hidden from assistive technologies, so each state but "to
* do" is read out by a visually hidden text after the label (#384); the
* "Skipped" badge is hidden from them, that text already says it.
*/
export default function OnboardingChecklist({
items,
done,
total,
budgetReady,
visitStepsInitialized,
onHide,
onResume,
}: OnboardingChecklistProps) {
const { t } = useTranslation();
return (
<div
role="region"
aria-label={t("onboarding.checklist.title")}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4"
>
<div className="flex items-center gap-3 mb-3">
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
{budgetReady && visitStepsInitialized && (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
)}
<span className="ml-auto">
<button
type="button"
onClick={onHide}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
>
{t("onboarding.checklist.hide")}
</button>
</span>
</div>
<ul className="space-y-2">
{items.map((item) => {
const state = rowState(item, budgetReady, visitStepsInitialized);
const label = t(`onboarding.checklist.items.${item.key}`);
const stateText =
state === "todo" ? null : (
<span className="sr-only"> {t(`onboarding.state.${state}`)}</span>
);
return (
<li key={item.key} className="flex flex-wrap items-center gap-3 text-sm">
<RowIcon state={state} />
{state === "todo" && (
<Link
to={item.to}
state={stepNavState(item.key)}
className="hover:underline text-[var(--foreground)]"
>
{label}
</Link>
)}
{state === "skipped" && (
<>
<span className="text-[var(--muted-foreground)]">
{label}
{stateText}
</span>
<span aria-hidden="true" className={BADGE_CLASS}>
{t("onboarding.checklist.skippedBadge")}
</span>
<Link
to={item.to}
state={stepNavState(item.key)}
onClick={() => onResume(item.key)}
className="font-medium text-[var(--primary)] hover:underline"
>
{t("onboarding.checklist.resume")}
</Link>
</>
)}
{state === "locked" && (
<Link
to={item.to}
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
>
<span>
{label}
{stateText}
</span>
<span className={BADGE_CLASS}>{t("onboarding.checklist.lockedBudget")}</span>
</Link>
)}
{state === "done" && (
<span className="text-[var(--muted-foreground)] line-through">
{label}
{stateText}
</span>
)}
{state === "pending" && (
<span className="text-[var(--muted-foreground)]">
{label}
{stateText}
</span>
)}
</li>
);
})}
</ul>
</div>
);
}

View file

@ -0,0 +1,209 @@
// ImportEmptyState — unit tests (#356).
//
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
// mocked, the component called as a function and its element tree walked.
// `t` returns the key, with the interpolation values appended, so the asserts
// read the i18n keys and never a hard-coded string. The inner `EmptyState` is
// expanded (called as a function too) so its buttons and links are visible.
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("lucide-react", () => ({
FolderOpen: () => null,
Inbox: () => null,
Folder: () => null,
FileText: () => null,
}));
import { FileText, Folder, FolderOpen, Inbox } from "lucide-react";
import ImportEmptyState, {
CSV_EXPORT_GUIDE_PATH,
folderNameFromPath,
type ImportEmptyStateProps,
} from "./ImportEmptyState";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
const LEAVES = new Set<unknown>([Link, FolderOpen, Inbox, Folder, FileText]);
/** Resolves the inner `EmptyState` component so its buttons and links are visible. */
function expand(node: ReactNode): ReactNode {
if (Array.isArray(node)) return node.map(expand);
if (!isValidElement(node)) return node;
const el = node as AnyElement;
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
const render = el.type as (props: Record<string, unknown>) => ReactNode;
return expand(render(el.props));
}
return { ...el, props: { ...el.props, children: expand(el.props.children) } } as ReactNode;
}
type ButtonEl = ReactElement<{ type: string; disabled: boolean; onClick: () => void; children?: ReactNode }>;
type LinkEl = ReactElement<{ to: string; children?: ReactNode }>;
function render(overrides: Partial<ImportEmptyStateProps> = {}) {
const props: ImportEmptyStateProps = {
hasFolder: false,
folderName: null,
onBrowse: vi.fn(),
onCreateSample: vi.fn(),
onRefresh: vi.fn(),
isCreatingSample: false,
...overrides,
};
const tree = expand(ImportEmptyState(props));
const buttons = find(tree, (e) => e.type === "button") as ButtonEl[];
const links = find(tree, (e) => e.type === Link) as LinkEl[];
return { props, tree, buttons, links };
}
describe("ImportEmptyState — no folder (#356)", () => {
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
const { props, tree, buttons, links } = render({ hasFolder: false });
expect(texts(tree)).toContain("t(import.empty.noFolder.title)");
expect(texts(tree)).toContain("t(import.empty.noFolder.description)");
expect(buttons).toHaveLength(1);
expect(texts(buttons[0].props.children)).toContain("t(import.empty.noFolder.cta)");
expect(buttons[0].props.disabled).toBe(false);
buttons[0].props.onClick();
expect(props.onBrowse).toHaveBeenCalledTimes(1);
expect(props.onCreateSample).not.toHaveBeenCalled();
expect(links).toHaveLength(1);
expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH);
expect(links[0].props.to).toBe("/settings/users#import-export");
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
});
it("never shows the sample or refresh actions without a folder", () => {
const { tree } = render({ hasFolder: false });
expect(texts(tree)).not.toContain("t(import.empty.createSample)");
expect(texts(tree)).not.toContain("t(import.empty.refresh)");
});
});
describe("ImportEmptyState — folder without sources (#356)", () => {
it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => {
const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" });
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
expect(buttons).toHaveLength(2);
expect(texts(buttons[0].props.children)).toContain("t(import.empty.createSample)");
expect(buttons[0].props.disabled).toBe(false);
buttons[0].props.onClick();
expect(props.onCreateSample).toHaveBeenCalledTimes(1);
expect(texts(buttons[1].props.children)).toContain("t(import.empty.refresh)");
buttons[1].props.onClick();
expect(props.onRefresh).toHaveBeenCalledTimes(1);
expect(props.onBrowse).not.toHaveBeenCalled();
expect(links).toHaveLength(1);
expect(links[0].props.to).toBe("/settings/users#import-export");
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
});
it("disables the create button while the sample folders are being written", () => {
const { buttons } = render({ hasFolder: true, folderName: "Relevés", isCreatingSample: true });
expect(buttons[0].props.disabled).toBe(true);
// Refresh stays available: it is not the action in flight.
expect(buttons[1].props.disabled).toBe(false);
});
it("names the folder in the description, never the full path", () => {
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
const { tree } = render({ hasFolder: true, folderName });
const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description"));
expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])");
expect(description).not.toContain("Users");
});
});
describe("ImportEmptyState — expected structure of the folder (#422)", () => {
const STRUCTURE = [
"title",
"parent",
"account1",
"file1",
"file2",
"account2",
"rootNote",
].map((name) => `t(import.empty.structure.${name})`);
it.each([
["no folder", { hasFolder: false }],
["a folder without sources", { hasFolder: true, folderName: "Relevés" }],
] as const)("shows the expected structure with %s, above the guide link", (_, overrides) => {
const { tree } = render(overrides);
const all = texts(tree);
for (const text of STRUCTURE) expect(all).toContain(text);
expect(all.indexOf("t(import.empty.structure.rootNote)")).toBeLessThan(
all.indexOf("t(import.empty.guideLink)"),
);
});
});
describe("ImportEmptyState — getting-started target (#398)", () => {
const highlighted = (tree: ReactNode) =>
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
it("highlights 'Choose a folder' without a folder, and only it", () => {
const { tree } = render({ hasFolder: false, onboardingTarget: true });
const targets = highlighted(tree);
expect(targets).toHaveLength(1);
expect(targets[0].type).toBe("button");
expect(texts(targets[0].props.children)).toContain("t(import.empty.noFolder.cta)");
});
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
const targets = highlighted(tree);
expect(targets).toHaveLength(1);
expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)");
});
it("highlights nothing without the property", () => {
expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0);
expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0);
expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0);
});
});
describe("folderNameFromPath (#356)", () => {
it("returns the last segment for POSIX and Windows paths", () => {
expect(folderNameFromPath("/home/max/releves")).toBe("releves");
expect(folderNameFromPath("C:\\Users\\max\\Relevés")).toBe("Relevés");
});
it("ignores trailing separators", () => {
expect(folderNameFromPath("/home/max/releves/")).toBe("releves");
expect(folderNameFromPath("C:\\Users\\max\\Relevés\\")).toBe("Relevés");
});
it("falls back to the path itself when it has no segment", () => {
expect(folderNameFromPath("/")).toBe("/");
expect(folderNameFromPath("")).toBe("");
});
});

View file

@ -0,0 +1,99 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { FolderOpen, Inbox } from "lucide-react";
import EmptyState from "../shared/EmptyState";
import ImportFolderStructure from "./ImportFolderStructure";
/** Where the user guide explains how to export a CSV from each bank (#355). */
export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export";
export interface ImportEmptyStateProps {
/** An import folder is configured; the empty state then speaks of its content. */
hasFolder: boolean;
/** Last segment of the import folder path, never the full path. */
folderName: string | null;
onBrowse: () => void;
onCreateSample: () => void;
onRefresh: () => void;
/** The sample folders are being written: the primary button is disabled. */
isCreatingSample: boolean;
/**
* Highlight the primary action for the getting-started card (#398): "Choose
* a folder" without a folder, "Create a sample folder" with one.
*/
onboardingTarget?: boolean;
}
/**
* Last segment of a filesystem path, whichever separator the OS used, with
* trailing separators ignored (`C:\Users\max\Relevés\` → `Relevés`). The full
* path is already shown by the folder card; the empty state only names it.
*/
export function folderNameFromPath(path: string): string {
const segments = path.split(/[\\/]+/).filter((s) => s.length > 0);
return segments.length > 0 ? segments[segments.length - 1] : path;
}
/**
* Empty state of the import page (#356), replacing the empty `SourceList` in
* both of its situations: no import folder yet (choose one), or a folder with
* no account sub-folder (create the sample folders, or refresh after dropping
* files). Both variants show the expected structure of the folder (#422) and
* link to the bank CSV export guide. Pure: no hook besides `useTranslation`,
* so it is tested by direct call.
*/
export default function ImportEmptyState({
hasFolder,
folderName,
onBrowse,
onCreateSample,
onRefresh,
isCreatingSample,
onboardingTarget,
}: ImportEmptyStateProps) {
const { t } = useTranslation();
const details = (
<>
<ImportFolderStructure />
<p className="mt-4 text-center">
<Link
to={CSV_EXPORT_GUIDE_PATH}
className="text-sm text-[var(--primary)] hover:underline"
>
{t("import.empty.guideLink")}
</Link>
</p>
</>
);
if (!hasFolder) {
return (
<EmptyState
icon={<FolderOpen size={40} />}
title={t("import.empty.noFolder.title")}
description={t("import.empty.noFolder.description")}
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }}
>
{details}
</EmptyState>
);
}
return (
<EmptyState
icon={<Inbox size={40} />}
title={t("import.empty.noSources.title")}
description={t("import.empty.noSources.description", { folderName: folderName ?? "" })}
primaryAction={{
label: t("import.empty.createSample"),
onClick: onCreateSample,
disabled: isCreatingSample,
onboardingTarget,
}}
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
>
{details}
</EmptyState>
);
}

View file

@ -50,9 +50,18 @@ export default function ImportFolderConfig({
{t("import.folder.refresh")}
</button>
)}
{/*
Without a folder the empty state below carries the primary "choose
a folder" button (#356); this one steps back to the secondary style
so a single primary button is on screen.
*/}
<button
onClick={onBrowse}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
className={
folderPath
? "flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
: "flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
}
>
<FolderOpen size={14} />
{t("import.folder.browse")}

View file

@ -0,0 +1,62 @@
// ImportFolderStructure — render tests (#422).
//
// `renderToStaticMarkup`, no jsdom; the shared marked `t` (#389), so every
// text is asserted in its `t(key)` form and a string that skipped `t` fails.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import ImportFolderStructure from "./ImportFolderStructure";
const KEYS = [
"title",
"parent",
"account1",
"file1",
"file2",
"account2",
"rootNote",
].map((name) => `import.empty.structure.${name}`);
/** The markup with the class attributes dropped, so the structure reads. */
function bare(): string {
return renderToStaticMarkup(<ImportFolderStructure />)
.replace(/ class="[^"]*"/g, "")
.replace(/<svg[^>]*>.*?<\/svg>/g, "<svg/>");
}
describe("ImportFolderStructure (#422)", () => {
it("renders its seven texts, each through t, in reading order", () => {
const html = renderToStaticMarkup(<ImportFolderStructure />);
const positions = KEYS.map((key) => html.indexOf(`t(${key})`));
for (const [i, position] of positions.entries()) {
expect(position, KEYS[i]).toBeGreaterThanOrEqual(0);
}
expect([...positions].sort((a, b) => a - b)).toEqual(positions);
});
it("nests the levels: parent folder > account folders > files", () => {
// The two files sit in a list inside the first account's item, itself in
// a list inside the parent's item; the second account is the first's
// sibling, and the root note is outside the tree.
expect(bare()).toBe(
"<div><h3>t(import.empty.structure.title)</h3>" +
"<ul><li><span><svg/>t(import.empty.structure.parent)</span>" +
"<ul>" +
"<li><span><svg/>t(import.empty.structure.account1)</span>" +
"<ul><li><svg/>t(import.empty.structure.file1)</li><li><svg/>t(import.empty.structure.file2)</li></ul>" +
"</li>" +
"<li><span><svg/>t(import.empty.structure.account2)</span></li>" +
"</ul></li></ul>" +
"<p>t(import.empty.structure.rootNote)</p></div>",
);
});
it("hides every icon from assistive technologies", () => {
const html = renderToStaticMarkup(<ImportFolderStructure />);
const icons = html.match(/<svg[^>]*>/g) ?? [];
expect(icons).toHaveLength(5);
for (const icon of icons) expect(icon).toContain('aria-hidden="true"');
});
});

View file

@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { FileText, Folder } from "lucide-react";
const ITEM_CLASS = "flex items-center gap-2";
const NESTED_CLASS = "mt-1 ml-5 space-y-1 border-l border-[var(--border)] pl-3";
/**
* "Expected structure" box of the import page's empty state (#422): the
* folder to choose is the parent one, each of its sub-folders is one source
* (one account), and only the files inside a sub-folder are read. It mirrors
* `scan_import_folder` (src-tauri/src/commands/fs_commands.rs): direct,
* non-hidden sub-folders become sources, files at the root are ignored.
*
* A nested list rather than a picture, so a screen reader reads the levels;
* the icons are decorative. Pure: no hook besides `useTranslation`, so it is
* tested by direct call.
*/
export default function ImportFolderStructure() {
const { t } = useTranslation();
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm text-[var(--foreground)]">
<h3 className="mb-2 font-semibold">{t("import.empty.structure.title")}</h3>
<ul className="space-y-1">
<li>
<span className={`${ITEM_CLASS} font-medium`}>
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--primary)]" />
{t("import.empty.structure.parent")}
</span>
<ul className={NESTED_CLASS}>
<li>
<span className={ITEM_CLASS}>
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.account1")}
</span>
<ul className={NESTED_CLASS}>
<li className={ITEM_CLASS}>
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.file1")}
</li>
<li className={ITEM_CLASS}>
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.file2")}
</li>
</ul>
</li>
<li>
<span className={ITEM_CLASS}>
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
{t("import.empty.structure.account2")}
</span>
</li>
</ul>
</li>
</ul>
<p className="mt-3 text-[var(--muted-foreground)]">{t("import.empty.structure.rootNote")}</p>
</div>
);
}

View file

@ -0,0 +1,55 @@
// RootFilesWarning (#427) through the REAL locales: the plural forms
// (`message_one`/`message_other`, `more_one`/`more_other`) and the quotes
// around the folder name only exist in fr.json and en.json, which the marked
// `t` of RootFilesWarning.test.tsx never reads.
import { describe, it, expect, afterAll } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import i18n from "../../i18n/config";
import RootFilesWarning from "./RootFilesWarning";
const text = (names: string[]) =>
renderToStaticMarkup(<RootFilesWarning names={names} folderName="Relevés" />)
.replace(/<[^>]+>/g, "");
const SEVEN = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
afterAll(async () => {
await i18n.changeLanguage("fr");
});
describe("RootFilesWarning — French (#427)", () => {
it("singular", async () => {
await i18n.changeLanguage("fr");
expect(text(["a.csv"])).toBe(
"1 fichier est posé directement dans « Relevés » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte.",
);
});
it("plural, five names then « et 2 autres »", async () => {
await i18n.changeLanguage("fr");
expect(text(SEVEN)).toBe(
"7 fichiers sont posés directement dans « Relevés » et sont ignorés : 1.csv, 2.csv, 3.csv, 4.csv, 5.csv et 2 autres. Déplacez-les dans un sous-dossier par compte.",
);
});
it("one more: « et 1 autre »", async () => {
await i18n.changeLanguage("fr");
expect(text(SEVEN.slice(0, 6))).toContain("5.csv et 1 autre.");
});
});
describe("RootFilesWarning — English (#427)", () => {
it("singular", async () => {
await i18n.changeLanguage("en");
expect(text(["a.csv"])).toBe(
"1 file sits directly in “Relevés” and is ignored: a.csv. Move it into a subfolder per account.",
);
});
it("plural, five names then “and 2 more”", async () => {
await i18n.changeLanguage("en");
expect(text(SEVEN)).toBe(
"7 files sit directly in “Relevés” and are ignored: 1.csv, 2.csv, 3.csv, 4.csv, 5.csv and 2 more. Move them into a subfolder per account.",
);
});
});

View file

@ -0,0 +1,39 @@
// RootFilesWarning (#427). No jsdom: rendered to static markup with the shared
// marked `t`, so the message key, its count and its interpolations are read.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import RootFilesWarning from "./RootFilesWarning";
const render = (names: string[], folderName = "Relevés") =>
renderToStaticMarkup(<RootFilesWarning names={names} folderName={folderName} />);
describe("RootFilesWarning (#427)", () => {
it("one file: the message with count 1, the folder name and the file", () => {
expect(render(["a.csv"])).toContain(
"t(import.rootFiles.message[count=1,folder=Relevés,names=a.csv])",
);
});
it("three files: the message with count 3 and the names joined by a comma", () => {
expect(render(["a.csv", "b.csv", "c.txt"])).toContain(
"t(import.rootFiles.message[count=3,folder=Relevés,names=a.csv, b.csv, c.txt])",
);
});
it("seven files: five names, then 'and 2 more'", () => {
const names = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
expect(render(names)).toContain(
"t(import.rootFiles.message[count=7,folder=Relevés,names=1.csv, 2.csv, 3.csv, 4.csv, 5.csv t(import.rootFiles.more[count=2])])",
);
});
it("an icon hidden from assistive tech, and no live region", () => {
const html = render(["a.csv"]);
expect(html).toMatch(/<svg[^>]*aria-hidden="true"/);
expect(html).not.toContain("role=");
expect(html).not.toContain("aria-live");
});
});

View file

@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import { formatRootFiles } from "../../utils/rootFiles";
export interface RootFilesWarningProps {
/** Names of the CSV/TXT files placed directly at the root, sorted by the scan. */
names: readonly string[];
/** Last segment of the import folder path, never the full path. */
folderName: string;
}
/**
* Warning about the statement files placed directly in the import folder,
* which no source reads (#427). Names up to five of them, then counts the
* rest. Shown when the page loads, so no live region. On screen only: the
* names never reach the logs. Pure besides `useTranslation`, tested by direct
* render.
*/
export default function RootFilesWarning({ names, folderName }: RootFilesWarningProps) {
const { t } = useTranslation();
const { shown, more } = formatRootFiles(names);
const listed =
shown.join(", ") + (more > 0 ? ` ${t("import.rootFiles.more", { count: more })}` : "");
return (
<div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--accent)] flex items-start gap-2">
<AlertTriangle size={16} aria-hidden="true" className="text-[var(--accent)] shrink-0 mt-0.5" />
<p className="text-sm text-[var(--foreground)]">
{t("import.rootFiles.message", {
count: names.length,
folder: folderName,
names: listed,
})}
</p>
</div>
);
}

View file

@ -1,19 +1,26 @@
import { useTranslation } from "react-i18next";
import { FolderOpen, FileText, CheckCircle } from "lucide-react";
import type { ScannedSource } from "../../shared/types";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
interface SourceCardProps {
source: ScannedSource;
isConfigured: boolean;
newFileCount: number;
onClick: () => void;
/** Highlight this card for the getting-started card (#398). */
onboardingTarget?: boolean;
}
/** `data-onboarding-target` of a highlighted source card. */
export const SOURCE_CARD_TARGET = "source-card";
export default function SourceCard({
source,
isConfigured,
newFileCount,
onClick,
onboardingTarget,
}: SourceCardProps) {
const { t } = useTranslation();
@ -21,6 +28,7 @@ export default function SourceCard({
<button
onClick={onClick}
className="w-full text-left bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] hover:border-[var(--primary)] hover:shadow-sm transition-all"
{...onboardingTargetAttributes(SOURCE_CARD_TARGET, onboardingTarget)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">

View file

@ -0,0 +1,44 @@
// SourceConfigPanel — the source name is read-only, the subfolder's (#426).
// No jsdom: rendered to static markup with the shared marked `t`.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SourceConfigPanel from "./SourceConfigPanel";
import { initialWizardState } from "../../hooks/useImportWizard";
const noop = () => undefined;
const render = () =>
renderToStaticMarkup(
<SourceConfigPanel
source={{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] }}
config={{ ...initialWizardState.sourceConfig, name: "Mon compte chèques" }}
selectedFiles={[]}
headers={[]}
configTemplates={[]}
onConfigChange={noop}
onFileToggle={noop}
onSelectAllFiles={noop}
onAutoDetect={noop}
onSaveAsTemplate={noop}
onApplyTemplate={noop}
onUpdateTemplate={noop}
onDeleteTemplate={noop}
selectedTemplateId={null}
/>,
);
describe("SourceConfigPanel — source name (#426)", () => {
it("shows the subfolder name with its hint", () => {
const html = render();
expect(html).toContain("t(import.config.sourceNameHint)");
expect(html).toMatch(/<p[^>]*>Desjardins<\/p>/);
});
it("has no field holding the configured name", () => {
const html = render();
expect(html).not.toContain("Mon compte chèques");
});
});

View file

@ -15,6 +15,7 @@ import {
type BankSignatureId,
} from "../../utils/bankSignatures";
import ColumnMappingEditor from "./ColumnMappingEditor";
import SourceNameDisplay from "./SourceNameDisplay";
interface SourceConfigPanelProps {
source: ScannedSource;
@ -249,18 +250,8 @@ export default function SourceConfigPanel({
)}
</div>
{/* Source name */}
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</label>
<input
type="text"
value={config.name}
onChange={(e) => updateConfig({ name: e.target.value })}
className={inputClass}
/>
</div>
{/* Source name: the subfolder name, read-only (#426) */}
<SourceNameDisplay folderName={source.folder_name} />
{/* Basic settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">

View file

@ -0,0 +1,81 @@
// SourceList and SourceCard — the getting-started target (#398). No jsdom:
// react-dom/server renders in vitest's node environment, and the markup says
// which card carries the highlight.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SourceList from "./SourceList";
import { SOURCE_CARD_TARGET } from "./SourceCard";
import type { ScannedSource } from "../../shared/types";
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const source = (name: string): ScannedSource => ({
folder_name: name,
folder_path: `/releves/${name}`,
files: [{ filename: "releve.csv", file_path: `/releves/${name}/releve.csv`, size_bytes: 10, modified_at: "" }],
});
const SOURCES = [source("Desjardins"), source("Tangerine"), source("RBC")];
const markup = (onboardingTarget?: boolean, sources: ScannedSource[] = SOURCES) =>
renderToStaticMarkup(
<SourceList
sources={sources}
configuredSourceNames={new Set()}
importedFileNames={new Map()}
onSelectSource={vi.fn()}
onboardingTarget={onboardingTarget}
/>,
);
/** The source cards, in order: each is a button. */
const cards = (html: string) => html.split("<button").slice(1);
describe("SourceList — getting-started target (#398)", () => {
it("highlights the first card of the list, and only it", () => {
const html = markup(true);
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
const [first, ...others] = cards(html);
expect(first).toContain(HIGHLIGHT);
expect(first).toContain(`data-onboarding-target="${SOURCE_CARD_TARGET}"`);
expect(first).toContain("Desjardins");
expect(others).toHaveLength(2);
for (const card of others) expect(card).not.toContain("data-onboarding");
});
it("highlights the only card of a one-source list", () => {
expect(markup(true, [source("Desjardins")]).split(HIGHLIGHT).length - 1).toBe(1);
});
it("highlights nothing without the property", () => {
expect(markup()).not.toContain("data-onboarding");
expect(markup(false)).not.toContain("data-onboarding");
});
});
describe("SourceList and SourceCard — displayed texts (#389)", () => {
it("titles the list and counts each source's files through t", () => {
const html = markup();
expect(html).toContain(">t(import.sources.title)<");
expect(cards(html)[0]).toContain("t(import.sources.fileCount[count=1])");
});
it("marks the files not yet imported through t", () => {
const html = renderToStaticMarkup(
<SourceList
sources={[source("Desjardins")]}
configuredSourceNames={new Set()}
importedFileNames={new Map([["Desjardins", new Set<string>()]])}
onSelectSource={vi.fn()}
/>,
);
expect(html).toContain("t(import.sources.new)");
});
it("says the folder is empty through t", () => {
expect(markup(false, [])).toContain(">t(import.sources.empty)<");
});
});

View file

@ -8,6 +8,8 @@ interface SourceListProps {
configuredSourceNames: Set<string>;
importedFileNames: Map<string, Set<string>>;
onSelectSource: (source: ScannedSource) => void;
/** Highlight the first card for the getting-started card (#398): one target at a time. */
onboardingTarget?: boolean;
}
export default function SourceList({
@ -15,6 +17,7 @@ export default function SourceList({
configuredSourceNames,
importedFileNames,
onSelectSource,
onboardingTarget,
}: SourceListProps) {
const { t } = useTranslation();
@ -38,7 +41,7 @@ export default function SourceList({
{t("import.sources.title")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sources.map((source) => {
{sources.map((source, index) => {
const isConfigured = configuredSourceNames.has(source.folder_name);
// Count files not yet imported for this source
const sourceHashes = importedFileNames.get(source.folder_name);
@ -55,6 +58,7 @@ export default function SourceList({
isConfigured={isConfigured}
newFileCount={newFileCount}
onClick={() => onSelectSource(source)}
onboardingTarget={onboardingTarget && index === 0}
/>
);
})}

View file

@ -0,0 +1,27 @@
// SourceNameDisplay (#426). No jsdom: rendered to static markup with the shared
// marked `t`, so the label and hint keys are read as written.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SourceNameDisplay from "./SourceNameDisplay";
const render = (folderName: string) =>
renderToStaticMarkup(<SourceNameDisplay folderName={folderName} />);
describe("SourceNameDisplay (#426)", () => {
it("shows the subfolder name, the label and the hint", () => {
const html = render("Desjardins chèques");
expect(html).toContain("Desjardins chèques");
expect(html).toContain("t(import.config.sourceName)");
expect(html).toContain("t(import.config.sourceNameHint)");
});
it("has no input field: the name cannot be edited", () => {
const html = render("Tangerine");
expect(html).not.toContain("<input");
expect(html).not.toContain("<textarea");
expect(html).not.toContain("contenteditable");
});
});

View file

@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
interface SourceNameDisplayProps {
/** Name of the source subfolder, which is the source name (#426). */
folderName: string;
}
/**
* Read-only source name in the source configuration (#426). A source is
* looked up by its subfolder name on the next import, so its name is that
* of the subfolder and cannot be edited.
*/
export default function SourceNameDisplay({ folderName }: SourceNameDisplayProps) {
const { t } = useTranslation();
return (
<div>
<span className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</span>
<p className="text-sm font-medium text-[var(--foreground)] break-words">{folderName}</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("import.config.sourceNameHint")}
</p>
</div>
);
}

View file

@ -0,0 +1,53 @@
// WizardNavigation — the getting-started target (#398). No jsdom:
// react-dom/server renders in vitest's node environment, and the markup says
// which button carries the highlight.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import WizardNavigation, { WIZARD_NEXT_TARGET } from "./WizardNavigation";
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const markup = (props: Parameters<typeof WizardNavigation>[0]) =>
renderToStaticMarkup(
<WizardNavigation
onBack={vi.fn()}
onNext={vi.fn()}
onCancel={vi.fn()}
nextLabel="NEXT_LABEL"
{...props}
/>,
);
/** The buttons, in order: cancel, back, next. */
const buttons = (html: string) => html.split("<button").slice(1);
describe("WizardNavigation — getting-started target (#398)", () => {
it("highlights the next button, and neither back nor cancel", () => {
const html = markup({ onboardingTarget: true });
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
const all = buttons(html);
expect(all).toHaveLength(3);
const [cancel, back, next] = all;
expect(next).toContain("NEXT_LABEL");
expect(next).toContain(HIGHLIGHT);
expect(next).toContain(`data-onboarding-target="${WIZARD_NEXT_TARGET}"`);
expect(cancel).toContain("t(common.cancel)");
expect(cancel).not.toContain("data-onboarding");
expect(back).toContain("t(import.wizard.back)");
expect(back).not.toContain("data-onboarding");
});
it("keeps the highlight on a disabled next button", () => {
const html = markup({ onboardingTarget: true, nextDisabled: true });
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
});
it("highlights nothing without the property, or without a next button", () => {
expect(markup({})).not.toContain("data-onboarding");
expect(markup({ onboardingTarget: false })).not.toContain("data-onboarding");
expect(markup({ onboardingTarget: true, showNext: false })).not.toContain("data-onboarding");
});
});

View file

@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
interface WizardNavigationProps {
onBack?: () => void;
@ -11,8 +12,13 @@ interface WizardNavigationProps {
showBack?: boolean;
showNext?: boolean;
showCancel?: boolean;
/** Highlight the next button for the getting-started card (#398). */
onboardingTarget?: boolean;
}
/** `data-onboarding-target` of a highlighted next button. */
export const WIZARD_NEXT_TARGET = "wizard-next";
export default function WizardNavigation({
onBack,
onNext,
@ -23,6 +29,7 @@ export default function WizardNavigation({
showBack = true,
showNext = true,
showCancel = true,
onboardingTarget,
}: WizardNavigationProps) {
const { t } = useTranslation();
@ -54,6 +61,7 @@ export default function WizardNavigation({
onClick={onNext}
disabled={nextDisabled}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
{...onboardingTargetAttributes(WIZARD_NEXT_TARGET, onboardingTarget)}
>
{nextLabel || t("import.wizard.next")}
<ChevronRight size={16} />

View file

@ -0,0 +1,241 @@
// AppShell and the getting-started banner (#397). No jsdom: react-dom/server
// renders in vitest's node environment. The path comes from a fake
// `useOnboarding()` built like the provider builds it: a loaded state, the
// real `computeChecklist`, and the provider's own pure derivation
// (`deriveOnboardingView`, with the real `CARD_PAGES`) at the rendered route.
// What is pinned is where the banner shows, and where it sits.
import { describe, it, expect, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter, Route, Routes } from "react-router-dom";
// Importing the hook module pulls the onboarding service (→ db → tauri
// plugins) and useEntitlement (→ LicenseContext): stub both.
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
let onboarding: OnboardingValue;
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
useOnboarding: () => onboarding,
}));
import AppShell from "./AppShell";
import {
deriveOnboardingView,
initialOnboardingChecklistState,
onboardingChecklistReducer,
type OnboardingChecklistState,
type OnboardingValue,
} from "../../hooks/useOnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { CARD_PAGES } from "../../utils/onboardingPath";
import type { OnboardingSnapshot } from "../../services/onboardingService";
const NEW_PROFILE: OnboardingSnapshot = {
counters: { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
importFolderSet: false,
hidden: false,
skipped: [],
acknowledged: [],
visitStepsInitialized: true,
};
/**
* Folder, source and import done: the next step is the manual category, on
* /transactions. Stopped after the import, so the visit steps are
* acknowledged (rule of the templates, #425).
*/
const IMPORTED: OnboardingSnapshot = {
...NEW_PROFILE,
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
importFolderSet: true,
acknowledged: ["categories", "reports"],
};
/** Everything but the budget: the next step is on /budget. */
const BUDGET_LEFT: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1 },
};
const ALL_DONE: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1, budgetEntries: 1 },
};
/** The provider's state after a first successful load of `snapshot`. */
const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
[
{ type: "LOAD_START", seq: 1 } as const,
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
function pathAt(
pathname: string,
state: OnboardingChecklistState,
budgetReady = true,
budgetAllowed = true,
): OnboardingValue {
const checklist = state.counters
? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
acknowledged: state.acknowledged,
visitStepsInitialized: state.visitStepsInitialized,
})
: null;
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
return {
status: state.status,
checklist,
counters: state.counters,
hidden: state.hidden,
budgetReady,
visitStepsInitialized: state.visitStepsInitialized,
nextStep,
mode,
cardPage,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
function renderWith(pathname: string, value: OnboardingValue): string {
onboarding = value;
return renderToStaticMarkup(
<MemoryRouter initialEntries={[pathname]}>
<Routes>
<Route element={<AppShell />}>
<Route path="*" element={<p data-page="">page</p>} />
</Route>
</Routes>
</MemoryRouter>,
);
}
const renderAt = (pathname: string, state: OnboardingChecklistState, budgetReady = true) =>
renderWith(pathname, pathAt(pathname, state, budgetReady));
const BANNER = 'aria-label="t(onboarding.banner.label)"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
describe("AppShell — the getting-started banner (#397)", () => {
it("puts the banner on top of the routed page, inside main", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(count(html, BANNER)).toBe(1);
const main = html.indexOf("<main");
const banner = html.indexOf(BANNER);
expect(main).toBeGreaterThan(-1);
expect(banner).toBeGreaterThan(main);
expect(html.indexOf("data-page")).toBeGreaterThan(banner);
});
it("shows nothing before the first load", () => {
const html = renderAt("/import", initialOnboardingChecklistState);
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing when the path is hidden", () => {
const html = renderAt("/import", loaded({ ...IMPORTED, hidden: true }));
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain(BANNER);
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
});
it("shows the banner on a dashboard with transactions, and on any other page", () => {
for (const pathname of ["/", "/reports", "/categories", "/settings/users"]) {
expect(renderAt(pathname, loaded(IMPORTED)), pathname).toContain(BANNER);
}
});
it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => {
expect(CARD_PAGES.has("/import")).toBe(true);
const value = pathAt("/import", loaded(NEW_PROFILE));
expect(value.nextStep?.to).toBe("/import");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/import");
expect(renderWith("/import", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /import.
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"');
});
it("gives /transactions, the page of the next step, its step card instead of the banner (#399)", () => {
expect(CARD_PAGES.has("/transactions")).toBe(true);
const value = pathAt("/transactions", loaded(IMPORTED));
expect(value.nextStep?.to).toBe("/transactions");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/transactions");
expect(renderWith("/transactions", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /transactions.
expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"');
});
it("gives /budget, the page of the next step, its step card instead of the banner (#400)", () => {
expect(CARD_PAGES.has("/budget")).toBe(true);
const value = pathAt("/budget", loaded(BUDGET_LEFT));
expect(value.nextStep?.to).toBe("/budget");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/budget");
expect(renderWith("/budget", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /budget.
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
});
it("in the Free edition, /budget never gets a card and the path ends at 7/7 (#400)", () => {
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
expect(value.checklist?.total).toBe(7);
expect(value.nextStep).toBeNull();
expect(value.cardPage).toBeNull();
expect(value.mode).toBe("complete");
const html = renderWith("/budget", value);
expect(html).toContain(BANNER);
expect(html).toContain("t(onboarding.banner.complete)");
});
it("shows nothing on a page that carries a step card", () => {
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
expect(renderWith("/import", value)).not.toContain(BANNER);
});
it("shows the end message once every step is done (D4)", () => {
const html = renderAt("/reports", loaded(ALL_DONE));
expect(html).toContain(BANNER);
expect(html).toContain("t(onboarding.banner.complete)");
});
it("waits for the Budget entitlement before showing the counter", () => {
const waiting = renderAt("/reports", loaded(IMPORTED), false);
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
"t(onboarding.banner.progress[done=6,total=8])",
);
});
it("before the visit steps' initialization: no counter, and no end for a path that was complete (#425)", () => {
const waiting = renderAt("/reports", loaded({ ...IMPORTED, acknowledged: [], visitStepsInitialized: false }));
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
const complete = renderAt("/reports", loaded({ ...ALL_DONE, acknowledged: [], visitStepsInitialized: false }));
expect(complete).not.toContain(BANNER);
expect(complete).not.toContain("onboarding.banner.next");
});
it("starts with the list folded", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain("onboarding.checklist.title");
});
});

View file

@ -0,0 +1,144 @@
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397, #409).
//
// renderToStaticMarkup runs no click, so React's hooks come from the shared
// harness (src/test/hookHarness.ts): the slot runs as a plain function, and
// its state survives from one call to the next as it would between two
// renders of a mounted component.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Outlet } from "react-router-dom";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
pathname: "/reports",
onboarding: null as unknown,
}));
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: ctx.pathname }),
}));
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => ctx.onboarding,
}));
vi.mock("./Sidebar", () => ({ default: () => null }));
import { hookHarness } from "../../test/hookHarness";
import AppShell, { OnboardingBannerSlot } from "./AppShell";
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function onboardingValue(): OnboardingValue {
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "banner",
cardPage: null,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
/** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps {
const element = harness.render(() => OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>);
expect(element.type).toBe(OnboardingBanner);
return element.props;
}
beforeEach(() => {
harness.clear();
ctx.pathname = "/reports";
ctx.onboarding = onboardingValue();
});
describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => {
const value = ctx.onboarding as OnboardingValue;
const props = renderSlot();
expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist);
expect(props.nextStep).toBe(value.nextStep);
expect(props.budgetReady).toBe(true);
expect(props.visitStepsInitialized).toBe(true);
expect(props.listOpen).toBe(false);
expect(props.onHide).toBe(value.hide);
expect(props.onResume).toBe(value.resume);
});
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
ctx.onboarding = {
...onboardingValue(),
mode: "none",
budgetReady: false,
visitStepsInitialized: false,
};
const props = renderSlot();
expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false);
expect(props.visitStepsInitialized).toBe(false);
});
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(true);
// Another render without a click keeps it unfolded.
expect(renderSlot().listOpen).toBe(true);
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(false);
});
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
ctx.pathname = "/import";
const shell = harness.render(() => AppShell() as ReactNode);
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
expect(slot).toBeDefined();
expect(slot.key).toBe("/import");
ctx.pathname = "/transactions";
const [next] = find(harness.render(() => AppShell() as ReactNode), (e) => e.type === OnboardingBannerSlot);
expect(next.key).toBe("/transactions");
});
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
const shell = harness.render(() => AppShell() as ReactNode);
const [main] = find(shell, (e) => e.type === "main");
expect(main).toBeDefined();
const [provider] = find(main.props.children, (e) => typeof e.type === "function");
const children = provider.props.children as ReactNode[];
expect(Array.isArray(children)).toBe(true);
const [first, second] = children as AnyElement[];
expect(first.type).toBe(OnboardingBannerSlot);
expect(second.type).toBe(Outlet);
});
});

View file

@ -1,12 +1,50 @@
import { Outlet } from "react-router-dom";
import { useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Sidebar from "./Sidebar";
import OnboardingBanner from "../onboarding/OnboardingBanner";
import { OnboardingProvider, useOnboarding } from "../../contexts/OnboardingContext";
/**
* The getting-started banner (#397) on top of the routed page. It reads the
* shared path, so it sits inside the provider; the path's `mode` already keeps
* it off a hidden path, the empty dashboard (D11), a page that carries a step
* card and the first load, and the banner renders nothing then.
*
* "Show the list" unfolds the list in place. `AppShell` mounts one slot per
* route, so each page starts folded and the banner stays one line on top of
* it (D7).
*/
export function OnboardingBannerSlot() {
const onboarding = useOnboarding();
const [listOpen, setListOpen] = useState(false);
return (
<OnboardingBanner
mode={onboarding.mode}
checklist={onboarding.checklist}
nextStep={onboarding.nextStep}
budgetReady={onboarding.budgetReady}
visitStepsInitialized={onboarding.visitStepsInitialized}
listOpen={listOpen}
onToggleList={() => setListOpen((open) => !open)}
onHide={onboarding.hide}
onResume={onboarding.resume}
/>
);
}
export default function AppShell() {
const { pathname } = useLocation();
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
{/* The getting-started path, shared by every page (#396), and its banner in the page flow (#397). */}
<OnboardingProvider>
<OnboardingBannerSlot key={pathname} />
<Outlet />
</OnboardingProvider>
</main>
</div>
);

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