Compare commits

...

152 commits
v0.8.0 ... main

Author SHA1 Message Date
le king fu
75ea48d96a chore: release v0.9.1
All checks were successful
Release / build-and-release (push) Successful in 29m42s
2026-05-10 20:36:51 -04:00
3024374e50 docs(changelog): note maximus-api activation post-0.9.0 (#197) 2026-05-10 19:27:33 +00:00
bc7a0e0231 docs(adr): 0013 — stocks provider evaluation, AV retained as bascule target (#196) 2026-05-09 12:40:08 +00:00
le king fu
9010c04315 state: sync after #187 + #188 2026-05-03 19:42:55 -04:00
d2e65ae1ea Merge PR #195: chore(balance) post-merge cleanup of #182-#185 reviews (#187) 2026-05-03 23:42:14 +00:00
le king fu
4095aec453 Merge remote-tracking branch 'origin/main' into issue-187-balance-cleanup-post-184-185
All checks were successful
PR Check / rust (pull_request) Successful in 22m28s
PR Check / frontend (pull_request) Successful in 2m33s
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-05-03 19:41:55 -04:00
4e7ba6b460 Merge PR #194: fix(ui) apply WebKitGTK date picker workaround to remaining 7 inputs (#188) 2026-05-03 23:39:46 +00:00
le king fu
9dd78b77f2 fix(rust): wrap Modified Dietz formula doc block in text fence (S7)
All checks were successful
PR Check / rust (pull_request) Successful in 23m2s
PR Check / frontend (pull_request) Successful in 2m42s
Before this commit, `cargo test --doc --manifest-path src-tauri/Cargo.toml`
failed: the indented formula at return_calculator.rs:12-13 was parsed by
rustdoc as a Rust code block and the pseudo-math (`R = ... sum(CF_i)`)
did not compile. Pre-existing since commit 531624b.

Wrapping the formula in an explicit `\`\`\`text` fence tells rustdoc to
render but not compile-test the block. `cargo test --doc` now passes
(0 doctests, no failures).

Also adds the consolidated #187 entry to CHANGELOG.md and CHANGELOG.fr.md
under Fixed/Corrigé summarizing all six fixes (S1, S2, S3, S4, S5, S7) —
S6 already factorized, S8 deferred to backlog, S9 obsolete.

Suggestion S7 from worker note on #176 (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:31:29 -04:00
le king fu
a7daabdf70 refactor(balance): use useTranslation directly in BalanceOnboardingCard.Step (S5)
The internal Step component received `t: TFunction` as a prop while every
other component in the codebase calls useTranslation() directly at the
top of the function. Aligns with the majority pattern.

Suggestion S5 from PR #184 review (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:29:12 -04:00
le king fu
372a785834 fix(balance): hide period selector, chart and table on empty /balance (S2)
Before this commit, /balance rendered the BalanceOnboardingCard plus the
period selector + evolution chart + accounts table whenever the user had
no accounts or no snapshot. The lower three components surfaced their
own empty states, producing 3 stacked "no data" messages under the
onboarding card.

Lifts the (accountsCount, hasAnySnapshot) computation out of the inline
IIFE and uses a single isEmpty branch: empty profiles see only the
BalanceOnboardingCard; populated profiles see the full overview.

No logic change — only JSX restructuring. Tests covering useBalanceOverview
and BalanceOnboardingCard remain green (61 tests passing).

Suggestion S2 from PR #184 review (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:28:41 -04:00
le king fu
445822b792 fix(balance): pre-seed balance_starter_proposed pref for new profiles (S1)
Before this commit, a brand-new profile briefly showed the
StarterAccountsModal even though the 4 starter accounts were already
seeded — the modal rendered 4 collision rows with no actionable choice
before being dismissed. Pre-seeding the pref in consolidated_schema.sql
suppresses the modal on first /balance visit for new profiles entirely.

Existing profiles already running the app are unaffected: they handle
the modal once on their first /balance visit (the pref-write happens on
dismiss). No migration is needed for them.

Suggestion S1 from PR #185 review (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:28:04 -04:00
le king fu
8c3a64d172 fix(balance): re-check collisions in-transaction in proposeStarterAccounts (S3)
Adds defense-in-depth: each iteration runs a SELECT COUNT(*) WHERE name=?
AND balance_category_id=? AND archived_at IS NULL inside the BEGIN/COMMIT
block, immediately before its INSERT. On a hit, the iteration skips the
INSERT silently and the returned ids array excludes the skipped starter.

Rationale: balance_accounts has no UNIQUE constraint on (name, category)
and the upstream pre-filter (getStarterCollisions) is best-effort. If a
race or a bypass slips a duplicate through, the in-txn check catches it
without surfacing a confusing error to the user.

Existing two tests in StarterAccountsModal.test.tsx updated to mock the
new SELECT call sequence; new test "skips silently when in-txn collision
check finds an existing account" added.

Suggestion S3 from PR #185 review (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:27:16 -04:00
le king fu
2eeac78b40 fix(balance): exclude archived accounts from starter collisions (S4)
getStarterCollisions now filters `archived_at IS NULL` so a starter
account the user voluntarily archived no longer blocks re-creation
through the StarterAccountsModal. Matches the rest-of-codebase
convention (active = is_active=1 AND archived_at IS NULL).

Suggestion S4 from PR #185 review (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:26:23 -04:00
le king fu
3b9badb726 fix(ui): apply WebKitGTK date picker workaround to remaining 7 inputs (#188)
All checks were successful
PR Check / rust (pull_request) Successful in 22m55s
PR Check / frontend (pull_request) Successful in 2m29s
Extends PR #189's fix (one input on /balance/snapshot) to the 7 remaining
native <input type="date"> fields across 4 components:
- transactions/TransactionFilterBar.tsx (dateFrom + dateTo)
- adjustments/AdjustmentForm.tsx (form.date)
- balance/LinkTransfersModal.tsx (from + to)
- dashboard/PeriodSelector.tsx (localFrom + localTo)

Each onChange handler now calls e.currentTarget.blur() after the state
update to dismiss the native date popup on Linux Tauri WebView. The call
is a no-op on Windows WebView2 / macOS WKWebView, where the picker
already auto-closes.

No automated test added: this is a WebKitGTK-specific WebView quirk that
cannot be reproduced in jsdom/vitest. Manual smoke test on Linux Tauri
dev was the validation, mirroring PR #189's approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:19:20 -04:00
le king fu
fbd8be403a state: bootstrap STATE.md (opt-in)
simpl-resultat n'avait jamais ete opt-in pour le pattern STATE.md
malgre son statut de produit final commercial. Cette commit pose le
seed (3 sections : Position actuelle / Decisions recentes /
Blockers actifs) et ajoute l'import @STATE.md dans CLAUDE.md pour
qu'il soit auto-charge en session.

Step 10.5 de /fix-issue prendra le relais sur les prochaines PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:55:28 -04:00
87dfd59eda Merge PR #193: fix(deps) bump postcss to 8.5.13 to address GHSA-qx2v-qp2m-jg93 (#180) 2026-05-03 19:32:50 +00:00
le king fu
0a8b5c7805 fix(deps): bump postcss to 8.5.13 to address GHSA-qx2v-qp2m-jg93 (#180)
All checks were successful
PR Check / rust (pull_request) Successful in 23m30s
PR Check / frontend (pull_request) Successful in 2m26s
Transitive dependency via vite (range ^8.5.3 already accepts the fix).
Lockfile-only change; no package.json modification needed.

Advisory GHSA-qx2v-qp2m-jg93 is a moderate severity XSS via unescaped
</style> in the CSS stringifier output. postcss runs at build time only
and never ships in the Tauri binary, so practical exposure is nil — but
this clears the npm audit warning and the defenseur finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:21:18 -04:00
efea8fb273 Merge PR #192: refactor(settings) split monolithic page into /settings/{users,data,systems} (#190) 2026-05-03 13:57:43 +00:00
le king fu
f02fd95ab1 refactor(settings): split monolithic Settings page into 3 sub-pages (#190)
All checks were successful
PR Check / rust (pull_request) Successful in 22m55s
PR Check / frontend (pull_request) Successful in 2m27s
The single 12-card SettingsPage is replaced by a hub at /settings linking
to three thematic sub-pages mounted via a shared SettingsLayout (Outlet):

  /settings           SettingsHomePage     (3 cards-cluster + PageHelp)
  /settings/users     UsersSettingsPage    (Account, License, DocsContent)
  /settings/data      DataSettingsPage     (Categories, DataManagement,
                                            PriceFetchConsentToggle)
  /settings/systems   SystemsSettingsPage  (Version, UpdateCard,
                                            ChangelogContent, LogViewer)

DocsPage and ChangelogPage are extracted into reusable DocsContent /
ChangelogContent components and the standalone /docs and /changelog
routes become Navigate redirects to preserve external bookmarks and
release-note links. UpdateCard is extracted from the inline updater
block for symmetry and testability.

TokenStoreFallbackBanner is mounted once in SettingsLayout, surfacing
the OS-keychain-fallback warning across the four main routes only.
The two existing /settings/categories/{standard,migrate} sub-routes
stay flat (siblings of SettingsLayout) to keep their focused flows
free of the banner — their internal back-links now point to
/settings/data.

i18n FR/EN gain settings.{home, users, data, systems, backToHome};
docs/architecture.md and CHANGELOG{,.fr}.md updated. Pure refactor of
presentation: no new business logic, no Tauri commands, no SQL
migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:02 -04:00
e7e02d636c Merge PR #191: docs replace JWT-like Bearer placeholder with <license-token> (#181) 2026-05-02 20:10:55 +00:00
le king fu
7f5e5a8c71 docs: replace JWT-like Bearer placeholder with <license-token> (#181)
All checks were successful
PR Check / rust (pull_request) Successful in 22m12s
PR Check / frontend (pull_request) Successful in 2m29s
Defenseur secrets-scanner false positive: the truncated example token
in api-contract-prices.md:471 passed the entropy threshold for the
"Bearer Token" pattern. Swap for an explicit <license-token>
placeholder so the next defenseur run no longer flags it.

No user-visible behavior change — doc placeholder only, no CHANGELOG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:05:20 -04:00
f9b4e4fa40 Merge PR #189: fix(ui) close native date picker after selection on WebKitGTK (#177) 2026-05-02 20:01:06 +00:00
le king fu
0d50a92b0e fix(ui): close native date picker after selection on WebKitGTK (#177)
All checks were successful
PR Check / rust (pull_request) Successful in 22m43s
PR Check / frontend (pull_request) Successful in 2m26s
WebKitGTK (Linux Tauri WebView) does not auto-dismiss the native
<input type="date"> popup after a value commit — the user has to
press Esc. Force-blur on change is a no-op on Edge Chromium WebView2
(Windows) and WKWebView (macOS), where the popup already closes.

Scope narrowed to /balance/snapshot per issue body. Six other date
inputs across the app share the same WebKitGTK bug; tracked in a
follow-up issue rather than bundled here.

Diagnostic: Ubuntu 24.04 + libwebkit2gtk-4.1-0 2.50.4-0ubuntu0.24.04.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:55:57 -04:00
le king fu
4cd0ac9a13 Merge PR #186: feat(branding) replace default Tauri icon + bundle 64x64
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-05-02 15:46:03 -04:00
le king fu
0cf13de7fe Merge PR #185: feat(balance) starter accounts + opt-in modal + ADR 0012 (#179) 2026-05-02 15:32:01 -04:00
le king fu
a9d1301dd2 Merge PR #184: feat(balance) 2-step onboarding card on empty /balance (#178) 2026-05-02 15:32:01 -04:00
le king fu
e342a1f567 Merge PR #183: fix(balance) atomic snapshot save + cleanup migration v11 (#176) 2026-05-02 15:32:01 -04:00
le king fu
3260ea8c47 Merge PR #182: fix(balance) SQL aggregate misuse in getAccountsPeriodAnchor (#175) 2026-05-02 15:31:53 -04:00
le king fu
8030a4a1c4 feat(branding): bundle 64x64 icon in tauri.conf
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m23s
Follow-up to PR #186 review: tauri icon CLI generates 64x64.png
but it was not declared in bundle.icon, so packagers (deb/rpm)
weren't picking it up. Add it alongside the other Linux sizes.
2026-05-02 15:01:35 -04:00
le king fu
d147520d6b feat(branding): replace default Tauri icon with custom design
All checks were successful
PR Check / rust (pull_request) Successful in 22m51s
PR Check / frontend (pull_request) Successful in 2m24s
Robot-faced calculator with a privacy lock on the Enter / `=` key.
Conveys the four product values: robot (assistant), simplicity
(geometric shapes), accounting (calculator), privacy (lock).

- New source SVG at src-tauri/icons/icon.svg (kept in repo for future
  iterations) and public/icon.svg (web favicon)
- Regenerated 16 platform-specific raster icons via `tauri icon`
- Removed unused default Vite/Tauri SVG assets from public/
- Fixed window <title> ("Tauri + React + Typescript" → "Simpl'Résultat")
- gitignore ios/ and android/ subdirs (out-of-scope, desktop-only targets)
2026-05-02 14:51:55 -04:00
le king fu
cd0a2b826f feat(balance): starter accounts + opt-in modal + ADR 0012
Part 1 — New profiles: seed 4 starter accounts in
consolidated_schema.sql (Compte chèque/CELI/REER/Compte
non-enregistré, currency CAD, is_active=1) right after the
balance_categories seeds. Categories resolved via SELECT subquery
on the seeded `key` values for robustness.

Part 2 — Existing profiles: StarterAccountsModal proposes the same
4 starters at first /balance visit. Default-checked checkboxes,
collision rule (case-insensitive trim name + matching category)
disables matches with a "Déjà présent" tooltip. The atomic helper
`proposeStarterAccounts` wraps the inserts in BEGIN/COMMIT (rolls
back on error). user_preferences.balance_starter_proposed records
{shown_at, accepted} so the modal never reappears, dismissed or
confirmed.

Part 3 — docs/adr/0012-balance-two-level-model.md (Proposed):
captures the future vehicles × compositions model for reflection,
no code change. Numbered 0012 because 0011 was already taken by
the providers-best-effort-yahoo ADR. Linked from architecture.md
ADR table and Bilan section.

Tests: StarterAccountsModal.test.tsx covers STARTER_ACCOUNTS shape,
getStarterCollisions (case-insensitive trim, category-scoped) and
proposeStarterAccounts (insert order, COMMIT, ROLLBACK on failure).
No render tests — mirrors the BalanceOnboardingCard pattern (no
jsdom configured).

Resolves #179
2026-05-02 11:59:45 -04:00
le king fu
eac2a516b5 feat(balance): 2-step onboarding card on /balance empty state
Replace empty BalanceOverviewCard with BalanceOnboardingCard showing
two steps:
1. Create an account
2. Enter a snapshot

Step 2 is grayed out until at least one account exists; the entire
card is replaced by BalanceOverviewCard once a snapshot is recorded.
Hide "+ New snapshot" button when 0 accounts (it lives inside the
overview card, which is now hidden in that state).

Improve SnapshotEditPage noAccounts copy to clarify account vs
snapshot semantics.

Resolves #178
2026-05-02 11:48:57 -04:00
le king fu
50b119121f fix(balance): atomic snapshot save with BEGIN/COMMIT + cleanup migration
useSnapshotEditor.save now validates all simple/priced lines in-memory
before any DB write, then delegates to a new saveSnapshotAtomic helper
that wraps INSERT snapshot + INSERT lines in an explicit BEGIN/COMMIT
transaction (ROLLBACK on catch). Pattern matches categorizationService.

Migration v11 cleans existing orphan snapshots in profiles that hit the
old race; new orphans are no longer possible thanks to the transaction.

Resolves #176
2026-05-01 07:33:44 -04:00
le king fu
44cc77d8f6 fix(balance): use ROW_NUMBER window function in getAccountsPeriodAnchor
All checks were successful
PR Check / rust (pull_request) Successful in 22m48s
PR Check / frontend (pull_request) Successful in 2m22s
SQLite raised "misuse of aggregate function MIN()" because MIN was used
in the WHERE clause of a scalar subquery. Replace with ROW_NUMBER()
OVER (PARTITION BY account_id ORDER BY snapshot_date ASC) filtered on
rn = 1.

Adds vitest coverage and a regression test for /balance load.

Resolves #175
2026-05-01 07:18:53 -04:00
le king fu
bde47dabed chore(gitignore): ignore reports/ and spec-* scratch
reports/ is used by /autopilot for daily reports and per-worker decision
logs (scratch). spec-decisions-*.md and spec-plan-*.md are produced by
/plan-overnight and committed only when promoted to docs/archive/.

Prevents `git add -A` in workers from sweeping these into PRs.
2026-05-01 07:12:24 -04:00
le king fu
5836760f3c chore: release v0.9.0
All checks were successful
Release / build-and-release (push) Successful in 25m25s
2026-04-29 19:20:03 -04:00
67c48029a0 Merge pull request 'feat(prices): commit smoke test scaffold for /v1/prices (Phase A)' (#173) from issue-161-smoke-scaffold into main
feat(prices): commit smoke test scaffold for /v1/prices (Phase A) (#173)

Refs #161
2026-04-29 10:25:36 +00:00
le king fu
e0844f0f34 feat(prices): commit smoke test scaffold for /v1/prices
All checks were successful
PR Check / rust (pull_request) Successful in 22m30s
PR Check / frontend (pull_request) Successful in 2m24s
Phase A of #161: ships the smoke script and README before the
maximus-api prices-proxy endpoint is live in prod. The script will
fail on case 1 (HTTP 404) until /v1/prices is implemented and
deployed — that is expected; running it is gated to Phase B (after
prices-proxy ships, before cutting v0.9.0).

Script covers 4 cases:
  1. Stock happy path (AAPL) — HTTP 200, .price > 0
  2. Crypto happy path (BTC) — HTTP 200, .price > 0
  3. Invalid symbol — HTTP 404, error.code=symbol_not_found
  4. Missing auth — HTTP 401, error.code=missing_token

`set -euo pipefail`, exits non-zero on first failure. Reads token
from MAXIMUS_API_TEST_TOKEN env var (never committed). README
documents env vars and the two paths for obtaining a premium test
token (admin endpoint TODO in maximus-api, manual JWT signing as
current workaround).

CSP whitelist for https://api.lacompagniemaximus.com is already in
place in src-tauri/tauri.conf.json — verified, no change needed.

No application code touched; npm test (492) and cargo test --lib
(69) remain green.

Phase A only (#161)
2026-04-28 21:31:27 -04:00
f16f340c22 Merge pull request 'chore(ci): drop redundant push trigger; add concurrency group' (#172) from issue-171-ci-drop-push-trigger into main
chore(ci): drop redundant push trigger; add concurrency group (#172)

Fixes #171
2026-04-29 01:17:30 +00:00
le king fu
af36b51cf7 chore(ci): drop redundant push trigger; add concurrency group
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m26s
The `push` + `pull_request` combo doubled CI runs on every PR (visible
on #170 with 4 pending checks instead of 2). Drop `push`, keep
`pull_request: branches: [main]`.

Trade-off: branches pushed without an open PR no longer get CI
feedback. Open a draft PR if you want CI to run before requesting
review — `/fix-issue` always opens a PR right after pushing, so the
gap is essentially zero in practice.

Also adds a concurrency group `ci-${{ github.ref }}` with
cancel-in-progress so force-pushes cancel the previous run instead
of stacking.

Same change applied to .github/workflows/check.yml (GitHub mirror)
to keep the two configs in sync.

Fixes #171
2026-04-28 20:50:34 -04:00
3342fd9bb7 Merge pull request 'feat(balance): add asset_type column to balance_categories' (#170) from issue-169-asset-type-balance-categories into main
feat(balance): add asset_type column to balance_categories (#170)

Fixes #169
2026-04-29 00:47:01 +00:00
le king fu
3963f552ae feat(balance): add asset_type column to balance_categories
All checks were successful
PR Check / rust (push) Successful in 23m42s
PR Check / frontend (push) Successful in 2m26s
PR Check / rust (pull_request) Successful in 22m55s
PR Check / frontend (pull_request) Successful in 2m24s
Priced balance categories now carry an explicit `asset_type`
('stock' | 'crypto') so PriceFetchControl can route to the right
provider without symbol heuristics. ETH = Ethan Allen NYSE AND
Ethereum crypto are no longer ambiguous.

Migration v10 adds a nullable column and backfills the two seeded
priced categories (key='stock','crypto'). Legacy custom priced rows
stay NULL until the user edits the category — SnapshotLineRow hides
the price-fetch button when asset_type is NULL on a priced row, so
manual entry remains available.

Service-side validation rejects priced creation without asset_type
('asset_type_required') and rejects values outside ('stock','crypto')
('asset_type_invalid'). Simple kind coerces asset_type to NULL.

The CategoryVariant of AccountForm shows the selector only when
kind=priced, requires it on submit, and resets it on kind switch.
i18n keys added under balance.category.assetType.* (FR + EN).

Tests:
- 4 new Rust migration tests in lib.rs (column add, seed backfill,
  legacy row stays NULL, CHECK rejects 'gold')
- 6 new vitest cases on createBalanceCategory + listBalanceAccounts
  asserts c.asset_type AS category_asset_type in the join
- balance-flow integration test updated to pass asset_type='stock'

No new test for SnapshotLineRow render guard — project lacks
@testing-library/react + jsdom; the guard is one boolean expression
covered by manual QA per autopilot decisions in PR #167.

Fixes #169
2026-04-28 19:54:04 -04:00
877aff8d6d Merge pull request 'feat(prices): Settings revocation toggle for price_fetching_consent (#159)' (#168) from issue-159-settings-revoke-toggle into main 2026-04-28 01:36:14 +00:00
le king fu
a6097afcf3 chore: drop decisions-log.md (autopilot scratch, conflicts with main cleanup)
All checks were successful
PR Check / rust (push) Successful in 23m34s
PR Check / frontend (push) Successful in 2m30s
PR Check / rust (pull_request) Successful in 23m22s
PR Check / frontend (pull_request) Successful in 2m27s
2026-04-27 21:35:57 -04:00
d140ed938a Merge pull request 'feat(prices): PriceFetchControl + consent modal + best-effort UX (#158)' (#167) from issue-158-pricefetchcontrol into main 2026-04-28 01:35:23 +00:00
le king fu
da4eef2bdd chore: drop decisions-log.md (autopilot scratch, conflicts with main cleanup)
All checks were successful
PR Check / rust (push) Successful in 23m37s
PR Check / frontend (push) Successful in 2m36s
PR Check / rust (pull_request) Successful in 23m18s
PR Check / frontend (pull_request) Successful in 2m27s
2026-04-27 21:35:04 -04:00
le king fu
88c3c04dea chore: untrack decisions-log.md (autopilot scratch from #166 merge) 2026-04-27 21:32:59 -04:00
97f91f87aa Merge pull request 'feat(prices): balance.service prices section + rate-limit + tests (#156)' (#166) from issue-156-balance-service-prices into main 2026-04-28 01:32:41 +00:00
le king fu
55c610c1f2 chore: untrack decisions-log.md (autopilot scratch file)
The autopilot worker prompt instructed writing decisions to
decisions-log.md at worktree root, but didn't exclude it from git
add — so each worker committed its version, causing add/add merge
conflicts between sibling PRs in the prices milestone.

Add to .gitignore so future autopilot runs leave the scratch file
local only.
2026-04-27 21:32:28 -04:00
edd1a5cbe4 Merge pull request 'feat(prices): Rust Tauri command fetch_price + tests (#155)' (#165) from issue-155-rust-fetch-price into main 2026-04-28 01:06:40 +00:00
01cfbdba8b Merge pull request 'feat(prices): useIsPremium hook (#157)' (#164) from issue-157-use-is-premium into main 2026-04-28 01:06:35 +00:00
3b2384af25 Merge pull request 'feat(prices): i18n FR/EN keys + CHANGELOG entries (#160)' (#163) from issue-160-i18n-changelog into main 2026-04-28 01:06:30 +00:00
0511d2ef06 Merge pull request 'feat(prices): commit /v1/prices contract + ADR 0011 (#154)' (#162) from issue-154-contract-and-adr into main 2026-04-28 01:04:48 +00:00
le king fu
80c28d43ac feat(prices): Settings revocation toggle for price_fetching_consent
All checks were successful
PR Check / rust (push) Successful in 28m16s
PR Check / frontend (push) Successful in 3m0s
PR Check / rust (pull_request) Successful in 29m45s
PR Check / frontend (pull_request) Successful in 3m6s
- Adds PriceFetchConsentToggle to SettingsPage Privacy section
- Reads/writes user_preferences.price_fetching_consent for active profile
- Confirmation dialog before revoke (DELETE the key entirely so next click re-opens consent modal)
- Disabled (with notPremium tooltip) when license is not premium
- Adds deletePreference() to userPreferenceService
- Adds settings.privacy.title i18n key (FR + EN)
- 10 vitest tests covering all paths

Closes #159
2026-04-27 08:41:15 -04:00
le king fu
8fa34d786d merge: bring in #158 (transitively #156/#157/#160) 2026-04-27 08:38:16 -04:00
le king fu
043e9bf622 feat(prices): PriceFetchControl component + consent modal + best-effort UX
All checks were successful
PR Check / rust (push) Successful in 30m45s
PR Check / frontend (push) Successful in 3m12s
PR Check / rust (pull_request) Successful in 28m50s
PR Check / frontend (pull_request) Successful in 3m15s
- New component renders button + consent modal + spinner + attribution
- Best-effort warning shown once per session for stock categories
- Hidden if not premium or category kind != 'priced'
- Consent persisted per-profile in user_preferences.price_fetching_consent
- Manual unit_price input remains active in all paths
- 17 vitest tests (no RTL/jsdom — logged MEDIUM in decisions-log.md)
- Wired into SnapshotLineRow/SnapshotEditor/SnapshotEditPage
- asset_type hardcoded to 'stock' pending category schema extension (MEDIUM)

Closes #158
2026-04-27 08:36:23 -04:00
le king fu
c90badae39 merge: bring in balance.service prices namespace from #156 2026-04-27 08:30:53 -04:00
le king fu
99814b9a0d merge: bring in useIsPremium hook from #157 2026-04-27 08:30:50 -04:00
le king fu
b1dc76b487 merge: bring in i18n keys from #160 2026-04-27 08:30:48 -04:00
le king fu
920f81fce5 feat(prices): balance.service prices section with rate-limit + dedup + retries
All checks were successful
PR Check / rust (push) Successful in 27m27s
PR Check / frontend (push) Successful in 2m49s
PR Check / rust (pull_request) Successful in 28m58s
PR Check / frontend (pull_request) Successful in 2m57s
- prices.fetchPrice wraps invoke('fetch_price', ...) with local rate-limit (1/2s), in-flight dedup, exp backoff on 5xx (2/4/8s, max 3 retries), no retry on 4xx/429, hard 100/session cap
- 9 vitest tests with vi.useFakeTimers() (happy, 401/403/404, 429 no-retry, 5xx retries, dedup, pacing, session cap)
- Annexe B i18n mapping wired (PriceError → balance.priceFetching.errors.* keys)
- Session cap checked before rate-limit/dedup; failures do not consume budget (MEDIUM decision)

Closes #156

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:28:24 -04:00
le king fu
531624bcb4 feat(prices): Rust Tauri command fetch_price + tests
All checks were successful
PR Check / rust (push) Successful in 25m28s
PR Check / frontend (push) Successful in 2m33s
PR Check / rust (pull_request) Successful in 25m38s
PR Check / frontend (pull_request) Successful in 2m44s
- Add fetch_price command with PriceResponse and FetchPriceError types
- Privacy-strict header policy (Authorization, Accept, User-Agent only)
- Rename SIMPL_API_URL -> MAXIMUS_API_URL across src-tauri
- 7+ mockito tests covering happy path, 401/403/404/429/5xx, and header allowlist
- Fix pre-existing clippy warnings (doc_overindented_list_items, is_multiple_of)

Closes #155
2026-04-27 08:23:18 -04:00
le king fu
98f68f7a1f feat(prices): useIsPremium hook from license.edition
All checks were successful
PR Check / rust (push) Successful in 26m18s
PR Check / frontend (push) Successful in 2m37s
PR Check / rust (pull_request) Successful in 25m0s
PR Check / frontend (pull_request) Successful in 2m41s
- Reads useLicense().state.edition === 'premium'
- Ergonomic only — server enforces independently (ADR 0011)
- 3 vitest tests (premium, base, free)
- CLAUDE.md hook count 12 -> 13

Closes #157
2026-04-27 08:11:23 -04:00
le king fu
ab7e0a3362 feat(prices): i18n FR/EN keys + CHANGELOG entries
All checks were successful
PR Check / rust (push) Successful in 26m25s
PR Check / frontend (push) Successful in 2m34s
PR Check / rust (pull_request) Successful in 26m20s
PR Check / frontend (pull_request) Successful in 2m54s
Closes #160
2026-04-27 08:06:54 -04:00
le king fu
ddb0cb257b docs(prices): commit /v1/prices contract + ADR 0011
All checks were successful
PR Check / rust (push) Successful in 25m39s
PR Check / frontend (push) Successful in 2m42s
PR Check / rust (pull_request) Successful in 26m45s
PR Check / frontend (pull_request) Successful in 2m58s
- Add frozen v2 /v1/prices API contract (docs/api-contract-prices.md)
- Add ADR 0011: providers best-effort Yahoo (docs/adr/0011-providers-best-effort-yahoo.md)
- Add dedicated ADR table section to docs/architecture.md (rows 0001-0011)

Closes #154
2026-04-27 08:06:03 -04:00
c14de9a6f8 Merge pull request 'feat(license): rotate Ed25519 public key for maximus-api (#49)' (#137)
Closes #49 — maximus-api now serves /licenses/* in production at https://api.lacompagniemaximus.com

Fixes #49
2026-04-26 13:45:01 +00:00
le king fu
97680417ee feat(license): rotate embedded Ed25519 public key (#49)
All checks were successful
PR Check / rust (push) Successful in 22m25s
PR Check / frontend (push) Successful in 2m25s
PR Check / rust (pull_request) Successful in 22m19s
PR Check / frontend (pull_request) Successful in 2m24s
Replace the placeholder public key with the one whose private
counterpart is now held by the maximus-api license server. The old
key had no licenses issued against it (the server did not exist), so
no users are affected.

The 34 Rust unit tests still pass — license_commands tests use
ad-hoc test keypairs rather than the embedded one, and
embedded_public_key_pem_parses confirms the new PEM is valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:42:40 -04:00
9c79b73871 Merge pull request 'docs(balance): architecture + ADRs + user guide (#145)' (#153) from issue-145-bilan-7 into main 2026-04-26 13:25:43 +00:00
51a6cec8f1 Merge pull request 'test(balance): cross-cutting integration tests (#144)' (#152) from issue-144-bilan-6 into main 2026-04-26 13:25:37 +00:00
8df1aed258 Merge pull request 'feat(balance): Modified Dietz returns + transfer linking (#142)' (#151) from issue-142-bilan-4 into main 2026-04-26 13:25:32 +00:00
47ecf886d2 Merge pull request 'feat(balance): /balance page + evolution chart + sidebar (#141)' (#150) from issue-141-bilan-3 into main 2026-04-26 13:25:26 +00:00
6341aeb74c Merge pull request 'feat(balance): priced-kind support (#140)' (#149) from issue-140-bilan-2 into main 2026-04-26 13:25:20 +00:00
a344eab2bb Merge pull request 'feat(balance): SnapshotEditPage + simple-kind editor (#146)' (#148) from issue-146-bilan-1b into main 2026-04-26 13:25:15 +00:00
b6387f4b31 Merge pull request 'feat(balance): schema migration v9 + service skeleton + AccountsPage (#138)' (#147) from issue-138-bilan-1a into main 2026-04-26 13:25:09 +00:00
le king fu
ce15c903e4 docs: i18n + CHANGELOG for Bilan documentation
All checks were successful
PR Check / rust (push) Successful in 22m56s
PR Check / frontend (push) Successful in 2m23s
- Add docs.balance.* keys (FR + EN) for the new Balance Sheet section
  consumed by DocsPage (title / overview / features / steps / tips).
- Wire the new section into DocsPage.tsx SECTIONS array with a Wallet
  icon, slotted between reports and settings.
- Add bilingual [Unreleased] CHANGELOG entry for #145 covering the
  architecture.md updates, the 3 ADRs, the new guide section and the
  i18n keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:06:53 -04:00
le king fu
bef330affb docs(guide): add user guide section for Bilan
New section 10 "Bilan" walks through:
- the 3 routes (/balance, /balance/snapshot, /balance/accounts)
- snapshot entry (simple + priced kinds, prefill button)
- transfer linking with auto-suggested direction
- multi-horizon Modified Dietz returns (3M / 1Y / since inception)
  read alongside the unadjusted return for comparison
- the FK RESTRICT user-facing message on linked-transaction deletion
- price-fetching premium flagged "coming Phase 5"

Renumbers Settings to section 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:06:46 -04:00
le king fu
098e15bb5c docs(adr): add ADRs 0008-0010 (Modified Dietz, proxy price-fetching, FK RESTRICT)
- 0008 — Modified Dietz: justifies the choice over ROI / TWR / IRR;
  references commands/return_calculator.rs and the 7 TDD cases.
- 0009 — Proxy price-fetching via maximus-api: documents the privacy
  proxy architecture (header stripping, no log correlation, fixed
  simpl-resultat UA), the Yahoo + CoinGecko adapter abstraction, the
  Bearer activation_token auth strategy, the rate limiting (client
  + server), and the dual-side premium gating. Implementation stays
  BLOCKED in #143; this ADR documents the agreed-upon design.
- 0010 — FK ON DELETE RESTRICT on balance_account_transfers
  .transaction_id: justifies the integrity-over-friction trade-off
  for Modified Dietz reproducibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:06:40 -04:00
le king fu
4d5a0e2e3b docs(architecture): document balance domain (tables, services, hooks, commands, routes)
Update docs/architecture.md to reflect the Bilan feature:
- BDD: 5 new tables + 7 indexes + CHECK + FK invariants (CAD lock,
  kind invariants, ON DELETE RESTRICT on transaction_id)
- Migrations: v8 + v9 added to history
- Services: balance.service.ts with its 4 logical sections
- Hooks: useBalanceAccounts / useSnapshotEditor / useBalanceOverview
- Commands: compute_account_return (1 new) + Phase 5 fetch_price stub
- Routing: /balance, /balance/snapshot, /balance/accounts
- Components: balance/ folder added in tree

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:06:30 -04:00
le king fu
5274e51907 chore: CHANGELOG entry for cross-cutting tests
All checks were successful
PR Check / rust (push) Successful in 22m44s
PR Check / frontend (push) Successful in 2m23s
Bilingual entry under [Unreleased] documenting the integration test
suite added for Issue #144: end-to-end happy path, currency lock,
priced-kind tolerance safety, computeAccountReturn wiring, three Rust
migration-on-seeded-DB scenarios, and the source-level non-regression
test on the inlined transfer icon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:54:04 -04:00
le king fu
5a54d37de5 test(transactions): add non-regression test for inline transfer icon
Source-level structural test on `TransactionTable.tsx` to lock down the
inlined transfer icon contract introduced in #142. Without RTL or jsdom
in the dev-deps, the test reads the component source and asserts:

  - the icon is gated by `linkedTransfersByTxId?.has(row.id)`,
  - optional-chaining short-circuits cleanly when the prop is omitted
    (zero-impact on pre-#142 callers),
  - the prop is declared OPTIONAL on the component interface,
  - the `Link2` glyph comes from lucide-react,
  - tooltip + aria-label go through `transactions.transferIcon.*` i18n
    keys,
  - the row's description cell layout (truncate span + title) stays
    shared between linked and non-linked rows.

Catches the specific regression vectors: someone removing the gate,
renaming the prop, or breaking the optional-chaining pattern that
guarantees the page renders identically when no transfers are linked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:53:59 -04:00
le king fu
50fe0ab1ac test(balance): add migration v9 integration on seeded DB
Three new Rust integration tests applied at the bottom of `lib.rs`'s
`#[cfg(test)] mod tests`. They exercise the realistic upgrade path: a v1
profile DB with imported transactions + categories already there gets the
v9 migration applied on top.

`migration_v9_preserves_existing_transactions_on_seeded_db` asserts no
row loss / data mutation after the migration runs. Spot-checks one
amount preserved verbatim and that the v9 seeded categories coexist with
the v1 categories table.

`integration_link_unlink_transfer_roundtrip_on_seeded_db` walks link →
joined-view read → blocked deletion (FK RESTRICT) → unlink → allowed
deletion → orphan-row sanity check. Covers the FK chain end-to-end on
real (non-stub) transaction ids.

`integration_modified_dietz_inputs_read_back_correctly_on_seeded_db`
mirrors the exact SQL used by `balance_commands.rs::read_value_at_or_before`
and `read_cash_flows`, asserting the snapshot-endpoint lookups and the
period-bounded JOINed cash flows return the expected shapes when run
against a seeded v1+v9 DB.

`integration_v9_preserves_v1_categories_and_keywords` verifies the
`categories.id` and `balance_categories.id` namespaces are independent
(same numeric id allowed on each table without collision).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:53:50 -04:00
le king fu
9adfb85d84 test(balance): add cross-cutting integration tests
End-to-end happy path through the full Bilan stack: account → priced
category → priced snapshot → linked transfer → return. Drives every
service against the existing in-memory FakeDb harness used by
category-migration tests so SQL shape (table names + parameters) can be
asserted alongside service outputs.

Currency lock: USD / EUR / GBP / JPY / AUD all rejected up-front by the
service with a typed `currency_unsupported` code, no DB hit. The CAD
default is verified to land in the INSERT params explicitly.

Priced-kind safety: a snapshot save with one out-of-tolerance line must
NOT clear pre-existing lines (the DELETE is gated behind the validation
loop). A drift just within ε is accepted unchanged.

computeAccountReturn wiring: malformed dates are rejected client-side
without invoking the Rust command; missing active profile yields a typed
`transfer_active_profile_unknown`; partial-period payloads are forwarded
unchanged (null fields preserved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:53:36 -04:00
le king fu
ca275821bc feat(balance): i18n + CHANGELOG for returns/transfers
All checks were successful
PR Check / rust (push) Successful in 22m37s
PR Check / frontend (push) Successful in 2m30s
Issue #142 / Bilan #4 — translations and changelog entries.

i18n (FR + EN):
- `balance.returns.partialTooltip`, `balance.returns.noTransfersWarning`
- `balance.accountsTable.return3m/return1y/sinceCreation/unadjusted`
  (label + tooltip variants)
- `balance.transfers.linkAction` + `balance.transfers.direction.{in,out}`
- `balance.transfers.modal.*` (every modal label, including the
  partial-failure summary and the per-row direction toggle)
- `balance.transfers.errors.*` (5 new typed error codes)
- `balance.evolution.transferIn/transferOut` (chart label)
- `transactions.transferIcon.tooltip/ariaLabel`

CHANGELOG (English source + French translation):
- New entry under `[Unreleased]` summarising the Modified Dietz
  formula, the per-account return columns (3M / 1A / since-inception
  + unadjusted), the link-transfers modal, the transactions-page
  inline icon, the typed FK error on bulk-delete paths, and the
  vertical reference markers on the evolution chart. References #142.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:39:06 -04:00
le king fu
faa09614a3 feat(balance): add transfer markers on evolution chart
Issue #142 / Bilan #4 — vertical reference lines for tagged transfers.

`BalanceEvolutionChart.tsx` accepts a new optional prop
`transferMarkers?: BalanceAccountTransferWithTransaction[]`. For every
marker whose `transaction_date` matches a date already on the X axis,
the chart renders a `<ReferenceLine>` (Recharts) — green for `in`
(capital added), red for `out` (capital removed). The marker is drawn
in both `line` and `stacked` modes; in line mode an inline label
("In" / "Out") sits at the top-right of the marker so the user can
identify the direction without hovering.

Markers whose date is between two snapshot ticks are filtered out
(Recharts categorical axis silently drops unknown ticks; preferred
over an off-axis bug). A future improvement is to switch the X axis
to a numeric/time scale so markers can land anywhere — out of scope
here per the autopilot prompt's "least invasive" guideline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:55 -04:00
le king fu
0e996a5aa1 feat(transactions): inline transfer icon + FK error message
Issue #142 / Bilan #4 — non-regressive transfer awareness in the
transactions table + clean error mapping on bulk delete.

- `TransactionTable.tsx`: optional new prop
  `linkedTransfersByTxId?: Map<txId, links[]>`. When supplied, a small
  `<Link2>` icon appears next to the description for every linked
  transaction; tooltip lists the account name(s) and direction(s).
  Without the prop, the table renders byte-for-byte identical to
  before — preserves the spec's non-regression invariant.
- `TransactionsPage.tsx`: loads the linked-transfers map once on mount
  via `listAllLinkedTransfersForTooltip()` (one batch SELECT) and
  threads it through to the table. Failure to load the map degrades
  gracefully to an empty map (icon simply doesn't appear).
- `transactionService.ts`: new `deleteTransaction(id)` helper +
  `TransactionLinkedToBalanceError` (typed FK guard). Pre-checks
  `balance_account_transfers` before attempting the DELETE so the
  error carries the offending account names; falls back to the FK
  pattern matcher if a race linked the transaction between the
  SELECT and the DELETE.
- `importedFileService.ts`: both bulk delete paths
  (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`)
  now pre-check for any linked transfer and surface the same typed
  error before they would explode on FK RESTRICT. The pre-check has
  a `LIMIT 50` safety cap on the global path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:46 -04:00
le king fu
a45e5c3cd0 feat(balance): add LinkTransfersModal + return columns in accounts table
Issue #142 / Bilan #4 — UI for transfer linking + per-account returns.

- New `LinkTransfersModal.tsx`: portal modal with date-range / category /
  free-text filters, multi-select with auto-proposed direction (`in` for
  negative bank amounts, `out` for positive — flippable per row).
  Submits via sequential `linkTransfer` calls; reports per-row failures
  inline (most common case: `transfer_already_linked` on a re-submit).
- `BalanceAccountsTable.tsx`: 4 new columns rendered side-by-side —
  3M / 1A / Since-inception (Modified Dietz via `compute_account_return`)
  + Unadjusted (`(V_end - V_start) / V_start`). Returns load lazily
  after mount via `Promise.all` over (account × horizon); per-cell
  failure leaves the slot at "—" without blocking the rest of the
  table. The actions menu gains a *Link transfers* item that bubbles
  the request up to the parent page. New props:
  `sinceCreationDate` (anchors the since-inception horizon) and
  `onLinkTransfers` (modal opener).
- `BalancePage.tsx`: hosts the new modal, loads the categories list
  once on mount for the filter dropdown, fetches the union of
  `listAccountTransfers` per account so the chart can render markers,
  and threads the earliest snapshot date down to the table. Reload
  is triggered after the modal reports at least one successful link.
- `balance.service.ts`: dropped the unused `BalanceAccountTransfer`
  import to satisfy `tsc --noUnusedLocals`.

`npm run build` clean. `npm test` → 429 passed. Manual sanity check:
the table renders "…" placeholders during the per-row return load,
then resolves to either a percentage or a "—" with the partial
tooltip when the underlying snapshot endpoint is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:24 -04:00
le king fu
dafdd4ce17 feat(balance): add returns + transfers section to balance.service
Issue #142 / Bilan #4 — TS bridge for the Modified Dietz command + plain
CRUD for transfer linking.

Types (`src/shared/types/index.ts`):
- `BalanceTransferDirection` ('in' | 'out')
- `BalanceAccountTransfer` (raw row) +
  `BalanceAccountTransferWithTransaction` (joined view)
- `AccountReturn` (mirrors the Rust struct, ready to receive the invoke
  payload as-is)

Service (`src/services/balance.service.ts`):
- `computeAccountReturn(accountId, periodStart, periodEnd)`: resolves the
  active profile's `db_filename` from `loadProfiles()` and calls the
  `compute_account_return` Tauri command.
- `linkTransfer(accountId, transactionId, direction, notes?)`: INSERT
  with duplicate guard (typed `transfer_already_linked` error instead of
  raw SQL UNIQUE failure).
- `unlinkTransfer(accountId, transactionId)`: DELETE with
  `transfer_not_linked` guard for stale-UI calls.
- `listAccountTransfers(accountId, dateRange?)`: joined SELECT for
  modal/list rendering.
- `listLinkedTransactionIds()`: returns a `Set<number>` for the
  transaction icon (one query, in-memory `.has()` lookups thereafter).
- `listAllLinkedTransfersForTooltip()`: returns
  `Map<transactionId, links[]>` for tooltip rendering.
- `suggestTransferDirection(amount)`: pure helper for the modal — maps
  negative bank amounts to 'in', positive to 'out'.
- `isLinkedTransactionFkError(error)`: detects the canonical SQLite "FK
  constraint failed" text so `transactionService.deleteTransaction` can
  surface a clear i18n message.
- 5 new error codes added to `BalanceErrorCode`.

Tests (`balance.service.test.ts`): 22 new vitest cases bringing the file
to 85 passed. Mocks `@tauri-apps/api/core` `invoke` and
`./profileService` `loadProfiles`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:27:16 -04:00
le king fu
23ff8466c0 fix(balance): use transactions.date column (not transaction_date)
The schema's transactions table uses `date` (see schema.sql:67), not
`transaction_date`. Compile-checked the column name was correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:24:13 -04:00
le king fu
0381dd48bb feat(balance): add compute_account_return Tauri command
Issue #142 / Bilan #4 — server-side Modified Dietz wrapper.

- New `src-tauri/src/commands/balance_commands.rs` with single command
  `compute_account_return(db_filename, account_id, period_start, period_end)`:
  - Opens the active profile DB via `rusqlite::Connection::open(app_data_dir
    / db_filename)` — matches `repair_migrations` / `delete_profile_db`.
  - Reads `value_start` (latest snapshot ≤ period_start) + `value_end`
    (latest snapshot ≤ period_end) via correlated SELECT.
  - Reads cash flows via JOIN `balance_account_transfers` ⨝
    `transactions` filtered by `transaction_date BETWEEN`. Sign applied
    per direction (`in` → +, `out` → −).
  - Calls `return_calculator::modified_dietz`, returns typed
    `AccountReturn`.
- Registered in `commands/mod.rs` (pub use) and in `lib.rs`'
  `tauri::generate_handler!` array.

`cargo check` clean. `cargo test --lib` → 54 passed (including the 7
return_calculator + 7 migration_v9 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:23:14 -04:00
le king fu
c9cdb5a891 feat(balance): add chrono dep + Modified Dietz return_calculator with tests
Issue #142 / Bilan #4 — TDD step 1.

- Added `chrono = "0.4"` (default-features off, `serde` + `std` features)
  to `src-tauri/Cargo.toml` for day-precision date arithmetic.
- New private module `src-tauri/src/commands/return_calculator.rs`:
  - `pub(crate) fn modified_dietz(value_start, value_end, cash_flows,
    period_start, period_end) -> AccountReturn`
  - `AccountReturn { value_start, value_end, net_contributions, return_pct,
    annualized_pct, is_partial, has_no_transfers_warning }` (Serialize)
  - Edge cases handled: missing start/end snapshot (`is_partial = true`,
    `return_pct = None`), no transfers (collapses to simple return + warn
    flag), zero-length period (skips annualization), V_start = 0 with first
    flow > 0 (account-created mid-period), depleted-then-refilled (no
    panic, finite output).
- 7 co-located TDD tests covering nominal + every edge case above.
- Module declared `pub(crate)` in `commands/mod.rs` (kept out of the
  wildcard re-export — only `balance_commands.rs` will consume it).

`cargo test --lib commands::return_calculator` → 7 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:21:37 -04:00
le king fu
1e261ae2ea feat(balance): i18n + CHANGELOG for /balance page
All checks were successful
PR Check / rust (push) Successful in 22m24s
PR Check / frontend (push) Successful in 2m22s
FR + EN translations under:
- nav.balance — sidebar label
- balance.overview.* — page title, latest total, Δ% vs previous,
  staleness warning, new-snapshot CTA, accounts table headers,
  empty/no-snapshot states
- balance.period.* — 3M / 6M / 1A / 3A / all selector labels
- balance.chart.* — empty state, mode legend, line / stacked
  toggle labels
- balance.sidebar — entry label (mirrors nav.balance)

CHANGELOG entry under [Unreleased] / Added documenting the new
page, period selector, evolution chart modes, accounts table,
sidebar entry, the four service helpers, and the new hook.

Refs: #141

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:08:10 -04:00
le king fu
83ac484a22 feat(balance): add sidebar Bilan entry
Insert a new "Bilan" / "Balance sheet" entry in NAV_ITEMS pointing
at /balance with the Wallet lucide-react icon. Position: between
Reports and Settings, matching the autopilot prompt instruction
and the spec-plan-bilan v2 ordering.

Sidebar.tsx imports Wallet and registers it in iconMap.

Refs: #141

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:07:13 -04:00
le king fu
ffefa90fd0 feat(balance): add BalancePage with chart + accounts table
Three new components composed under a new BalancePage at /balance:

- BalanceOverviewCard — latest aggregate net worth, Δ% vs the
  previous chronological snapshot (rendered as "—" when only
  one snapshot exists), 60-day staleness warning, and a
  "+ Nouveau snapshot" CTA pointing at /balance/snapshot.

- BalanceEvolutionChart — Recharts-based line / stacked-area
  toggle. Line mode plots SUM(value) per snapshot_date with a
  single primary-coloured stroke. Stacked mode transposes the
  byCategory series into one Area per category_key with a
  fixed 10-color palette indexed deterministically. Tooltip
  formats CAD via Intl.NumberFormat.

- BalanceAccountsTable — one row per active account with name,
  category label, latest value, and Δ% over the active period
  (latest_value vs the period anchor). Returns columns
  (3M / 1Y / since-creation / unadjusted) reserved for #142
  with a TODO marker. Action menu includes a disabled "Detail"
  placeholder + functional "Archive" wired through reload().

BalancePage composes the three with an inline period selector
(3M / 6M / 1A / 3A / Tout) and chart-mode toggle, both styled
as segmented controls. State flows through useBalanceOverview.

Route /balance registered before /balance/accounts in App.tsx.

Refs: #141

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:07:04 -04:00
le king fu
202b008bc9 feat(balance): add useBalanceOverview hook
Scoped useReducer hook backing BalancePage. Tracks:
- period (3M / 6M / 1A / 3A / all) — defaults to 1A
- chartMode (line / stacked) — defaults to line
- evolutionTotals + evolutionByCategory + accountsLatest +
  accountsPeriodAnchor (parallel-fetched on mount and on every
  period change via Promise.all)
- isLoading + error

Exposes computeBalanceDateRange(period, today) as a pure helper
so the date math is unit-testable without mocking time. Anchors
on `today` rather than the latest snapshot — keeps the chart's
right edge stable as the user enters new snapshots.

Refs: #141

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:06:38 -04:00
le king fu
396310aa74 feat(balance): add timeseries aggregator helpers + tests
Add four service helpers used by the upcoming `/balance` overview:

- getSnapshotTotalsByDate(range?) — SUM(value) GROUP BY snapshot_date
  with an optional inclusive [from, to] range. LEFT JOIN preserves
  empty snapshots as zero rows so the chart shows continuity.
- getSnapshotTotalsByCategoryAndDate(range?) — same aggregation broken
  down by balance_categories.key, returned as one row per snapshot
  date with a `byCategory` map. Powers the stacked-area variant.
- getAccountsLatestSnapshot() — one row per active account with the
  value of its most-recent snapshot line (NULL when none exists).
  Filters archived accounts via WHERE is_active = 1 AND archived_at
  IS NULL, matches the listBalanceAccounts default.
- getAccountsPeriodAnchor(range) — earliest snapshot_date >= from
  per account, with the value at that date — the anchor used to
  compute the per-account Δ% column on the accounts table.

Tests cover empty DB, single/multi snapshot, archived exclusion via
SQL inspection, date-range params (from-only, both bounds, open).

Refs: #141

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:06:23 -04:00
le king fu
80c0a97841 feat(balance): i18n + CHANGELOG for priced kind
All checks were successful
PR Check / rust (push) Successful in 22m31s
PR Check / frontend (push) Successful in 2m21s
- Adds keys under balance.category.kind, balance.category.form.kindLabel
  / kindHint*, balance.category.actions.deleteHasAccountsHint,
  balance.category.error.has_accounts, balance.account.form
  .symbolRequiredForPriced, balance.snapshot.priced.* (FR + EN).
- Extends balance.errors.* with the four new typed codes:
  snapshot_priced_quantity_required,
  snapshot_priced_unit_price_required,
  snapshot_priced_value_mismatch,
  snapshot_simple_must_be_scalar.
- CHANGELOG entries (FR + EN) under [Unreleased].

Refs #140

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:02:18 -04:00
le king fu
5bc7fe80b1 feat(balance): improve category deletion UX with linked-accounts message
- AccountsPage Categories tab now uses the new AccountForm 'category'
  variant for creation (with kind selector).
- Delete button is disabled when the category has linked accounts;
  the disabled tooltip surfaces the count.
- Clicking the delete button on a category with linked accounts now
  shows a dismissable error banner listing up to the first 3 names
  (with ellipsis when more) so the user knows exactly which accounts
  to archive first. The service-level FK RESTRICT remains the
  ultimate guard.

Refs #140

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:01:44 -04:00
le king fu
6288a3fe23 feat(balance): support priced kind in AccountForm + SnapshotLineRow
- AccountForm now exposes a 'category' variant with a kind selector
  (simple | priced); the legacy 'account' variant is unchanged
  modulo the new symbol-required-for-priced UI guard.
- SnapshotLineRow dispatches on account.category_kind:
  * simple variant unchanged from #146
  * priced variant: quantity + unit_price inputs + read-only
    computed value rendered live (qty × price, 2 decimals) +
    [Manuel] attribution tag
- useSnapshotEditor extends state with pricedValues map, exposes
  setLineQuantity / setLineUnitPrice handlers, prefill copies
  quantity but leaves unit_price blank (per spec-decisions row),
  save() builds mixed simple+priced batches.
- SnapshotEditor + SnapshotEditPage thread the new priced state.
- Total line at the top of SnapshotEditPage now sums simple + priced
  contributions live as the user types.

Refs #140

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:01:38 -04:00
le king fu
db5bffbdcf feat(balance): add priced-kind validation to service + tests
- Export validateLineKindInvariants helper for both 'simple' and 'priced'
  account kinds; surfaces typed BalanceServiceError codes.
- Extend SnapshotLineInput with optional account_kind / quantity /
  unit_price (default 'simple' to preserve #146 callers).
- upsertSnapshotLines now validates kind invariants ahead of the SQL
  CHECK and persists priced lines with non-NULL qty / unit_price.
- Tolerance constant PRICED_VALUE_TOLERANCE = 0.01 absorbs FP drift.
- 14 new unit tests covering simple invariants, priced invariants,
  tolerance edge cases, and mixed batches.

Refs #140

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:55:20 -04:00
le king fu
8f5cc71707 feat(balance): add i18n keys + CHANGELOG entry for snapshot editor
All checks were successful
PR Check / rust (push) Successful in 21m48s
PR Check / frontend (push) Successful in 2m19s
i18n FR/EN under balance.snapshot.* — page (titles, date label and
immutability notice, total, prefill, save/create/delete buttons),
editor (empty state), line (placeholder + a11y label), delete
(double-confirm modal copy). Five new error codes added to
balance.errors.* (snapshot_date_required, snapshot_date_taken,
snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported).

Adds common.back so the SnapshotEditPage back arrow has a localized title.

CHANGELOG entries for #146 under [Unreleased] in both EN and FR.

Refs #146

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:49:41 -04:00
le king fu
fdc6cc6c38 feat(balance): add useSnapshotEditor hook + SnapshotEditPage + components
New scoped useReducer hook covering the full single-snapshot lifecycle —
LOAD_FOR_DATE / SET_LINE_VALUE / SAVE / DELETE / PREFILL_FROM_PREVIOUS /
RESET — with the following semantics:
- 'new' mode (?date= absent or no snapshot at that date) creates the row
  at save time only, so abandoning the form does not leave an empty
  snapshot behind;
- 'edit' mode loads existing lines + prefills the values map;
- prefillFromPrevious copies simple-kind values from the most recent
  earlier snapshot (priced branch is a no-op + TODO Issue #140);
- save() flips 'new' -> 'edit' on success and updates the URL ?date=
  so refresh keeps the user in edit mode;
- snapshotDate is immutable in edit mode (UI guard, matches spec).

New SnapshotEditPage at /balance/snapshot:
- date picker (native input type=date — matches the AdjustmentForm /
  TransactionFilterBar / PeriodSelector pattern, no new dep)
- per-category groups of accounts with one value field each
- prefill button (disabled when no earlier snapshot exists, with
  tooltip explaining why)
- delete button with double-confirmation modal that requires retyping
  the snapshot date before the destructive action enables.

New SnapshotEditor (groups by category sort_order) and SnapshotLineRow
(simple variant — single value field per account) components.

Route /balance/snapshot wired in App.tsx.

Refs #146

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:49:33 -04:00
le king fu
afc338b564 feat(balance): extend balance.service with snapshots + lines (simple kind)
Adds the snapshots + lines section of the balance service for Issue #146
(Bilan #1b). Simple-kind only — quantity / unit_price are forced to NULL
both at the SQL CHECK level (already in v9) and at the service level
(`upsertSnapshotLines` validates ahead of time). Priced-kind upsert lands
in #140.

New service exports:
- listSnapshots / getSnapshotByDate / getSnapshotById / getPreviousSnapshot
- createSnapshot (throws snapshot_date_taken when UNIQUE per date violated
  so the UI can redirect to edit mode)
- updateSnapshot / deleteSnapshot (cascade lines via FK)
- listLinesBySnapshot / upsertSnapshotLines (rewrite-all strategy)

New BalanceErrorCode entries: snapshot_date_required, snapshot_date_taken,
snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported.

New shared types: BalanceSnapshot, BalanceSnapshotLine.

22 new vitest cases cover: invalid-date guards, unique-per-date violation,
simple-kind null invariant on inserts, NaN/Infinity rejection,
clear+rewrite line semantics, getPreviousSnapshot strict-before ordering.

Refs #146

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:49:19 -04:00
le king fu
4c71eaca2d feat(balance): add i18n keys + CHANGELOG entry
All checks were successful
PR Check / rust (push) Successful in 22m25s
PR Check / frontend (push) Successful in 2m27s
PR Check / rust (pull_request) Successful in 22m44s
PR Check / frontend (pull_request) Successful in 2m21s
Adds the FR/EN translation namespace `balance.*` covering:
- balance.accountsPage.* (page chrome, tab labels, empty states)
- balance.account.* (table fields, status badges, action labels,
  full account form copy with priced/simple-aware hints)
- balance.category.* (intro text, table fields, kind labels, origin
  labels, action prompts, simple-only creation form, seeded labels
  for the 7 standard categories)
- balance.errors.* (one entry per BalanceErrorCode union member)

CHANGELOG.md and CHANGELOG.fr.md both gain a single entry under
`[Unreleased] / Added` summarising the schema migration v9, the new
balance.service CRUD section and the AccountsPage tabs.

Refs #138

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:38:09 -04:00
le king fu
fccc8e4fa2 feat(balance): add useBalanceAccounts hook + AccountsPage + AccountForm
Wires the AccountsPage end-to-end with a new scoped useReducer hook,
the page itself (accessible at /balance/accounts) and the account form.

useBalanceAccounts (src/hooks/useBalanceAccounts.ts):
- Loads accounts (excludes archived by default) + categories in parallel
- Surfaces typed errors from balance.service via state.errorCode so the
  UI can localize them (e.g. seed protection, currency rejection)
- CRUD operations on both domains: addAccount/editAccount/archive/
  unarchiveAccount + addCategory/editCategory/removeCategory

AccountsPage (src/pages/AccountsPage.tsx):
- Two tabs: Comptes + Catégories
- Accounts tab: archive toggle, table of (name, category, symbol,
  currency, status), inline edit/archive/restore
- Categories tab: full list of seeded + user categories. Add new
  simple-kind category (priced creation lands in #140). Rename via
  inline prompt; delete disabled on seeded rows. Errors surfaced via
  i18n keys keyed on BalanceErrorCode.

AccountForm (src/components/balance/AccountForm.tsx):
- Variant=account only (category variant lands in #140)
- Auto-detects priced category to hint the symbol field
- Full FR/EN coverage of labels and validation messages

Per spec-plan-bilan.md v2 the sidebar entry "Bilan" is intentionally
not added in this issue — it lands in #141 (Bilan #3) when the
/balance overview becomes navigable. Until then the route is reachable
directly via URL.

Refs #138

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:37:30 -04:00
le king fu
58d3c86336 feat(balance): add balance.service CRUD section + tests
Adds the TypeScript service layer for the Bilan feature, scoped to
Issue #138 (Bilan #1a) — categories + accounts CRUD only. Snapshots,
snapshot lines, transfers and price-fetching land in subsequent issues.

The service uses `getDb()` + tauri-plugin-sql directly per project
convention (96 occurrences across 15 services). No new Tauri commands
introduced — the only future Rust commands are `compute_account_return`
(Issue #142) and `fetch_price` (Issue #144).

API surface:
- listBalanceCategories / getBalanceCategory / createBalanceCategory /
  updateBalanceCategory / deleteBalanceCategory (with seed + has-accounts
  guards)
- listBalanceAccounts (excludes archived by default) / getBalanceAccount
  / createBalanceAccount (CAD-only at MVP) / updateBalanceAccount /
  archiveBalanceAccount / unarchiveBalanceAccount (soft delete)

Typed errors via BalanceServiceError + BalanceErrorCode union so the UI
can render distinct i18n messages. Domain types added under
`src/shared/types/index.ts`: BalanceCategoryKind, BalanceCategory,
BalanceAccount, BalanceAccountWithCategory, BALANCE_CURRENCY_CAD.

19 vitest cases cover: ordering, kind validation, seed protection,
linked-account guard, currency rejection, missing-category lookup,
soft delete + restore round-trip, symbol/notes normalization.

Refs #138

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:33:39 -04:00
le king fu
a6787adef0 feat(balance): add migration v9 schema (5 tables, 7 indexes, seed)
Adds the SQL foundation for the Bilan (balance sheet) feature:

- 5 new tables: balance_categories, balance_accounts, balance_snapshots,
  balance_snapshot_lines, balance_account_transfers
- 7 indexes (category, active partial, snapshot, accounts x2, transaction,
  snapshot_date)
- Seed of 7 standard categories (5 simple + 2 priced) marked is_seed=1
- CHECK(currency = 'CAD') on balance_accounts (MVP — v2 lifts the constraint
  with a multi-currency rate table)
- CHECK kind invariants on balance_snapshot_lines (quantity/unit_price both
  NULL OR both NOT NULL)
- FK transaction_id ON DELETE RESTRICT to preserve reproducibility of
  Modified Dietz returns calculated on past periods

Migration v9 is added inline to the lib.rs Vec<Migration> via a new
constant database::BALANCE_SCHEMA backed by balance_schema.sql. The
schema is mirrored in consolidated_schema.sql so brand-new profiles
get the feature preinstalled without replaying v9.

13 new co-located rusqlite tests validate the migration on a fresh
in-memory DB: schema applies cleanly, 7 categories seeded with correct
kinds, CHECK rejects invalid currency/kind/direction, UNIQUE rejects
duplicate snapshot_date / (snapshot_id,account_id) / (transaction_id,
account_id), FK CASCADE on snapshot delete, FK RESTRICT on transaction
delete and on category with linked accounts, seed idempotent on replay.

Refs #138

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:31:50 -04:00
1f506fb171 Merge pull request 'fix(reports): render category combobox in hierarchical DFS order (#126)' (#134) from issue-126-category-combobox-presentation into main 2026-04-22 01:09:35 +00:00
le king fu
871768593d fix(reports): render category combobox in hierarchical DFS order (#126)
All checks were successful
PR Check / rust (push) Successful in 22m7s
PR Check / frontend (push) Successful in 2m19s
PR Check / rust (pull_request) Successful in 21m37s
PR Check / frontend (pull_request) Successful in 2m14s
The by-category report combobox (`/reports/category`) was showing its full
category list with scrambled indentation — parents from one sub-tree
interleaved with children from another. Root cause: `getAllCategoriesWithCounts`
returns rows via `ORDER BY sort_order, name`, which is a *global* sort; two
different roots with sort_order=1 would be followed by their respective
children in the same bucket, mixing depths together.

Add a pure `sortHierarchical(categories, resolveName)` helper in
`CategoryCombobox.tsx` that rebuilds the display order as a DFS walk of the
tree: each parent is emitted immediately followed by its descendants, with
siblings within a group sorted by `sort_order` then localized display name.
Orphans (parent filtered out or missing) are appended at the end so nothing
disappears. The helper runs client-side inside the combobox's `useMemo`, so
the fix is scoped to this component and doesn't affect other consumers of
`getAllCategoriesWithCounts`. Filtering on the input query remains unchanged.

Covered by 7 unit tests on the helper (empty list, single root, the exact
bug-reproducing scrambled case, sort_order + name tiebreak, 3-level
hierarchy, orphans, idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:58:53 -04:00
le king fu
1c9eebb78c chore: release v0.8.4
All checks were successful
Release / build-and-release (push) Successful in 24m1s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:32:44 -04:00
95f708c8fd Merge pull request 'test(categories): complete test coverage for migration flow (#123)' (#133) from issue-123-complete-tests into main 2026-04-21 23:28:08 +00:00
le king fu
12d1877870 test(categories): complete test coverage for migration flow (#123)
All checks were successful
PR Check / rust (push) Successful in 22m48s
PR Check / frontend (push) Successful in 2m20s
PR Check / rust (pull_request) Successful in 22m51s
PR Check / frontend (pull_request) Successful in 2m21s
Adds unit + integration + regression tests and a QA checklist for the
v2→v1 seed migration feature.

- Fixtures: src/__fixtures__/profiles.ts (makeV2Profile, makeV1Profile,
  makeV2ProfileWithCustom) with realistic categories, keywords,
  suppliers, transactions, budgets.
- Unit: categoryMappingService (100 cases covering every DEFAULT_MAPPINGS
  entry, 4-pass priority, splits, preserved/custom detection),
  categoryBackupService (23 cases — Tauri mocks: success, write error,
  integrity check, PIN-encrypted profile), categoryMigrationService (16
  cases — BEGIN/COMMIT/ROLLBACK flow, backup-missing abort, journaling,
  custom parent creation).
- Integration: full plan→backup→migrate→verify flow; rollback via SREF
  import; backup failure → no DB write; migration SQL failure → ROLLBACK
  + intact state.
- Regression: parameterised v2/v1 fixtures covering auto-categorisation,
  budget aggregation, splits preservation.
- Docs: docs/qa-refonte-seed-categories-ipc.md — manual checklist for UX,
  system errors, encrypted profile, custom preservation, 90-day banner,
  restore flow.

331 vitest tests pass (up from 193 baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:25:13 -04:00
4c7f3d09e1 Merge pull request 'feat(categories): restore backup banner and permanent restore action (#122)' (#132) from issue-122-restore-backup-banner into main 2026-04-21 01:52:08 +00:00
le king fu
0132e6e164 feat(categories): add restore backup banner and permanent restore action (#122)
All checks were successful
PR Check / rust (push) Successful in 21m45s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 21m1s
PR Check / frontend (pull_request) Successful in 2m13s
Surfaces the pre-migration SREF backup to the user so they can roll back a
category migration without digging into the filesystem:

- 90-day dismissable banner at the top of Settings > Categories pointing to
  the automatic backup (hidden once reverted, once dismissed, or past 90d).
- Permanent "Restore a backup" entry in Settings > Categories, available as
  long as a migration journal exists (even past the 90-day window).
- Confirmation modal with two-step consent, red Restore button, fallback
  file picker when the recorded path is missing, PIN prompt for encrypted
  SREF files, full-page reload on success.

Internals:
- New `categoryRestoreService` wrapping `read_import_file` +
  `importTransactionsWithCategories` with stable error codes
  (file_missing, read_failed, parse_failed, wrong_envelope_type,
  needs_password, wrong_password, import_failed).
- New `file_exists` Tauri command for the pre-flight presence check.
- On success: `categories_schema_version=v2` + merge `reverted_at` into
  `last_categories_migration`.
- Pure `shouldShowBanner` / `isWithinBannerWindow` helpers with tests.
- FR/EN i18n keys under `settings.categoriesCard.restore*`.
- CHANGELOG entries in both locales.

Closes #122
2026-04-20 21:47:43 -04:00
b9734acd93 Merge pull request 'feat(categories): 3-step migration page + categoryMigrationService (#121)' (#131) from issue-121-categories-migration-page into main 2026-04-21 01:33:55 +00:00
le king fu
0646875327 feat(categories): add 3-step migration page + categoryMigrationService (#121)
All checks were successful
PR Check / rust (push) Successful in 21m39s
PR Check / frontend (push) Successful in 2m21s
PR Check / rust (pull_request) Successful in 21m49s
PR Check / frontend (pull_request) Successful in 2m15s
New user-facing 3-step migration flow at /settings/categories/migrate that
allows legacy v2 profiles to opt in to the v1 IPC taxonomy.

Step 1 Discover — read-only taxonomy tree (reuses CategoryTaxonomyTree from
Livraison 1, #117).
Step 2 Simulate — 3-column dry-run table with confidence badges (high /
medium / low / needs-review), transaction preview side panel, inline target
picker for unresolved rows. The "next" button is blocked until every row is
resolved.
Step 3 Consent — checklist + optional PIN field for PIN-protected profiles +
4-step loader (backup created / verified / SQL applied / committed).

Success and error screens surface the SREF backup path and the counts of
rows migrated. Errors never leave the profile in a partial state — the new
categoryMigrationService wraps the entire SQL writeover in a
BEGIN/COMMIT/ROLLBACK atomic transaction and aborts up-front if the backup
is not present / verified.

New code:
- src/services/categoryMigrationService.ts — applyMigration(plan, backup)
  atomic writer (INSERT v1 → UPDATE transactions/budgets/budget_templates/
  keywords/suppliers → reparent preserved customs → deactivate v2 seed →
  bump categories_schema_version=v1 → journal last_categories_migration).
- src/hooks/useCategoryMigration.ts — useReducer state machine
  (discover → simulate → consent → running → success | error).
- src/hooks/useCategoryMigration.test.ts — 13 pure reducer tests.
- src/components/categories-migration/{StepDiscover,StepSimulate,StepConsent,
  MappingRow,TransactionPreviewPanel}.tsx — UI per the mockup.
- src/pages/CategoriesMigrationPage.tsx — wrapper with internal router,
  stepper, backup/migrate orchestration, success/error screens.

Tweaks:
- src/App.tsx — new /settings/categories/migrate route.
- src/components/settings/CategoriesCard.tsx — additional card surfacing
  the migrate entry for v2 profiles only.
- src/i18n/locales/{fr,en}.json — categoriesSeed.migration.* namespace
  (page / stepper / 3 steps / running / success / error / backup error codes).
- CHANGELOG.{md,fr.md} — [Unreleased] / Added entry.

Scope limits respected: no SQL migration modified, no new migration added,
no unit tests of the applyMigration writer (covered by #123 in wave 4),
no restore-backup button (#122 in wave 4), no post-migration banner (#122).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:31:21 -04:00
084b621506 Merge pull request 'feat(categories): dashboard v1 discovery banner (#118)' (#130) from issue-118-dashboard-discovery-banner into main 2026-04-21 01:16:20 +00:00
le king fu
0ded5a1ac6 feat(categories): add dashboard v1 discovery banner (#118)
All checks were successful
PR Check / rust (push) Successful in 21m42s
PR Check / frontend (push) Successful in 2m14s
PR Check / rust (pull_request) Successful in 21m50s
PR Check / frontend (pull_request) Successful in 2m17s
Adds a non-destructive, dismissable banner on the Dashboard that invites
profiles still on the legacy v2 category seed to discover the new v1 IPC
taxonomy. The banner links to the standard categories guide page
(/settings/categories/standard, shipped in #117).

Visibility rules:
- Only rendered when `categories_schema_version='v2'` in `user_preferences`.
- Hidden once the user dismisses it — dismissal is persisted in the same
  `user_preferences` table under the key `categories_v1_banner_dismissed`
  (value '1'), so the banner never reappears across app restarts.
- New v1-seeded profiles never see it.

No DB schema change: reuses the existing key/value `user_preferences`
table via the existing `getPreference`/`setPreference` helpers. No
migration added.

i18n keys under `dashboard.categoriesBanner.*` (FR + EN).
Changelog entry added under [Unreleased] in both CHANGELOG.md and
CHANGELOG.fr.md.
2026-04-20 21:11:56 -04:00
1640a73499 Merge pull request 'feat(categories): categoryMappingService 4-pass algo (#119)' (#128) from issue-119-category-mapping-service into main 2026-04-21 01:07:16 +00:00
115f707823 Merge pull request 'feat(categories): categories standard guide page (#117)' (#129) from issue-117-categories-standard-guide into main 2026-04-21 01:07:05 +00:00
le king fu
defa63a063 feat(categories): add categories standard guide page (#117)
All checks were successful
PR Check / rust (push) Successful in 21m42s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 22m37s
PR Check / frontend (pull_request) Successful in 2m12s
Adds a read-only Settings subpage at /settings/categories/standard that
exposes the full v1 IPC category taxonomy:

- Recursive tree with per-root expand/collapse (chevron buttons), clickable
  only via the disclosure caret — no destructive actions anywhere on the
  page.
- Live counter banner: roots / subcategories / leaves / total, computed
  from the bundled categoryTaxonomyV1 JSON.
- Accent- and case-insensitive full-text search over translated names;
  matching nodes keep their ancestor chain visible, non-matching branches
  are pruned from the visible tree.
- Hover tooltips (native `title`) showing i18n_key, type (income /
  expense / transfer — translated) and numeric id of each node — useful
  for power-users cross-referencing the consolidated schema.
- Export as PDF button that triggers window.print(); a dedicated
  @media print rule in styles.css forces every branch to render fully
  expanded during printing regardless of the on-screen collapse state,
  and hides the toolbar / back-link.
- All labels resolve via t(node.i18n_key, { defaultValue: node.name })
  to be forward-compatible with future user-created taxonomy rows that
  have no i18n_key.

Also:
- New CategoriesCard in Settings that links to the page (FolderTree
  icon, consistent with the userGuide / changelog card pattern).
- i18n keys added under categoriesSeed.guidePage.* and
  settings.categoriesCard.* (FR + EN).
- CHANGELOG.md + CHANGELOG.fr.md updated under [Unreleased] / Added.

Route uses the English-style `/settings/categories/standard` to match
the rest of the app (/settings, /categories, /changelog, ...). The
original spec mentions a French-accented path but the existing router
is English-only; documenting here so reviewers can see the decision.

No SQL migration, no schema change, no write to the database — this
is strictly a read-only view on the TS-side taxonomy bundle.

Type-check clean (tsc --noEmit), 148/148 vitest tests pass, vite build
succeeds.
2026-04-20 21:02:38 -04:00
le king fu
be3cda1556 feat(categories): add categoryMappingService (4-pass algo) (#119)
All checks were successful
PR Check / rust (push) Successful in 22m33s
PR Check / frontend (push) Successful in 2m18s
PR Check / rust (pull_request) Successful in 21m36s
PR Check / frontend (pull_request) Successful in 2m13s
Pure function that computes a v2 → v1 category migration plan from a
snapshot of the profile data. The 4-pass algorithm (keyword → supplier
propagation → default fallback → needs review) produces a MigrationPlan
with confidence badges (high/medium/low/none) per row, exposes split
targets for categories that ventilate across multiple v1 leaves (e.g.
Transport en commun → Autobus + Train), and preserves user-custom
categories in a dedicated bucket for later placement under
"Catégories personnalisées (migration)".

- Mapping tables encoded from
  .spikes/archived/seed-standard/code/mapping-old-to-new.md
- No DB I/O: the caller hands us categories, keywords, transactions and
  optional suppliers; the service stays testable and side-effect-free.
- 20 unit tests cover every pass, custom preservation, split exposure,
  stats aggregation and pass priority.

Prepares the ground for #121 (migration writer UI).
2026-04-20 21:01:55 -04:00
b8fa089c5f Merge pull request 'feat(categories): categoryTaxonomyService + useCategoryTaxonomy (#116)' (#127) from issue-116-category-taxonomy-service into main 2026-04-21 00:54:58 +00:00
le king fu
742aa9ec3c feat(categories): add categoryTaxonomyService + useCategoryTaxonomy hook (#116)
All checks were successful
PR Check / rust (push) Successful in 22m44s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 22m4s
PR Check / frontend (pull_request) Successful in 2m15s
Source of truth for the v1 IPC taxonomy on the TS side. Loads the bundled
JSON, exposes typed helpers (findById, findByPath, getLeaves, getParentById)
used by the upcoming Guide (#117) and Migration (#121) pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:53:15 -04:00
le king fu
b37be36ddc chore(claude): add project rules and release skill, ignore local state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:44:49 -04:00
le king fu
4912ae39b0 docs: add WIP specs for OAuth keychain, monetisation, reports, and web
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:41:00 -04:00
f3af3d7c1b Merge pull request 'feat(categories): v1 IPC seed + i18n keys + migration v8 (#115)' (#125) from issue-115-seed-v1-i18n into main 2026-04-19 20:51:16 +00:00
63feebefc8 Merge pull request 'feat(categories): categoryBackupService pre-migration SREF wrapper (#120)' (#124) from issue-120-category-backup-service into main 2026-04-19 20:48:51 +00:00
le king fu
bd992f2f94 feat(categories): add v1 IPC seed, i18n keys, and migration v8 (#115)
All checks were successful
PR Check / rust (push) Successful in 22m29s
PR Check / frontend (push) Successful in 2m18s
PR Check / rust (pull_request) Successful in 22m39s
PR Check / frontend (pull_request) Successful in 2m18s
Livraison 1 du milestone spec-refonte-seed-categories-ipc. Applies the
new v1 IPC (Indice des prix à la consommation) taxonomy to freshly
created profiles while leaving existing v2 profiles untouched until the
migration wizard (upcoming issue #121) prompts them to move.

- Migration v8 (additive only):
    - ALTER TABLE categories ADD COLUMN i18n_key TEXT
    - INSERT OR IGNORE user_preferences.categories_schema_version=v2
      (existing profiles tagged as v2 for later migration)
- consolidated_schema.sql rewritten with the full v1 seed and
  categories_schema_version='v1' default for brand-new profiles
- src/data/categoryTaxonomyV1.json bundled as the TS-side source of
  truth (consumed by #116 categoryTaxonomyService next)
- categoriesSeed.* i18n namespace (FR/EN) — 150 entries each
- CategoryTree and CategoryCombobox fall back to the raw `name` when
  i18n_key is null (user-created categories stay literal)
- CategoryTreeNode and CategoryRow gain the i18n_key field end-to-end

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:41:55 -04:00
le king fu
3c628d4cd1 feat(categories): add categoryBackupService for pre-migration SREF backup (#120)
All checks were successful
PR Check / rust (push) Successful in 22m20s
PR Check / frontend (push) Successful in 2m18s
PR Check / rust (pull_request) Successful in 22m1s
PR Check / frontend (pull_request) Successful in 2m16s
Wrapper around dataExportService that creates and verifies a full SREF
backup before the v2->v1 categories migration. Throws on any failure to
ensure migration aborts cleanly.

- Generates filename <ProfileName>_avant-migration-<ISO8601>.sref
- Writes to ~/Documents/Simpl-Resultat/backups/ (creates dir if missing)
- Verifies integrity via re-read + SHA-256 checksum
- Reuses profile PIN for encryption when protected
- Adds two minimal Tauri commands: ensure_backup_dir, get_file_size
- Stable error codes (BackupError) to map to i18n keys in the UI layer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:23 -04:00
le king fu
0e2078088a docs: add spec decisions and plan for categories IPC seed refactor
Source of truth for milestone spec-refonte-seed-categories-ipc
(issues #115-#123).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:35:15 -04:00
le king fu
0af5dd95cc chore: release v0.8.3
All checks were successful
Release / build-and-release (push) Successful in 23m6s
2026-04-19 10:01:29 -04:00
f371ae3f7e Merge pull request 'feat(reports/cartes): Mensuel/YTD toggle on KPI cards + user guide section (#102)' (#114) from issue-102-cartes-ytd-toggle-docs into main 2026-04-19 13:55:35 +00:00
le king fu
3be05db41a feat(reports/cartes): Mensuel/YTD toggle on KPI cards + user guide section (#102)
All checks were successful
PR Check / rust (push) Successful in 21m48s
PR Check / frontend (push) Successful in 2m15s
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m16s
Adds a segmented Monthly/YTD toggle next to the reference-month picker that
flips the four KPI cards (income, expenses, net, savings rate) between the
reference-month value (unchanged default) and a Year-to-Date cumulative view.

In YTD mode, the "current" value sums January to the reference month of the
reference year; MoM delta compares it to Jan to (refMonth - 1) of the same
year (null in January, since no prior YTD window exists); YoY delta compares
it to Jan to refMonth of the previous year; savings rate is recomputed from
YTD income and expenses, and stays null when YTD income is zero.

The 13-month sparkline, top movers, seasonality and budget adherence cards
remain monthly regardless of the toggle (by design). The savings-rate tooltip
is now dynamic and mirrors the active mode. The mode is persisted in
localStorage under `reports-cartes-period-mode`.

Also adds a dedicated Cartes section to `docs/guide-utilisateur.md` covering
the four KPI formulas, the Monthly/YTD toggle and its effect on deltas, the
sparkline, top movers, seasonality, budget adherence and the savings-rate
edge case. Mirrored in the in-app `docs.reports` i18n tree (features/steps/
tips extended) for both FR and EN.

No SQL migration: YTD sums are computed from the already-fetched
`flowByMonth` map, so no extra round trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:49:21 -04:00
d834ffeb9c Merge pull request 'fix(reports/cartes): Budget Adherence card filtered out all expense categories' (#113) from issue-112-budget-adherence-signed-fix into main 2026-04-19 13:39:11 +00:00
le king fu
2ec48d4e90 fix(reports/cartes): Budget Adherence card was filtering out all expense categories
All checks were successful
PR Check / rust (push) Successful in 21m31s
PR Check / frontend (push) Successful in 2m13s
PR Check / rust (pull_request) Successful in 22m0s
PR Check / frontend (pull_request) Successful in 2m14s
Expense budgets are stored signed-negative by budgetService. The Cartes
budget-adherence card used raw values in its filter (monthBudget > 0),
its in-target comparison (|actual| <= monthBudget), and its overrun
calculation — all of which silently rejected every expense row. Route
every amount through Math.abs() so the card reflects real budget data.

Test: regression fixture with a signed-negative monthBudget that must
pass the filter and count as in-target or overrun based on absolute
values.

Fixes #112
2026-04-19 09:35:26 -04:00
da6041dc45 Merge pull request 'Highlights: default reference month to previous + YTD current year, user-changeable (#106)' (#111) from issue-106-highlights-default-ref-month into main 2026-04-19 12:34:37 +00:00
le king fu
8b90cb6489 feat(reports/highlights): default reference month to previous month + YTD current year, user-changeable (#106)
All checks were successful
PR Check / rust (push) Successful in 21m14s
PR Check / frontend (push) Successful in 2m16s
PR Check / rust (pull_request) Successful in 21m31s
PR Check / frontend (pull_request) Successful in 2m14s
- Extract shared defaultReferencePeriod helper (src/utils/referencePeriod.ts)
- useHighlights now reads ?refY=YYYY&refM=MM, defaults to previous month
- getHighlights signature: (referenceYear, referenceMonth, ytdYear, windowDays, ...)
- YTD tile pinned to Jan 1 of current civil year, independent of reference month
- CompareReferenceMonthPicker surfaced on /reports/highlights
- Hub highlights panel inherits the same default via useHighlights
- useCartes and useCompare now delegate their default-period helpers to the shared util
2026-04-19 08:28:30 -04:00
3842a1102a Merge pull request 'feat(reports/trends): add stacked-area chart option for category view (#105)' (#110) from issue-105-trends-stacked-area into main 2026-04-19 12:22:03 +00:00
le king fu
94104c4223 refactor(reports/trends): rename chart type from 'line' to 'bar' to match actual rendering
All checks were successful
PR Check / rust (push) Successful in 21m44s
PR Check / frontend (push) Successful in 2m18s
PR Check / rust (pull_request) Successful in 21m27s
PR Check / frontend (pull_request) Successful in 2m11s
The legacy chart was a stacked BarChart, not a LineChart — the initial 'line'
naming was misleading. Rename internal type, i18n key (chartLine -> chartBar,
Lignes -> Barres, Lines -> Bars) and icon. Legacy 'line' in localStorage is
migrated to 'bar' on read.
2026-04-19 07:26:22 -04:00
le king fu
02efc75542 feat(reports/trends): add stacked-area chart option for category view (#105)
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Successful in 21m22s
PR Check / frontend (pull_request) Successful in 2m15s
Adds a segmented chart-type toggle to the /reports/trends "By category"
sub-view that switches between the existing stacked bars (default,
unchanged) and a new Recharts AreaChart with stackId="1" showing total
composition over time. Both modes share the same category palette and
SVG grayscale patterns so the visual signature is preserved.

- CategoryOverTimeChart gains a chartType: 'line' | 'area' prop
  (default 'line' for backward compatibility with the dashboard usage).
- New TrendsChartTypeToggle component, persisted in localStorage under
  "reports-trends-category-charttype".
- Toggle only renders in the "By category" sub-view with chart view
  mode selected; hidden otherwise.
- i18n keys reports.trends.chartLine / chartArea / chartTypeAria in
  FR and EN.
- CHANGELOG entries in both languages.
2026-04-19 07:23:49 -04:00
e95612a55d Merge pull request 'feat(reports/compare): 8-column table with monthly + cumulative YTD blocks (#104)' (#109) from issue-104-compare-eight-col-table into main 2026-04-19 11:19:21 +00:00
le king fu
bd8a5732c6 feat(reports/compare): 8-column table with monthly + cumulative YTD blocks (#104)
All checks were successful
PR Check / rust (push) Successful in 21m21s
PR Check / frontend (push) Successful in 2m11s
PR Check / rust (pull_request) Successful in 21m30s
PR Check / frontend (pull_request) Successful in 2m8s
Mirror the BudgetVsActualTable structure in the Actual-vs-Actual compare
mode so MoM and YoY both surface a Monthly block (reference month vs
comparison month) and a Cumulative YTD block (progress through the
reference month vs progress through the previous window).

- CategoryDelta gains cumulative{Previous,Current}Amount and
  cumulativeDelta{Abs,Pct}. Legacy previousAmount / currentAmount /
  deltaAbs / deltaPct are kept as aliases of the monthly block so the
  Highlights hub, Cartes dashboard and ComparePeriodChart keep working
  unchanged.
- getCompareMonthOverMonth: cumulative-previous window ends at the end
  of the previous month within the SAME year; when the reference month
  is January, the previous window sits entirely in the prior calendar
  year (Jan → Dec).
- getCompareYearOverYear: now takes an optional reference month
  (defaults to December for backward compatibility). Monthly block
  compares the single reference month across years; cumulative block
  compares Jan → refMonth across years.
- ComparePeriodTable rebuilt with two colspan header groups, four
  sub-columns each, a totals row and month/year boundary sub-labels.
- ComparePeriodChart unchanged: still reads the monthly primary fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:17:32 -04:00
8d916a1283 Merge pull request 'feat(reports/category): searchable combobox for category zoom (#103)' (#108) from issue-103-category-combobox into main 2026-04-19 01:10:25 +00:00
le king fu
01869462f4 feat(reports/category): replace select with searchable combobox (#103)
All checks were successful
PR Check / rust (push) Successful in 21m2s
PR Check / frontend (push) Successful in 2m13s
PR Check / rust (pull_request) Successful in 21m14s
PR Check / frontend (pull_request) Successful in 2m9s
Swap the native <select> in CategoryZoomHeader for the reusable
CategoryCombobox. Enhances the combobox with ARIA compliance
(combobox, listbox, option roles + aria-expanded, aria-controls,
aria-activedescendant) and hierarchy indentation based on parent_id
depth. Adds reports.category.searchPlaceholder in FR/EN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:07:47 -04:00
49a4ef2171 Merge pull request 'fix(reports/cartes): remove broken period selector + add savings-rate tooltip' (#107) from issue-101-cartes-period-savings-tooltip into main 2026-04-19 01:04:38 +00:00
le king fu
b258e2b80a fix(reports/cartes): remove broken period selector + add savings-rate tooltip (#101)
All checks were successful
PR Check / rust (push) Successful in 21m51s
PR Check / frontend (push) Successful in 2m29s
PR Check / rust (pull_request) Successful in 21m3s
PR Check / frontend (pull_request) Successful in 2m11s
- Remove the non-functional PeriodSelector from /reports/cartes — the Cartes
  report is by design a "month X vs X-1 vs X-12" snapshot, so the
  reference-month picker is the only control needed.
- Simplify useCartes to drop its useReportsPeriod dependency; the hook now
  only exposes the reference year/month and its setter.
- Add a (?) help bubble (lucide HelpCircle) next to the savings-rate KPI
  title, wired up via a new `tooltip?: string` prop on KpiCard.
- Propagate `number | null` through CartesKpi.current and buildKpi so
  savings rate is now null (rendered as "—") when reference-month income
  is 0 instead of a misleading "0 %". Use refExpenses directly for
  seasonality.referenceAmount since it is always numeric.
- Update the cartes snapshot tests to expect null for the zero-income case.
- Add FR/EN strings reports.cartes.savingsRateTooltip + CHANGELOG entries
  in both locales.
2026-04-18 20:50:18 -04:00
le king fu
4416457c22 chore: release v0.8.2
All checks were successful
Release / build-and-release (push) Successful in 24m6s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:41:48 -04:00
4f4ab87bea feat: feedback hub widget in Settings Logs card (#67)
Closes #67

Add opt-in Feedback Hub widget integrated into the Settings Logs card. Routes through a Rust command to bypass CORS and centralize privacy audit. First submission triggers a one-time consent dialog; three opt-in checkboxes (context, logs, identify with Maximus account) all unchecked by default. Wording and payload follow the cross-app conventions in la-compagnie-maximus/docs/feedback-hub-ops.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:36:26 +00:00
le king fu
3b2587d843 chore: bump version to 0.8.1
All checks were successful
Release / build-and-release (push) Successful in 24m56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:07:53 -04:00
89b69f325e feat: new Cartes dashboard report — KPI cards, sparklines, top movers (#97)
Closes #97
2026-04-15 23:53:37 +00:00
le king fu
4c58b8bab8 feat(reports/cartes): new KPI dashboard sub-report with sparklines, top movers, budget adherence and seasonality (#97)
All checks were successful
PR Check / rust (push) Successful in 23m21s
PR Check / frontend (push) Successful in 2m24s
PR Check / rust (pull_request) Successful in 23m12s
PR Check / frontend (pull_request) Successful in 2m20s
New /reports/cartes page surfaces a dashboard-style snapshot of the
reference month:

- 4 KPI cards (income / expenses / net / savings rate) showing MoM and
  YoY deltas simultaneously, each with a 13-month sparkline highlighting
  the reference month
- 12-month income vs expenses overlay chart (bars + net balance line)
- Top 5 category increases + top 5 decreases MoM, clickable through to
  the category zoom report
- Budget adherence card: on-target count + 3 worst overruns with
  progress bars
- Seasonality card: reference month vs same calendar month averaged
  over the two previous years, with deviation indicator

All data is fetched in a single getCartesSnapshot() service call that
runs four queries concurrently (25-month flow, MoM category deltas,
budget-vs-actual, seasonality). Missing months are filled with zeroes
in the sparklines but preserved as null in the MoM/YoY deltas so the UI
can distinguish "no data" from "zero spend".

- Exported pure helpers: shiftMonth, defaultCartesReferencePeriod
- 13 vitest cases covering zero data, MoM/YoY computation, January
  wrap-around, missing-month handling, division by zero for the
  savings rate, seasonality with and without history, top mover sign
  splitting and 5-cap

Note: src/components/reports/CompareReferenceMonthPicker.tsx is a
temporary duplicate — the canonical copy lives on the issue-96 branch
(refactor: compare report). Once both branches merge the content is
identical and git will dedupe. Keeping the local copy here means the
Cartes branch builds cleanly on main without depending on #96.

Closes #97

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:44:58 -04:00
5fd2108d07 refactor: compare report — Actual-vs-actual mode with reference month picker (#96)
Closes #96
2026-04-15 23:10:23 +00:00
le king fu
4116db4090 refactor(reports/compare): unify MoM/YoY under one Actual-vs-actual mode with reference month picker (#96)
All checks were successful
PR Check / rust (push) Successful in 24m37s
PR Check / frontend (push) Successful in 2m21s
PR Check / rust (pull_request) Successful in 24m25s
PR Check / frontend (pull_request) Successful in 2m26s
Collapse the three Compare tabs (MoM / YoY / Budget) into two modes. The
new "Actual vs actual" mode exposes an explicit reference-month dropdown
in the header (defaults to the previous month, wraps around January) and
a MoM/YoY sub-toggle. The chart is rewritten to a grouped side-by-side
BarChart with two bars per category (reference period vs comparison
period) so both values are visible at a glance instead of just the
delta. The URL PeriodSelector stays in sync with the reference month.

- useCompare: state splits into { mode: "actual"|"budget", subMode:
  "mom"|"yoy", year, month }. Pure helpers previousMonth(),
  defaultReferencePeriod(), comparisonMeta() extracted for tests
- CompareModeTabs: 2 modes instead of 3
- New CompareSubModeToggle and CompareReferenceMonthPicker components
- ComparePeriodChart: grouped bars via two <Bar dataKey="..."/> on a
  vertical BarChart
- i18n: modeActual / subModeMoM / subModeYoY / referenceMonth (FR+EN),
  retire modeMoM / modeYoY
- 9 new vitest cases covering the pure helpers (January wrap-around for
  both MoM and YoY, default reference period, month/year arithmetic)

Closes #96

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:24:11 -04:00
196 changed files with 33018 additions and 1099 deletions

View file

@ -0,0 +1,6 @@
---
paths: ["src/**", "src-tauri/**"]
---
Tout changement affectant le comportement utilisateur doit avoir une entree sous `## [Unreleased]` dans `CHANGELOG.md`.
Categories : Added, Changed, Fixed, Removed. Format Keep a Changelog.
Le contenu est extrait par le CI pour les release notes et affiche dans l'app.

7
.claude/rules/i18n.md Normal file
View file

@ -0,0 +1,7 @@
---
paths: ["**/*.tsx", "**/*.ts"]
---
Toute chaine visible par l'utilisateur doit passer par i18n (react-i18next).
Fichiers : `src/i18n/locales/fr.json` et `src/i18n/locales/en.json`.
Jamais de texte en dur dans les composants React.
Toujours ajouter la cle dans les DEUX langues.

View file

@ -0,0 +1,7 @@
---
paths: ["**/migrations/**", "**/*.sql", "**/lib.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`.
Le schema consolide (`consolidated_schema.sql`) sert uniquement pour les nouveaux profils.

View file

@ -0,0 +1,50 @@
---
name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true
updated: 2026-04-19
---
# /release — Release Simpl-Resultat
## Context injection
1. Lire version dans `src-tauri/Cargo.toml` et `package.json`
2. Lister les derniers tags : `git tag --sort=-v:refname | head -10`
3. Lire `CHANGELOG.md` et `CHANGELOG.fr.md` (dernieres entrees)
## Workflow
1. Determiner la nouvelle version (argument utilisateur ou demander)
2. Bump version dans les 5 fichiers :
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
- `src-tauri/Cargo.lock` (bloc `[[package]] name = "simpl-result"` + sa ligne `version = "..."` ; ne PAS regenerer avec cargo)
- `src-tauri/tauri.conf.json` (champ `"version"`)
- `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.
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
- `CHANGELOG.md` (EN)
- `CHANGELOG.fr.md` (FR)
- Pattern de migration : transformer `## [Unreleased]` en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer une section `## [Unreleased]` vide au-dessus** pour accueillir les prochaines entrees. Ne pas deplacer le contenu — les sections sont laissees en place.
4. Si changement d'architecture : mettre a jour `docs/architecture.md`
5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs)
6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) :
```
git tag -a vX.Y.Z -m "Release X.Y.Z
- <bullet highlights>"
```
7. Push : `git push origin main && git push origin vX.Y.Z`
8. Forgejo CI build automatique (Windows + Linux) via `release.yml` sur `on: push: tags: v*`
## Regles
- **JAMAIS `git push --tags`** — toujours push le tag individuellement
- Toujours mettre a jour les **2 changelogs** (EN + FR)
- Format Keep a Changelog : `## [X.Y.Z] - YYYY-MM-DD`
- Les changelogs sont bundles dans `public/` pour l'affichage in-app
- Tag **annote** (`-a`), pas lightweight : les artefacts CI reference le tag pour les release notes
## Changelog
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)

View file

@ -1,17 +1,24 @@
name: PR Check
# Validates Rust + frontend on every branch push and PR.
# Validates Rust + frontend on every PR opened against main.
# Goal: catch compile errors, type errors, and failing tests BEFORE merge,
# instead of waiting for the release tag (which is when release.yml runs).
#
# Trigger is `pull_request` only — the previous `push` trigger duplicated
# every run when a branch was pushed and immediately opened as a PR (#171).
# Trade-off: branches pushed without an open PR don't get CI feedback. Open
# a draft PR if you want feedback before requesting review.
on:
push:
branches-ignore:
- main
pull_request:
branches:
- main
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
runs-on: ubuntu

View file

@ -2,15 +2,20 @@ name: PR Check
# Mirror of .forgejo/workflows/check.yml using GitHub-native runners.
# Forgejo is the primary host; this file keeps the GitHub mirror functional.
#
# Trigger is `pull_request` only — kept in sync with the Forgejo workflow
# after #171 dropped the redundant `push` trigger that duplicated every run.
on:
push:
branches-ignore:
- main
pull_request:
branches:
- main
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
runs-on: ubuntu-latest

18
.gitignore vendored
View file

@ -13,6 +13,7 @@ target/
# User data
data/
!src/data/
*.db
*.db-journal
*.db-wal
@ -51,3 +52,20 @@ public/CHANGELOG.fr.md
# Tauri generated
src-tauri/gen/
# Tauri icon CLI also generates iOS/Android assets — desktop targets only (nsis, deb, rpm)
src-tauri/icons/android/
src-tauri/icons/ios/
# Claude Code local state
.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees/
decisions-log.md
# Autopilot scratch + daily reports
reports/
# Spec scratch (committed only when promoted to docs/archive/)
spec-decisions-*.md
spec-plan-*.md

View file

@ -2,6 +2,84 @@
## [Non publié]
## [0.9.1] - 2026-05-10
### Ajouté
- Bilan : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils, plus un modal d'opt-in unique pour les profils existants à leur première visite de /balance. Cases cochées par défaut ; les comptes existants avec le même nom + catégorie désactivent la ligne correspondante avec un tooltip « Déjà présent ». Confirmation ou ignorance enregistrée dans `user_preferences.balance_starter_proposed` pour que le modal ne réapparaisse jamais. ADR 0012 (Proposed) capture le futur modèle à deux niveaux véhicule × composition (#179).
### Modifié
- **Récupération de prix activée**`/v1/prices` de `maximus-api` est en production depuis le 2026-05-05. La fonctionnalité premium de récupération de prix livrée en 0.9.0 (#160) est désormais fonctionnellement disponible de bout en bout (#161).
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178).
### Corrigé
- Bilan : correction de l'erreur SQLite « misuse of aggregate function MIN() » au chargement de /balance avec des snapshots existants ; remplacement du pattern aggregate-in-WHERE par une window function ROW_NUMBER() dans getAccountsPeriodAnchor (#175).
- Bilan : la sauvegarde d'un snapshot utilise désormais une transaction atomique BEGIN/COMMIT et valide toutes les lignes avant toute écriture en BDD, empêchant les snapshots orphelins lorsque la validation échoue. La migration v11 nettoie les orphelins existants (#176).
- Bilan : le sélecteur de date sur `/balance/snapshot` se ferme maintenant après la sélection sur Linux (WebKitGTK) au lieu de rester ouvert jusqu'à ce que l'utilisateur appuie sur Échap. Le contournement appelle `blur()` sur le champ après chaque changement — sans effet sur Windows WebView2 / macOS WKWebView, où le sélecteur se ferme déjà automatiquement (#177).
- Mise à jour de la dépendance `postcss` (8.5.6 → 8.5.13) pour corriger l'avis de sécurité de sévérité modérée GHSA-qx2v-qp2m-jg93 (XSS via `</style>` non échappé dans le stringifier CSS). Transitive via vite, build-time uniquement — aucun impact runtime sur le binaire Tauri livré (#180).
- Contournement du sélecteur de date WebKitGTK étendu aux 7 autres champs `<input type="date">` natifs répartis sur 4 composants (barre de filtres Transactions, formulaire Ajustements, modal de Liaison de transferts, sélecteur de période). Chaque handler onChange appelle désormais `e.currentTarget.blur()` pour fermer le popup natif sur Linux Tauri WebView — sans effet sur Windows WebView2 / macOS WKWebView. Même approche que #177 (#188).
- Bilan : nettoyage post-merge des suggestions issues des reviews des PR #182-#185. Six corrections groupées : (1) `getStarterCollisions` filtre désormais `archived_at IS NULL`, donc recréer un compte starter volontairement archivé n'est plus bloqué ; (2) `proposeStarterAccounts` re-vérifie chaque collision (nom, catégorie) en transaction avant l'INSERT en défense-in-depth (saut silencieux si déjà présent, aucune contrainte UNIQUE ajoutée) ; (3) les nouveaux profils reçoivent désormais `balance_starter_proposed` pré-seedé dans `consolidated_schema.sql` pour que le StarterAccountsModal ne s'ouvre jamais brièvement avec uniquement des collisions à la première visite de /balance ; (4) `/balance` cache maintenant le sélecteur de période, le graphique d'évolution et le tableau des comptes tant que la carte d'onboarding vide est affichée (évite trois messages vides empilés) ; (5) `BalanceOnboardingCard.Step` appelle directement `useTranslation()` au lieu de recevoir `t` en prop ; (6) le bloc de doc de la formule Modified Dietz dans `return_calculator.rs` est maintenant entouré d'une fence `text` pour que `cargo test --doc` n'essaie plus de compiler la pseudo-math comme du Rust (#187).
## [0.9.0] - 2026-04-29
### Ajouté
- **Bilan — colonne `asset_type` sur les catégories cotées** (route `/balance/accounts`) : les catégories cotées portent maintenant un `asset_type` explicite (`stock` ou `crypto`) qui pilote le routage de PriceFetchControl vers le bon fournisseur, sans heuristique sur le symbole (ex : ETH = Ethan Allen NYSE *et* Ethereum crypto, deux symboles homonymes). La migration v10 ajoute une colonne nullable et backfille les deux catégories cotées seedées (`stock`, `crypto`) avec leur valeur respective ; les lignes cotées custom existantes restent NULL en attendant un futur écran d'édition pour qu'on les renseigne. Le formulaire de création de catégorie (onglet Catégories) affiche désormais un sélecteur de type d'actif quand `kind = priced` et refuse l'enregistrement tant qu'aucune valeur n'est choisie. L'éditeur de snapshot masque le bouton de récupération de prix sur les lignes cotées dont l'`asset_type` est encore NULL — la saisie manuelle reste l'unique chemin sur ces lignes legacy. (#169)
- **Bilan — documentation et ADRs** (`docs/`) : finalise le milestone Bilan avec la passe documentaire. `docs/architecture.md` répertorie désormais les 5 nouvelles tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), les 7 nouveaux index, les invariants CHECK et FK (CAD seulement, invariants de type, `RESTRICT` sur `transaction_id` pour la reproductibilité Modified Dietz), le découpage 4 sections de `balance.service.ts` (CRUD / snapshots+lignes / rendements+transferts / prix), les 3 hooks scoped par page (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), la commande Tauri `compute_account_return` (avec mention de la future commande `fetch_price` Phase 5), et les 3 nouvelles routes `/balance*`. Trois nouveaux ADRs accompagnent : **0008 — Modified Dietz** (justifie le choix vs ROI / TWR / IRR avec référence à `return_calculator.rs`) ; **0009 — Proxy price-fetching via maximus-api** (architecture documentée maintenant, implémentation BLOQUÉE en attendant la Phase 2 de maximus-api — couvre les considérations privacy comme le strip de headers, l'absence de corrélation `(symbole, licence)` dans les logs et le User-Agent fixe `simpl-resultat`, l'abstraction adapter Yahoo + CoinGecko, la stratégie d'auth Bearer, le rate-limiting client + serveur et le double gating premium UI + serveur) ; **0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`** (justifie l'arbitrage intégrité vs friction pour la reproductibilité Modified Dietz). Le guide utilisateur gagne une nouvelle section *Bilan* qui détaille la saisie de snapshot (simple + coté), la liaison de transferts, la lecture des rendements multi-horizons (3M / 1A / depuis création avec colonne non-ajustée côte à côte), avec la mention « à venir Phase 5 » pour le price-fetching premium. Clés i18n `docs.balance.*` (FR + EN) ajoutées pour que le guide in-app reflète la nouvelle section (#145)
- **Bilan — suite de tests d'intégration cross-cutting** (infrastructure de tests) : clôt la feature *Bilan* avec une couche de tests d'intégration qui exerce toute la surface TypeScript en un seul flux de bout en bout (compte → catégorie cotée → snapshot coté → transfert lié → rendement) et des assertions dédiées sur le verrou de devise (CAD seulement au MVP, refusé à la fois côté service et côté CHECK SQL), la sécurité de tolérance pour le type coté (un mauvais enregistrement ne doit PAS supprimer les lignes existantes), le câblage de `computeAccountReturn` (résolution du profil actif, transmission des dates ISO, conservation telle quelle d'une réponse de période partielle). Trois nouveaux tests Rust d'intégration appliquent la migration v9 par-dessus un schéma v1 seedé contenant déjà des transactions pour vérifier (1) aucune perte ni mutation de données, (2) le round-trip lier / délier sur de vraies `transaction_id`, (3) la chaîne FK RESTRICT (suppression d'une transaction liée bloquée, autorisée après détachement), (4) la cohabitation indépendante des espaces d'identifiants `categories.id` (v1) et `balance_categories.id` (v9). Un test de non-régression au niveau source sur `TransactionTable.tsx` verrouille le contrat de l'icône de transfert inlinée : prop optionnelle, court-circuit en chaînage optionnel, clés i18n, aria-label, layout partagé de la cellule description — pour que la page reste rendue à l'identique en l'absence de transferts liés. (#144)
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin V_début ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T t_i) / T`, et annualisation `(1 + R)^(365/T) 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
- **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement (activation serveur en attente — fonctionnalité dormante jusqu'à la mise en ligne de `/v1/prices` de maximus-api). (#160)
### Modifié
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49)
### Corrigé
- **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126)
## [0.8.4] - 2026-04-21
### Ajouté
- **Bannière (90 jours) et entrée permanente *Rétablir une sauvegarde* pour annuler une migration de catégories à partir de la sauvegarde automatique** (Paramètres → *Catégories*) : après une migration v2→v1, une bannière fermable (icône `ShieldCheck`) s'affiche désormais en haut de la carte Catégories pendant 90 jours et pointe vers la sauvegarde SREF automatique écrite par `categoryBackupService`. Une entrée dédiée *Rétablir une sauvegarde* reste accessible sous le lien de migration tant qu'une migration est enregistrée — même après les 90 jours — afin que le rétablissement ne soit jamais perdu. La fenêtre de confirmation lit le journal `last_categories_migration` pour récupérer son horodatage et son chemin de sauvegarde, impose une confirmation en deux étapes avec un bouton rouge *Rétablir*, bascule sur un sélecteur de fichier lorsque le chemin enregistré n'est plus sur disque, demande le NIP du profil lorsque le fichier SREF est chiffré, puis en cas de succès remet `categories_schema_version=v2`, inscrit `reverted_at` dans le journal et recharge l'application. La bannière se masque d'elle-même une fois la migration rétablie. Ajout de la commande Tauri `file_exists` pour la vérification préalable, nouveau service `categoryRestoreService` qui emballe `read_import_file` + `importTransactionsWithCategories` avec des codes d'erreur stables (#122)
- **Page de migration des catégories en 3 étapes** (route `/settings/categories/migrate`, Paramètres → *Migrer vers la structure standard*) : les profils v2 peuvent désormais choisir de migrer vers la nouvelle taxonomie v1 IPC via un parcours guidé — *Découvrir* (arbre en lecture seule, réutilisé de la page Guide), *Simuler* (table 3 colonnes en dry-run avec badges de confiance haute / moyenne / basse / à réviser, panneau latéral cliquable montrant les 50 premières transactions impactées par ligne, sélecteur de cible en ligne pour les lignes non résolues, bouton *Suivant* bloqué tant qu'une ligne n'est pas résolue) et *Consentir* (case à cocher + champ NIP pour les profils protégés + loader 4 étapes). Au clic de confirmation, la page crée une sauvegarde SREF vérifiée via `categoryBackupService` (obligatoire, abort sur échec sans écriture BD) puis lance une transaction SQL atomique via le nouveau service `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT de la taxonomie v1 → UPDATE des transactions / budgets / budget_templates / keywords / suppliers vers les nouveaux id v1 → replacement des catégories personnalisées sous un nouveau parent *Catégories personnalisées (migration)* → désactivation des catégories v2 → pose de `categories_schema_version='v1'` et journalisation dans `user_preferences.last_categories_migration` → COMMIT. Toute erreur déclenche un ROLLBACK, laissant le profil dans son état pré-migration. Les écrans de succès et d'échec affichent le chemin de la sauvegarde et, pour le succès, le nombre de lignes insérées / transactions, mots-clés et budgets migrés (#121)
- **Bannière du tableau de bord invitant les profils v2 à découvrir la nouvelle taxonomie v1 IPC** : les profils existants (marqués `categories_schema_version='v2'`) voient désormais une bannière fermable en haut du tableau de bord qui pointe vers la nouvelle page Guide des catégories standard. La bannière est non destructive (CTA en lecture seule, aucun changement de catégories), ne s'affiche qu'aux profils v2 (les nouveaux profils semés en v1 ne la voient jamais), et sa fermeture est persistée dans `user_preferences` sous la clé `categories_v1_banner_dismissed` pour ne plus réapparaître (#118)
- **Page Guide des catégories standard** (Paramètres → *Structure standard des catégories*, route `/settings/categories/standard`) : nouvelle page en lecture seule qui expose la taxonomie v1 IPC complète sous forme d'arbre navigable avec repli/expansion par racine, un compteur global en direct (racines · sous-catégories · feuilles · total), une recherche plein texte insensible aux accents sur les noms traduits, des info-bulles au survol affichant la clé `i18n_key`, le type et l'identifiant de chaque nœud, et un bouton *Exporter en PDF* qui ouvre la boîte d'impression du navigateur. Une règle `@media print` dédiée force l'affichage complet de toutes les branches à l'impression, peu importe l'état de repli à l'écran. Tous les libellés passent par `categoriesSeed.*` avec `name` en repli pour les futures lignes personnalisées. Aucune écriture en base, aucune action destructive (#117)
- **Seed de catégories IPC pour les nouveaux profils** : les nouveaux profils sont désormais créés avec la taxonomie v1 IPC (Indice des prix à la consommation) — une hiérarchie alignée sur les catégories de Statistique Canada. Les noms des catégories du seed sont traduits dynamiquement depuis la clé i18n `categoriesSeed.*` (FR/EN), donc affichés dans la langue de l'utilisateur. Les profils existants gardent l'ancien seed v2, marqués via une nouvelle préférence `categories_schema_version` (une page de migration ultérieure offrira le passage v2→v1). Côté interne : colonne `categories.i18n_key` (nullable) ajoutée par la migration v8 (strictement additive), `src/data/categoryTaxonomyV1.json` livré comme source de vérité côté TS, les renderers `CategoryTree` et `CategoryCombobox` utilisent `name` en repli quand aucune clé de traduction n'est présente (catégories créées par l'utilisateur) (#115)
## [0.8.3] - 2026-04-19
### Ajouté
- **Rapport Cartes — Toggle Mensuel / Cumul annuel (YTD)** (`/reports/cartes`) : nouveau toggle segmenté à côté du sélecteur de mois de référence bascule les quatre cartes KPI (revenus, dépenses, solde net, taux d'épargne) entre la valeur du mois de référence (défaut inchangé) et une vue cumul annuel. En mode YTD, la valeur courante somme janvier → mois de référence, le delta MoM la compare à la fenêtre Jan → (mois 1) de la même année (null en janvier), le delta YoY la compare à Jan → mois de référence de l'année précédente, et le taux d'épargne utilise les revenus/dépenses YTD. La sparkline 13 mois, les top mouvements, la saisonnalité et l'adhésion budgétaire restent mensuels peu importe le toggle. L'info-bulle du taux d'épargne reflète maintenant le mode actif. Choix persisté dans `localStorage` (`reports-cartes-period-mode`) (#102)
- **Guide utilisateur — Section Cartes** : nouvelle section dédiée documentant les formules des quatre KPI, le toggle Mensuel/YTD, la sparkline, les top mouvements, les règles de saisonnalité et d'adhésion budgétaire, ainsi que le cas limite du taux d'épargne (« — » quand les revenus sont à zéro) (#102)
- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101)
- **Rapport Tendances — Par catégorie** (`/reports/trends`) : nouveau toggle segmenté pour basculer le graphique d'évolution par catégorie entre les barres empilées (par défaut, inchangé) et une vue surface empilée Recharts (`<AreaChart stackId="1">`) qui montre la composition totale dans le temps. Les deux modes partagent la même palette de catégories et les mêmes patterns SVG en niveaux de gris. Le type choisi est mémorisé dans `localStorage` (`reports-trends-category-charttype`) (#105)
### Modifié
- **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `<select>` natif (#103)
- **Rapport Comparables — Réel vs réel** (`/reports/compare`) : le tableau reprend maintenant la structure riche à 8 colonnes du tableau Réel vs budget, en scindant chaque comparaison en un bloc *Mensuel* (mois de référence vs mois de comparaison) et un bloc *Cumulatif YTD* (progression jusqu'au mois de référence vs progression jusqu'à la fenêtre précédente). En mode MoM, le cumulatif précédent couvre janvier → fin du mois précédent de la même année ; en mode YoY, il couvre janvier → même mois de l'année précédente. Le graphique reste uniquement mensuel (#104)
- **Rapport Faits saillants** (`/reports/highlights`) : les tuiles mensuelles (solde du mois courant, top mouvements vs mois précédent) s'ouvrent désormais sur le **mois précédent** au lieu du mois courant, en cohérence avec les rapports Cartes et Comparables. La tuile Cumul annuel reste ancrée au 1er janvier de l'année civile en cours. Un nouveau sélecteur de mois de référence permet de faire pivoter le solde mensuel et la comparaison des top mouvements vers n'importe quel mois passé ; le choix est mémorisé dans l'URL via `?refY=YYYY&refM=MM` pour que la vue soit bookmarkable. Le panneau de faits saillants du hub suit la même valeur par défaut (#106)
### Corrigé
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)
- **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101)
- **Rapport Cartes — adhésion budgétaire** : la carte affichait systématiquement « aucune catégorie avec budget ce mois-ci » même lorsque des budgets étaient définis sur les catégories de dépenses. Cause racine : les budgets de dépenses sont stockés signés négatifs et le filtre/la comparaison utilisaient les valeurs brutes au lieu des absolus. Le nombre de catégories, les catégories dans la cible et les montants de dépassement sont maintenant tous calculés sur les valeurs absolues (#112)
## [0.8.2] - 2026-04-17
### Ajouté
- **Widget Feedback Hub** (Paramètres → Journaux) : un bouton *Envoyer un feedback* dans la carte Journaux ouvre un dialogue pour soumettre suggestions, commentaires ou rapports de bogue vers le Feedback Hub central. Un dialogue de consentement (affiché une seule fois) explique que l'envoi atteint `feedback.lacompagniemaximus.com` — une exception explicite au fonctionnement 100 % local de l'app. Trois cases à cocher opt-in (toutes décochées par défaut) : inclure le contexte de navigation (page, thème, écran, version, OS), inclure les derniers logs d'erreur, m'identifier avec mon compte Maximus. L'envoi passe par une commande Rust côté backend, donc rien ne quitte la machine tant que l'utilisateur n'a pas cliqué *Envoyer* (#67)
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
### Modifié
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)
## [0.8.0] - 2026-04-14
### Ajouté

View file

@ -2,6 +2,84 @@
## [Unreleased]
## [0.9.1] - 2026-05-10
### Added
- Bilan: 4 starter accounts (Checking account, TFSA, RRSP, Non-registered account) are seeded for new profiles, and a one-shot opt-in modal proposes them to existing profiles on their first /balance visit. Default-checked checkboxes; existing accounts with the same name + category disable the matching row with a "Already exists" tooltip. Confirming or dismissing both write `user_preferences.balance_starter_proposed` so the modal never re-appears. ADR 0012 (Proposed) captures the future two-level vehicle × composition model (#179).
### Changed
- **Price-fetching activated**`maximus-api` `/v1/prices` is live in production since 2026-05-05. The premium price-fetching feature shipped in 0.9.0 (#160) is now functionally available end-to-end (#161).
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178).
### Fixed
- Bilan: fix SQLite "misuse of aggregate function MIN()" error when loading /balance with existing snapshots; replaced aggregate-in-WHERE pattern with ROW_NUMBER() window function in getAccountsPeriodAnchor (#175).
- Bilan: snapshot save now uses atomic BEGIN/COMMIT and validates all lines before any DB write, preventing orphan snapshot rows when validation fails. Migration v11 cleans existing orphans (#176).
- Bilan: snapshot date picker on `/balance/snapshot` now closes after a date is selected on Linux (WebKitGTK), instead of staying open until the user pressed Esc. Workaround calls `blur()` on the input after each change — no-op on Windows WebView2 / macOS WKWebView, where the picker already auto-closes (#177).
- Updated `postcss` dependency (8.5.6 → 8.5.13) to address moderate severity advisory GHSA-qx2v-qp2m-jg93 (XSS via unescaped `</style>` in CSS stringifier). Transitive via vite, build-time only — no runtime impact on the shipped Tauri binary (#180).
- WebKitGTK date picker workaround extended to the remaining 7 native `<input type="date">` fields across 4 components (Transactions filter bar, Adjustments form, Link Transfers modal, Period selector). Each onChange handler now calls `e.currentTarget.blur()` to dismiss the native popup on Linux Tauri WebView — no-op on Windows WebView2 / macOS WKWebView. Same approach as #177 (#188).
- Bilan: post-merge cleanup of suggestions raised in the #182-#185 reviews. Six fixes bundled: (1) `getStarterCollisions` now filters `archived_at IS NULL` so re-creating a voluntarily archived starter is no longer blocked; (2) `proposeStarterAccounts` re-checks each (name, category) collision in-transaction before INSERT as defense-in-depth (skips silently on hit, no UNIQUE constraint added); (3) brand-new profiles now get `balance_starter_proposed` pre-seeded in `consolidated_schema.sql` so the StarterAccountsModal never briefly opens with all-collisions on first /balance visit; (4) `/balance` now hides the period selector, evolution chart and accounts table while the empty-state onboarding card is shown (avoids three stacked empty messages); (5) `BalanceOnboardingCard.Step` now calls `useTranslation()` directly instead of receiving `t` as a prop; (6) `return_calculator.rs` Modified Dietz formula doc block is wrapped in a `text` fence so `cargo test --doc` no longer fails to compile pseudo-math as Rust (#187).
## [0.9.0] - 2026-04-29
### Added
- **Balance sheet — `asset_type` column on priced categories** (route `/balance/accounts`): priced balance categories now carry an explicit `asset_type` (`stock` or `crypto`) that drives PriceFetchControl provider routing without relying on symbol heuristics (e.g. ETH = Ethan Allen NYSE *and* Ethereum crypto are no longer ambiguous). Migration v10 adds a nullable column and backfills the two seeded priced categories (`stock`, `crypto`) with their matching values; legacy custom priced rows stay NULL until a future edit-category UI lets the user fill them in. The category creation form (Categories tab) now shows an asset-type selector when `kind = priced` and rejects submission until a value is picked. The snapshot editor hides the price-fetch button on priced rows whose `asset_type` is still NULL — manual entry remains the only path on those legacy rows. (#169)
- **Balance sheet — documentation and ADRs** (`docs/`): closes the Bilan milestone with the documentation pass. `docs/architecture.md` now lists the 5 new tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), the 7 new indexes, the SQL CHECK and FK invariants (CAD-only, kind invariants, `RESTRICT` on `transaction_id` for Modified Dietz reproducibility), the `balance.service.ts` 4-section layout (CRUD / snapshots+lines / returns+transfers / prices), the 3 page-scoped hooks (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), the `compute_account_return` Tauri command (with the `fetch_price` future-Phase-5 mention), and the 3 new `/balance*` routes. Three new ADRs land alongside: **0008 — Modified Dietz** (justifies the choice vs. ROI / TWR / IRR with reference to `return_calculator.rs`); **0009 — Proxy price-fetching via maximus-api** (architecture documented now, implementation stays BLOCKED by maximus-api Phase 2 — covers privacy considerations like header stripping, no `(symbol, license)` log correlation and the fixed `simpl-resultat` UA, the Yahoo + CoinGecko provider abstraction, the Bearer auth strategy, the client + server rate limiting and the dual-side premium gating); **0010 — FK RESTRICT on `balance_account_transfers.transaction_id`** (justifies the integrity over friction trade-off for Modified Dietz reproducibility). The user guide gains a new *Balance sheet* section walking through snapshot entry (simple + priced), transfer linking, multi-horizon return reading (3M / 1Y / since inception with the side-by-side unadjusted column), with the price-fetching premium flagged "coming in Phase 5". `docs.balance.*` i18n keys (FR + EN) ship so the in-app guide reflects the new section (#145)
- **Balance sheet — cross-cutting integration test suite** (test infrastructure): closes out the *Bilan* feature with a layer of integration tests that exercise the whole TypeScript surface in a single happy-path flow (account → priced category → priced snapshot → linked transfer → return) plus dedicated assertions for currency lock (CAD-only at the MVP, rejected at both the service layer and SQL CHECK), priced-kind tolerance safety (a bad save must NOT clear pre-existing lines), `computeAccountReturn` wiring (active-profile resolution, ISO date forwarding, partial-period payload pass-through). Three new Rust integration tests apply migration v9 on top of a seeded v1 schema with pre-existing transactions to verify (1) no row loss / data mutation, (2) link / unlink transfer round-trip on real transaction ids, (3) the FK RESTRICT chain (linked transaction deletion blocked, unblocked after unlink), (4) the v1 `categories.id` and v9 `balance_categories.id` namespaces coexist independently. A non-regression source-level test on `TransactionTable.tsx` locks down the inlined transfer icon contract: optional prop, optional-chaining short-circuit, i18n keys, aria-label, shared description-cell layout — so the page renders identically when no transfers are linked. (#144)
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end V_start ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T t_i) / T`, plus `(1 + R)^(365/T) 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
- **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent (server activation pending — feature dormant until maximus-api `/v1/prices` ships). (#160)
### Changed
- **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49)
### Fixed
- **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126)
## [0.8.4] - 2026-04-21
### Added
- **Settings banner (90-day) and permanent Restore action to roll back a category migration from the automatic pre-migration backup** (Settings → *Categories*): after a v2→v1 migration, a dismissable banner (`ShieldCheck` icon) now appears at the top of the Categories card for 90 days, pointing at the automatic SREF backup written by `categoryBackupService`. A dedicated *Restore a backup* entry stays available below the migrate link as long as a migration is recorded — even past the 90-day window — so the rollback is never lost. The confirm modal reads the `last_categories_migration` journal for its timestamp and backup path, enforces a two-step confirmation with a red *Restore* button, falls back to a file picker when the recorded path is missing on disk, prompts for the profile PIN when the SREF file is encrypted, and on success resets `categories_schema_version=v2` and stamps `reverted_at` on the journal before reloading the app. The banner hides automatically once the migration has been reverted. New Tauri command `file_exists` for the pre-flight presence check, new `categoryRestoreService` wrapping `read_import_file` + `importTransactionsWithCategories` with stable error codes (#122)
- **3-step category migration page** (route `/settings/categories/migrate`, Settings → *Migrate to the standard structure*): legacy v2 profiles can now opt in to migrate to the new v1 IPC taxonomy through a guided flow — *Discover* (read-only tree reused from the guide page), *Simulate* (3-column dry-run table with high / medium / low / needs-review confidence badges, a clickable side panel showing the first 50 affected transactions per row, inline target picker for unresolved rows, next button blocked until every row is resolved), and *Consent* (checklist + optional PIN field for protected profiles + 4-step loader). On confirm, the page creates a verified SREF backup via `categoryBackupService` (mandatory, abort on failure with no DB write) and then runs an atomic SQL transaction via the new `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT v1 taxonomy → UPDATE transactions / budgets / budget_templates / keywords / suppliers to the new v1 category ids → reparent custom categories under a new *Custom categories (migration)* parent → soft-deactivate the v2 seed categories → bump `categories_schema_version='v1'` and journal the run in `user_preferences.last_categories_migration` → COMMIT. Any thrown error triggers ROLLBACK so the profile stays in its pre-migration state. Success and error screens surface the backup path and (for success) the counts of rows inserted / transactions, keywords and budgets migrated (#121)
- **Dashboard banner inviting v2 profiles to discover the new v1 IPC category taxonomy**: legacy profiles (tagged `categories_schema_version='v2'`) now see a dismissable banner at the top of the Dashboard pointing to the new standard categories guide page. The banner is non-destructive (read-only CTA, no category changes), only shown to v2 profiles (new v1-seeded profiles never see it), and its dismissal is persisted in `user_preferences` under `categories_v1_banner_dismissed` so it never reappears once closed (#118)
- **Standard categories guide page** (Settings → *Standard category structure*, route `/settings/categories/standard`): new read-only page that exposes the full v1 IPC taxonomy as a navigable tree with expand/collapse per root, a live category counter (roots · subcategories · leaves · total), accent-insensitive full-text search over translated names, hover tooltips showing the `i18n_key` / type / ID of each node, and a *Export as PDF* button that triggers the browser print dialog. A dedicated `@media print` rule forces every branch to render fully expanded regardless of the on-screen collapse state. All labels resolve via `categoriesSeed.*` with `name` as fallback for future custom rows. No database writes, no destructive actions (#117)
- **IPC-aligned categories seed for new profiles**: brand-new profiles are now seeded with the v1 IPC (Indice des prix à la consommation) taxonomy — a structured hierarchy aligned with Statistics Canada consumer price index categories. Category labels are now translated dynamically from the `categoriesSeed.*` i18n namespace (FR/EN), so seed categories display in the user's current language. Existing profiles remain on the legacy v2 seed, marked via a new `categories_schema_version` user preference (a later migration wizard will offer the v2→v1 transition). Internally: nullable `categories.i18n_key` column added in migration v8 (additive only), `src/data/categoryTaxonomyV1.json` bundled as the TS-side source of truth, `CategoryTree` and `CategoryCombobox` renderers fall back to the raw `name` when no translation key is present (user-created rows) (#115)
## [0.8.3] - 2026-04-19
### Added
- **Cartes report — Monthly / YTD toggle** (`/reports/cartes`): new segmented toggle next to the reference-month picker flips the four KPI cards (income, expenses, net balance, savings rate) between the reference-month value (unchanged default) and a Year-to-Date cumulative view. In YTD mode, the "current" value sums January → reference month, MoM delta compares it to the same-year Jan → (refMonth 1) window (null for January), YoY delta compares it to Jan → refMonth of the previous year, and the savings rate uses the YTD income/expenses. The 13-month sparkline, top movers, seasonality and budget adherence cards remain monthly regardless of the toggle. The savings-rate tooltip now reflects the active mode. Choice persisted in `localStorage` (`reports-cartes-period-mode`) (#102)
- **User guide — Cartes section**: new dedicated section documenting the four KPI formulas, the Monthly/YTD toggle, the sparkline, top movers, seasonality and budget adherence rules, along with the savings-rate edge case ("—" when income is zero) (#102)
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income expenses) ÷ income × 100`, computed on the reference month (#101)
- **Trends report — by category** (`/reports/trends`): new segmented toggle to switch the category-evolution chart between stacked bars (default, unchanged) and a Recharts stacked-area view (`<AreaChart stackId="1">`) that shows total composition over time. Both modes share the same category palette and SVG grayscale patterns. The chosen type is persisted in `localStorage` (`reports-trends-category-charttype`) (#105)
### Changed
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#103)
- **Compare report — Actual vs. actual** (`/reports/compare`): the table now mirrors the rich 8-column structure of the Actual vs. budget table, splitting each comparison into a *Monthly* block (reference month vs. comparison month) and a *Cumulative YTD* block (progress through the reference month vs. progress through the previous window). MoM cumulative-previous uses Jan → end-of-previous-month of the same year; YoY cumulative-previous uses Jan → same-month of the previous year. The chart remains a monthly-only view (#104)
- **Highlights report** (`/reports/highlights`): the monthly tiles (current-month balance, top movers vs. previous month) now default to the **previous calendar month** instead of the current one, matching the Cartes and Compare reports. The YTD tile stays pinned to Jan 1st of the current civil year. A new reference-month picker lets you pivot both the monthly balance and the top-movers comparison to any past month; the selection is persisted in the URL via `?refY=YYYY&refM=MM` so the view is bookmarkable. The hub highlights panel follows the same default (#106)
### Fixed
- **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101)
- **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101)
- **Cartes report — budget adherence**: the card was always saying "no budgeted categories this month" even when budgets were defined on expense categories. Root cause: expense budgets are stored signed-negative, and the filter/comparison used raw values instead of absolutes. Categories, in-target counts, and worst-overrun amounts are now all computed on absolute values (#112)
## [0.8.2] - 2026-04-17
### Added
- **Feedback Hub widget** (Settings → Logs): a *Send feedback* button in the Logs card opens a dialog to submit suggestions, comments, or bug reports to the central Feedback Hub. A one-time consent prompt explains that submission reaches `feedback.lacompagniemaximus.com` — an explicit exception to the app's 100% local operation. Three opt-in checkboxes (all unchecked by default): include navigation context (page, theme, viewport, app version, OS), include recent error logs, identify with your Maximus account. Routed through a Rust-side command so nothing is sent unless you press *Send* (#67)
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
### Changed
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)
## [0.8.0] - 2026-04-14
### Added

View file

@ -1,5 +1,7 @@
# CLAUDE.md — Simpl'Résultat
@STATE.md
## Contexte du projet
**Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max.
@ -49,7 +51,7 @@ src/
│ ├── shared/ # Composants réutilisables
│ └── transactions/ # Transactions
├── contexts/ # ProfileContext (état global profil)
├── hooks/ # 12 hooks custom (useReducer)
├── hooks/ # 13 hooks custom (useReducer)
├── pages/ # 11 pages
├── services/ # 14 services métier
├── shared/ # Types et constantes partagés

27
STATE.md Normal file
View file

@ -0,0 +1,27 @@
# STATE — Simpl'Résultat
> Derniere MAJ : 2026-05-03 (par fix-issue #187 + #188)
## Position actuelle
Phase post-Bilan : milestone complet (5 sub-features merged, ADRs 0008-0010+0012). Polish + prep release pour shipper la nouvelle pubkey Ed25519 alignee sur maximus-api LIVE (`api.lacompagniemaximus.com`). Prochains gros chantiers : activation en ligne (#53), pipeline Stripe (#50, #135-136), price-fetching premium production (#161).
## Decisions recentes
- 2026-05-03 : Bilan post-merge cleanup (S1-S5+S7) — `getStarterCollisions` filtre `archived_at IS NULL`, in-txn re-check sur `proposeStarterAccounts`, pre-seed `balance_starter_proposed` pour nouveaux profils, guard empty-state `/balance`, `useTranslation` direct dans `Step`, doctest fence `text` sur Modified Dietz (ref #187)
- 2026-05-03 : WebKitGTK date picker workaround etendu aux 7 inputs date restants dans 4 composants (TransactionFilterBar, AdjustmentForm, LinkTransfersModal, PeriodSelector) (ref #188)
- 2026-05-03 : postcss 8.5.6 -> 8.5.13, fix GHSA-qx2v-qp2m-jg93 (transitif via vite, build-time only, exposition runtime nulle) (ref #180)
- 2026-05-02 : Settings eclate en 3 sous-pages `/settings/{users,data,systems}` + redirections legacy `/docs` et `/changelog` (ref #190)
- 2026-05-02 : Doc license — placeholder Bearer JWT-like remplace par `<license-token>` (ref #181)
- 2026-05-01 : WebKitGTK date picker — `blur()` apres selection sur `/balance/snapshot` (ref #177)
- 2026-05-01 : Icon Tauri custom (calculatrice + cadenas privacy), 16 raster sizes regenerees
- 2026-04-29 : Bilan starter accounts (4 comptes seedes + modal opt-in) + ADR 0012 vehicle x composition (ref #179)
- 2026-04-29 : Bilan onboarding 2-step empty state `/balance` (ref #178)
- 2026-04-28 : Bilan snapshot save atomic BEGIN/COMMIT + migration v11 cleanup orphans (ref #176)
## Blockers actifs
- #161 — feat(prices): production wiring + smoke test + release (BLOCKED par maximus-api Phase 2)
- #135 / #136 — maximus-api Stripe webhooks license auto-generate (BLOCKED par maximus-api Phase 2)
- #53 — online activation + machine limit enforcement (status:needs-fix)
- #50 / #52 — Stripe integration + purchase page (status:ready, design en attente)

View file

@ -0,0 +1,100 @@
# ADR 0008 — Modified Dietz pour le calcul du rendement par compte
- Status: Accepted
- Date: 2026-04-25
- Milestone: `overnight-2026-04-26-bilan` (Issues #138#145)
## Context
La feature Bilan introduit une vue patrimoniale (snapshots datés) avec calcul du rendement par compte. Le rendement réel d'un compte d'investissement n'est PAS `(V_fin V_début) / V_début` : cette formule confond les **gains réels** avec les **apports/retraits**.
> Exemple : compte CELI à 10 000 $, on dépose 5 000 $, le compte vaut 16 000 $ à la fin. La formule naïve donne 60 % (16/10), mais la moitié du gain est juste l'apport. Le vrai rendement est 6 % : `(16 000 5 000 10 000) / 10 000`.
C'est exactement la raison pour laquelle l'utilisateur tague des transferts (table `balance_account_transfers`) : pour les exclure du calcul.
Quatre formules candidates ont été comparées dans le spike (`~/claude-code/.spikes/bilan/code/rendement.md`) :
| Méthode | Pondère le timing des flux ? | Nécessite des valeurs intermédiaires ? | Standard d'industrie ? |
|---------|---|---|---|
| ROI ajusté simple | ❌ | ❌ | ❌ |
| **Modified Dietz** | ✅ (approximation linéaire) | ❌ | ✅ (GIPS-compliant en première approximation) |
| Time-Weighted Return (TWR) | ✅ (exact) | ✅ (à chaque flux) | ✅ |
| Money-Weighted Return / IRR | ✅ (exact, itératif) | ❌ | ✅ |
Contraintes du contexte Simpl'Résultat :
- Les snapshots sont saisis librement (mensuels, trimestriels, ad-hoc) — il n'y a **pas** de valeur du compte aux dates de flux.
- Pas de solveur numérique embarqué côté client (pas de Newton-Raphson en Rust pour l'IRR).
- L'utilisateur doit pouvoir comprendre le résultat sans formation financière.
## Decision
**Adopter Modified Dietz** comme méthode unique de calcul du rendement par compte au MVP, implémentée côté Rust dans le module privé `src-tauri/src/commands/return_calculator.rs`.
```
R = (V_fin V_début C_net) / (V_début + Σ(C_i × W_i))
```
où :
- `C_i` = chaque flux (signé : + apport, retrait)
- `W_i = (T t_i) / T` = poids temporel (1 si début de période, 0 si fin)
- `T` = durée totale de la période en jours, `t_i` = position du flux
### Architecture
- **Logique pure** : `commands/return_calculator.rs` (module privé, pas exposé comme commande). `pub(crate) fn modified_dietz(...) -> AccountReturn`.
- **Commande Tauri** : `commands/balance_commands.rs::compute_account_return(account_id, period_start, period_end, db_filename)` ouvre une connexion `rusqlite` courte sur la DB du profil actif, lit le snapshot ≤ start, le snapshot ≥ end et les cash flows liés, puis délègue le calcul.
- **Dépendance Cargo** : `chrono = "0.4"` ajoutée pour l'arithmétique de dates (poids temporels en jours).
- **Tests TDD co-localisés** : `#[cfg(test)] mod tests` dans le même fichier — 7 cas (nominal, pas de snapshot début, partial-end, compte créé en cours, compte vidé, aucun transfert, annualisation).
### Output
```rust
struct AccountReturn {
value_start: Option<f64>,
value_end: f64,
net_contributions: f64,
return_pct: Option<f64>, // None si dénominateur ≈ 0
annualized_pct: Option<f64>, // (1 + R)^(365/days) - 1, si days > 30
is_partial: bool, // true si snapshot manquant après fin
has_no_transfers_warning: bool, // true si aucun transfert lié
}
```
### Affichage côté UI (`BalanceAccountsTable`)
- 3 colonnes Modified Dietz : 3M / 1A / depuis création
- 1 colonne **rendement non-ajusté** (`(V_fin V_début) / V_début`) côte-à-côte — pédagogique : montre l'effet des apports vs gains réels
- Warnings visibles (`is_partial`, `has_no_transfers_warning`) avec tooltip i18n
## Consequences
### Positive
- **Pas besoin de valeurs intermédiaires** : le calcul ne nécessite que les snapshots existants + les transferts taggés. C'est exactement ce que l'utilisateur saisit déjà.
- **Standard d'industrie** : Modified Dietz est GIPS-compliant en première approximation. Le résultat est défendable.
- **Pédagogique** : afficher le rendement non-ajusté à côté du Modified Dietz éduque l'utilisateur sur la différence entre "valeur du compte" et "vraie performance".
- **Implémentation simple** : ~50 lignes de logique pure en Rust + 7 tests. Pas de solveur numérique.
- **Reproductibilité** : combinée avec la FK `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id` (voir [ADR 0010](0010-fk-restrict-balance-transfers.md)), une période déjà calculée ne peut pas changer rétroactivement.
### Negative / trade-offs
- **Approximation** : Modified Dietz suppose une distribution linéaire des flux dans le temps. Si plusieurs flux concentrés tombent juste avant un mouvement de marché significatif, l'erreur s'accumule. Acceptable pour un usage personnel ; un investisseur professionnel utiliserait TWR exact.
- **Cas dégénéré "compte vidé puis rechargé"** : le dénominateur `V_début + Σ(C_i × W_i)` peut tendre vers zéro et faire exploser le ratio. Mitigé par un warning UI "Performance non significative" basé sur `has_no_transfers_warning` ou un seuil sur le dénominateur.
- **Pas de TWR au MVP** : si l'utilisateur veut la vraie performance gestionnaire (indépendante du timing des flux), il devra attendre une v2 qui demandera de saisir des valeurs intermédiaires aux dates de flux.
- **Pas de Money-Weighted Return / IRR** : formule plus précise mais nécessite Newton-Raphson. Coût/bénéfice défavorable au MVP.
## Alternatives considered
- **ROI ajusté simple** (`(V_fin V_début C_net) / V_début`). Rejeté : ignore *quand* l'apport est arrivé. Un dépôt de 10 000 $ le 1er janvier vs le 31 décembre donne le même résultat — incorrect.
- **TWR (Time-Weighted Return)**. Rejeté pour le MVP : nécessite des valeurs du compte aux dates de flux, qu'on ne stocke pas. Possible v2 si l'utilisateur accepte de saisir des valeurs intermédiaires.
- **IRR (Money-Weighted Return)**. Rejeté : nécessite un solveur Newton-Raphson, complexité disproportionnée pour un usage personnel.
- **Calcul côté TypeScript (sans commande Rust)**. Rejeté : l'arithmétique de dates en JavaScript (`Date.UTC(...) / 86400000`) est correcte mais le pattern projet (logique financière côté Rust avec tests `cargo`) est plus robuste. Cohérent avec `aes-gcm`, `argon2`, etc.
## References
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md)
- Spike : `~/claude-code/.spikes/bilan/code/rendement.md` (comparaison ROI / Modified Dietz / TWR / IRR)
- Implémentation : `src-tauri/src/commands/return_calculator.rs`, `src-tauri/src/commands/balance_commands.rs`
- Tests TDD : `#[cfg(test)] mod tests` dans `return_calculator.rs` (7 cas)
- ADR liée : [0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`](0010-fk-restrict-balance-transfers.md)
- GIPS standards (Global Investment Performance Standards) — Modified Dietz est listé comme méthode acceptable d'approximation pour des périodes < 1 an.

View file

@ -0,0 +1,158 @@
# ADR 0009 — Price-fetching premium via proxy maximus-api
- Status: Accepted (architecture documentée — implémentation reportée à l'Issue #143, BLOCKED par maximus-api Phase 2)
- Date: 2026-04-25
- Milestone: `overnight-2026-04-26-bilan` (architecture spec)
## Context
La feature Bilan supporte des comptes "priced" (actions, crypto) où chaque ligne de snapshot stocke `(quantity, unit_price, value)`. La saisie manuelle de `unit_price` reste toujours possible mais devient pénible dès qu'on a plusieurs titres ou qu'on rétro-saisit un historique.
L'objectif est de proposer un bouton "récupérer le prix au [date]" qui interroge un fournisseur de données (Yahoo Finance, CoinGecko, etc.) sans **trahir le principe privacy-first NON NÉGOCIABLE** du projet :
> Zéro donnée envoyée vers un serveur tiers. Tout le traitement CSV et toutes les données financières restent en local. Aucune télémétrie, aucun analytics cloud.
Or interroger Yahoo ou CoinGecko, c'est par définition envoyer une requête sortante depuis l'IP de l'utilisateur. Quelles informations fuiteraient ?
- **L'IP de l'utilisateur** : géolocalisation grossière, profilage de session
- **L'User-Agent par défaut** de `reqwest` : `reqwest/0.12 ...`, identifie le client comme une app Tauri (silhouette technique reconnaissable)
- **Le symbole + date** : "AAPL au 2026-03-15" n'est pas identifiant en soi mais corrélé à l'IP, le provider peut reconstruire le portefeuille
- **Headers résiduels** : `Accept-Language` peut révéler la locale système
Trois architectures candidates :
| Option | Privacy | Complexité serveur | Coût d'API |
|--------|---------|--------------------|------------|
| Appel direct client → provider | ❌ IP exposée, fingerprint headers | aucune | par user (rate limits triggered fast) |
| Appel direct + Tor / VPN intégré | ⚠ partiel, latence dégradée | aucune | par user |
| **Proxy via maximus-api auto-hébergé** | ✅ IP cachée, headers strippés, cache mutualisé | Endpoint `/v1/prices` à maintenir | mutualisé (cache mutualisé entre users premium) |
## Decision
**Implémenter le price-fetching comme fonctionnalité premium-only servie par `maximus-api` agissant comme proxy**, avec consentement explicite et hygiène de headers stricte des deux côtés du fil.
### Architecture
```
[App Tauri]
│ GET /v1/prices?symbol=AAPL&date=2026-03-15
│ Headers: Authorization: Bearer <activation_token>
│ Accept: application/json
│ User-Agent: simpl-resultat
[maximus-api] ← VPS Max (Coolify)
│ 1. Strip TOUS headers entrants identifiants
│ 2. Validation tier premium (403 si non-premium)
│ 3. Cache SQLite (symbol, date) → price (TTL infini sur dates passées)
│ 4. Cache miss → adapter (Yahoo / CoinGecko)
[Provider tiers] ← voit l'IP du VPS, pas du client
```
### Choix de providers : abstraction adapter
Côté maximus-api, un module `price-fetcher` expose une interface unique et délègue à des adapters :
| Provider | Stocks | Crypto | Coût | Adapter |
|----------|--------|--------|------|---------|
| **Yahoo Finance** (unofficial) | ✅ | ⚠ | gratuit | `YahooAdapter` (HTTP direct) |
| **CoinGecko** | ❌ | ✅ excellent | gratuit (free tier 30 req/min) | `CoinGeckoAdapter` |
| Alpha Vantage (fallback) | ✅ | ⚠ | freemium | optionnel si Yahoo casse |
**Stocks → Yahoo** ; **Crypto → CoinGecko**. L'abstraction permet de swap si un provider casse, sans changer le contrat client.
### Stratégie d'authentification
- **`Authorization: Bearer <activation_token>` uniquement.** Le token est lu côté client depuis `activation_path` (le fichier déjà utilisé par `license_commands.rs` pour persister le token d'activation). **Jamais stocké dans `user_preferences`** (la table SQL de l'app n'a pas vocation à versionner les credentials).
- **Jamais en query string.** Un token-in-URL leakerait dans :
- Les logs Traefik / nginx du VPS (URL complète loguée par défaut)
- Le header `Referer` si maximus-api redirige
- Les écrans de partage (le header `Authorization` est masqué par les outils de capture, pas l'URL)
### Hygiène des headers — privacy en profondeur
**Côté client (Rust / `reqwest`)** :
- `reqwest::Client::builder().user_agent("simpl-resultat").build()` — UA fixe, pas le default `reqwest/0.12 ...`
- Headers envoyés UNIQUEMENT : `Authorization: Bearer <token>` + `Accept: application/json`
- **Pas** de `Accept-Language` (révèle la locale)
- **Pas** d'autres headers identifiants
**Côté serveur (maximus-api)** :
- Strip TOUS les headers entrants avant de proxyer vers le provider tiers (`X-Forwarded-For`, `User-Agent` client, `Accept-Language`, etc.)
- **Ne JAMAIS logger `(symbol, license_id)` ensemble.** Soit séparer les logs (un journal pour la facturation/quota par licence sans symbole, un journal pour les hits cache/provider sans license), soit hasher le `license_id` côté serveur avec un sel rotatif court avant log
- Validation premium **AVANT** cache et provider — un client non-premium reçoit 403 sans qu'aucun appel sortant ne soit déclenché
### Rate limiting
**Côté client** :
- Max 1 fetch / 2 secondes (timer simple)
- Dedup in-flight par `(symbol, date)` (deux clics rapides = 1 seule requête réseau)
- Backoff exponentiel sur 5xx / network : 2s, 4s, 8s — max 3 retries
- Plafond hard : 100 fetches par session snapshot (anti-loop)
**Côté serveur** :
- Quota par licence (proposition initiale : 1000 req/jour, le cache absorbe l'essentiel)
- Le cache `(symbol, date)` est immuable pour les dates passées (TTL infini), 5 min pour `today` (le marché peut bouger)
### Premium gating — défense en profondeur
- **UI client** : si `entitlements.check_entitlement("price-fetching")` retourne `false`, le bouton ↻ affiche un tooltip "Disponible avec abonnement" et est désactivé. Pas de tentative de fetch.
- **Server-side** : `maximus-api /v1/prices` valide le tier premium AVANT cache/provider. Un client modifié qui bypass la UI reçoit 403.
La double vérification est délibérée : le client est compromettable (l'app Tauri est ouverte au reverse-engineering), seul le serveur peut faire foi.
### Consentement explicite (per-profile)
- Stockage : `user_preferences.price_fetching_consent = {consented_at: <ISO>, version: 1}`
- **NE PAS seeder la clé.** Absence = jamais demandé. Le default doit être "non-décidé", pas "false".
- Premier clic sur le bouton ↻ → modal de consentement → écriture de la clé après acceptation
- **Permanence** : pas de re-consent automatique. Révocation explicite via toggle Settings (supprime la clé)
- Stockage **per-profile** (table `user_preferences` est par-profil), pas global au système
### Mode offline / fallback
L'app **ne doit jamais bloquer la saisie d'un snapshot** parce que le price-fetching a échoué. La saisie manuelle de `unit_price` reste TOUJOURS disponible :
| Erreur serveur | Comportement |
|----------------|--------------|
| 401 license expirée | Toast "Renouvelez votre abonnement" + champ manuel dispo |
| 403 non-premium | Toast "Disponible avec abonnement Premium" + champ manuel dispo |
| 404 symbole | Toast "Symbole introuvable — vérifiez l'orthographe" + champ manuel |
| 429 rate limit | Toast "Limite atteinte — réessayez plus tard" + champ manuel |
| Network error / 5xx | Toast "Service temporairement indisponible" + champ manuel |
## Consequences
### Positive
- **L'IP de l'utilisateur n'est JAMAIS exposée à Yahoo / CoinGecko.** Le provider voit l'IP du VPS de Max — privacy-first préservée.
- **Aucun symbole ne révèle de données personnelles.** "AAPL" ou "BTC" ne sont pas identifiants en soi ; corrélés à une license_id ils le redeviennent, c'est pourquoi le serveur ne logue jamais les deux ensemble.
- **Cache mutualisé.** Si 500 utilisateurs premium demandent AAPL au 2026-03-15, c'est UN seul appel sortant côté maximus-api. Économise les rate limits ET réduit la surface d'exposition.
- **Mode offline préservé.** L'app continue de fonctionner sans price-fetching — la saisie manuelle reste le chemin de secours.
- **Justification commerciale.** Le price-fetching premium aligne le coût d'API tiers sur la révenue récurrente, sans dégrader l'expérience free-tier (qui reste 100 % local).
- **Adapter pattern.** Si Yahoo casse (API non officielle), swap pour Alpha Vantage côté serveur sans changer le contrat client.
### Negative / trade-offs
- **Dépendance opérationnelle au VPS.** Si maximus-api est down, le price-fetching ne fonctionne pas — atténué par le fallback manuel toujours dispo.
- **Surface serveur à maintenir.** Endpoint `/v1/prices` + cache + adapters + auth + rate limiting + observabilité (sans corrélation log).
- **Charge financière sur Max.** Les tier free n'ont pas accès, donc les coûts d'API tiers sont absorbés par les abonnements premium ; le cache aide significativement.
- **Implémentation BLOQUÉE.** L'Issue #143 ne peut shipper tant que `maximus-api` Phase 2 n'expose pas `/v1/prices` (dépendance externe : issues maximus-api `#49` license server core et `#136` Stripe webhooks).
## Alternatives considered
- **Appel direct client → provider.** Rejeté : viole le principe privacy-first (IP exposée + fingerprint headers).
- **Tor / I2P intégré.** Rejeté : latence prohibitive (5-10 secondes par fetch), maintenance d'un client Tor embarqué dans Tauri, et certains providers bloquent les exits Tor.
- **VPN tiers (Mullvad, etc.) configuré par l'utilisateur.** Rejeté : ne supprime pas le fingerprint headers, et "exiger l'utilisateur à configurer un VPN" est une régression UX inacceptable.
- **Cache local sans serveur (chaque user a son propre cache).** Rejeté : pas de mutualisation, chaque user paie son propre rate limit, et le client doit toujours faire l'appel sortant initial (donc IP exposée).
- **Saisie manuelle uniquement, pas de price-fetching du tout.** C'est le mode free-tier — fonctionnel mais friction élevée pour les utilisateurs avec un portefeuille actions/crypto significatif. Le proxy premium est le compromis qui justifie l'abonnement sans dégrader le free-tier.
- **Endpoint `/v1/symbols/search` côté maximus-api** pour autocomplete. Reporté à v2 : l'autocomplete double la surface d'API et n'est pas critique. La saisie texte simple suffit au MVP.
## References
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md) (Issue #5 — Phase 5)
- Spike : `~/claude-code/.spikes/bilan/code/price-fetching.md` (architecture, choix providers, consent flow)
- Issue client (BLOCKED) : maximus/simpl-resultat #143
- Issues maximus-api (externes, prerequisites) : `maximus-api#49` (license server core), `maximus-api#136` (Stripe webhooks)
- Pattern auth : `src-tauri/src/commands/license_commands.rs` (`activation_path` + `activate_machine` — le token Bearer existe déjà)
- Privacy frame : ce que `maximus-api` voit jamais ensemble = `(IP, license_id, symbol)`. Le proxy garantit que (IP) est cachée du provider et que (license_id, symbol) ne se retrouvent pas dans le même log.

View file

@ -0,0 +1,85 @@
# ADR 0010 — `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`
- Status: Accepted
- Date: 2026-04-25
- Milestone: `overnight-2026-04-26-bilan` (Issues #138#145)
## Context
La table `balance_account_transfers` lie une `transaction` existante à un `balance_account` avec une direction (`'in'` = capital ajouté au compte, `'out'` = capital retiré). Cette table est l'input du calcul Modified Dietz (cf. [ADR 0008](0008-modified-dietz-pour-rendement.md)) qui sépare les **apports** des **gains réels** pour calculer la performance d'un compte d'investissement.
La question structurante : que se passe-t-il si l'utilisateur supprime une transaction qui est liée à un transfert de bilan ?
Trois politiques de FK sont possibles côté SQL :
| Politique | Comportement | Intégrité historique | Friction utilisateur |
|-----------|--------------|----------------------|----------------------|
| `ON DELETE CASCADE` | Suppression de la transaction supprime aussi le transfert | ❌ Le rendement Modified Dietz d'une période passée change rétroactivement | ✅ Aucune friction : tout disparaît silencieusement |
| `ON DELETE SET NULL` | Le transfert reste mais perd son `transaction_id` | ⚠ Le transfert devient "orphelin" : direction connue mais montant introuvable (les montants vivent dans `transactions.amount`) | ⚠ État partiellement valide |
| **`ON DELETE RESTRICT`** | La suppression est bloquée par SQLite tant que des transferts pointent vers la transaction | ✅ Préservée : un rendement déjà calculé reste reproductible | ⚠ L'utilisateur doit délier explicitement avant suppression |
Contraintes du contexte :
- Modified Dietz produit un rendement **R** sur une période **[t1, t2]** à partir de `(V_début, V_fin, [(date, montant)])`. Si une `transaction` liée disparaît silencieusement (CASCADE), la fonction reste pure mais ses inputs changent — `R` calculé hier ≠ `R` calculé aujourd'hui sur la même période. C'est exactement l'antithèse de la reproductibilité financière.
- Le calcul est déclenché à la demande (chargement de `BalanceAccountsTable`), il n'y a pas de cache server-side. Donc l'historique de "ce que le user a vu hier" n'existe pas : si les inputs bougent, le résultat affiché change sans que l'utilisateur sache pourquoi.
- L'usage attendu de la suppression de transactions est rare et lié à des erreurs d'import (doublons, mauvaise source). Bloquer ce cas avec un message clair est acceptable.
## Decision
**Adopter `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`** :
```sql
CREATE TABLE balance_account_transfers (
...
transaction_id INTEGER NOT NULL,
...
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT
);
```
### UX correspondante
La couche service `transactionService.ts` détecte l'erreur SQLite `FOREIGN KEY constraint failed` et la transforme en `TransactionLinkedToBalanceError` typée, qui porte la liste des comptes liés. La UI affiche alors :
> **Cette transaction est liée au compte de bilan _<nom du compte>_.**
> Pour la supprimer, déliez-la d'abord : ouvrez le compte → Lier transferts → décochez cette transaction.
Avec un lien direct vers la `LinkTransfersModal` du compte concerné. L'utilisateur ne peut pas se retrouver bloqué : le chemin de déliaison est toujours dispo, à un clic du message d'erreur.
Pour les chemins bulk (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`), une pré-vérification SELECT (`LIMIT 50`) liste les premiers transferts liés AVANT de tenter la suppression — l'utilisateur voit un message agrégé "X transactions de cet import sont liées à des comptes de bilan" plutôt qu'un raw FK error toast.
### Direction CASCADE conservée pour `account_id`
À noter : la même table a une autre FK, `account_id`, configurée en `ON DELETE CASCADE`. Si l'utilisateur supprime un compte de bilan, ses transferts disparaissent — c'est cohérent puisque les rendements de ce compte n'ont plus lieu d'être.
L'asymétrie est délibérée :
- `account_id` ON DELETE CASCADE : le compte de bilan est l'objet "principal" du domaine Bilan, sa suppression nettoie ses dépendances internes
- `transaction_id` ON DELETE RESTRICT : la transaction est externe au domaine Bilan, sa suppression ne doit pas casser silencieusement les calculs
## Consequences
### Positive
- **Reproductibilité Modified Dietz garantie.** Un rendement calculé sur une période passée ne peut pas changer à cause d'une suppression invisible côté `transactions`.
- **Audit trail préservé.** L'utilisateur qui consulte un compte de bilan voit toujours les mêmes flux pour les mêmes périodes, peu importe quand il consulte.
- **Erreur visible et actionnable.** L'utilisateur reçoit un message concret avec un chemin clair pour résoudre, plutôt qu'une suppression silencieuse qui invaliderait l'historique financier.
- **Aligné avec la convention SQL existante du projet.** D'autres FK utilisent déjà `RESTRICT` quand l'intégrité est critique (cf. `balance_accounts.balance_category_id`, `balance_snapshot_lines.account_id`).
### Negative / trade-offs
- **Friction utilisateur** : forcer l'unlink explicite avant suppression ajoute 2 clics (ouvrir le compte → ouvrir LinkTransfersModal → décocher → revenir → supprimer). Acceptable car le cas est rare et le coût d'un rendement faux est élevé.
- **Couplage UI ↔ erreur SQL** : `transactionService.ts` doit détecter le format d'erreur SQLite (`FOREIGN KEY constraint failed`) et le mapper sur `TransactionLinkedToBalanceError`. Si tauri-plugin-sql change le format du message d'erreur, le mapping casse silencieusement (mitigé par les tests d'intégration co-localisés dans `transactionService.test.ts`).
- **Pré-vérification bulk a un coût** : un `SELECT ... LIMIT 50` sur `balance_account_transfers` à chaque suppression d'import. Négligeable en pratique (la table reste petite), mais à surveiller si un utilisateur a des dizaines de milliers de transferts.
## Alternatives considered
- **`ON DELETE CASCADE`.** Rejeté : trahit la promesse de reproductibilité du calcul Modified Dietz. Un rendement vu hier peut changer sans signal vers l'utilisateur.
- **`ON DELETE SET NULL` + transferts orphelins.** Rejeté : laisse la base dans un état "valide mais incohérent". Le transfert sait sa direction mais a perdu son montant (qui vit dans `transactions.amount`). Le code Modified Dietz devrait alors filtrer les orphelins, et l'utilisateur ne saurait plus pourquoi son rendement a changé. Pire que CASCADE, qui au moins est explicite.
- **Pas de FK du tout, juste un INTEGER orphelin possible.** Rejeté : retire toute garantie d'intégrité référentielle, et les calculs de rendement deviendraient une chasse aux pointeurs cassés.
- **Soft-delete des transactions (`deleted_at` au lieu de DELETE)** pour préserver les données liées tout en cachant la transaction de l'UI. Rejeté pour l'instant : les transactions n'ont pas de soft-delete dans le schéma actuel et l'introduire ouvrirait un chantier transversal (toutes les requêtes de transactions devraient filtrer `WHERE deleted_at IS NULL`). À reconsidérer si plusieurs domaines en font la demande.
## References
- Implémentation : `src-tauri/src/database/balance_schema.sql` (FK definition), `src/services/transactionService.ts` (`TransactionLinkedToBalanceError` mapping)
- Tests : `src/services/transactionService.test.ts` (mapping FK error → typed error), `src/__integration__/balance-flow.test.ts` (lien + tentative de suppression bloquée)
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md) — décision "FK `balance_account_transfers.transaction_id` : `ON DELETE RESTRICT` + UI force unlink avec message clair"
- ADR liée : [0008 — Modified Dietz pour le calcul du rendement](0008-modified-dietz-pour-rendement.md)

View file

@ -0,0 +1,89 @@
# ADR 0011 — Providers de prix : exchanges directs (crypto) + Yahoo Finance best-effort (stocks)
- Status: Accepted
- Date: 2026-04-26
- Successor of: ADR 0009 (architecture proxy) — précise les providers concrets
- Milestone: `spec-price-fetching` + `prices-proxy` (maximus-api)
## Context
ADR 0009 a établi qu'un proxy `maximus-api` mutualisé sert le price-fetching premium pour préserver la privacy (IP cachée, headers strippés). La revue spec du contrat `/v1/prices` (2026-04-26) a soulevé deux risques critiques :
1. **Yahoo Finance n'a pas d'API publique officielle.** Les endpoints `query1/query2.finance.yahoo.com` sont non documentés, leur ToS interdit l'usage commercial et la redistribution. Un IP-ban du VPS coupe le feature pour 100% des premium en même temps.
2. **CoinGecko free tier interdit le proxy commercial.** Seul le plan Demo/Pro payant (~129 $/mo Analyst) le permet contractuellement.
Quatre options ont été considérées (cf. revue inline `docs/api-contract-prices.md` §0) :
| Option | Coût/mois | Légalité commercial | Stabilité | Couverture |
|--------|-----------|---------------------|-----------|------------|
| Polygon.io Starter | 29 $ | ✅ contractuelle | ✅ haute | Stocks NYSE/NASDAQ + crypto |
| Tiingo Power + exchanges directs (crypto) | 10 $ | ✅ Tiingo, ✅ exchanges (public market data OSS-légal) | ✅ haute | Stocks + crypto |
| **Exchanges directs (crypto) + Yahoo best-effort (stocks)** | **0 $** | ⚠️ Yahoo ToS risqué (data publique mais redistribution interdite) ; ✅ exchanges | ⚠️ Yahoo fragile, exchanges stables | Stocks + crypto |
| Polygon Stocks + CoinGecko Pro | 158 $ | ✅ | ✅ | Best-of-both |
## Decision
**Adopter l'option « tout-OSS / best-effort »** pour le MVP :
- **Crypto** : interrogation directe des exchanges majeurs via la lib `ccxt` (MIT). Les données de marché publiques (ticker, OHLC) sont gratuites et explicitement autorisées en commercial par les ToS de Kraken, Coinbase, Binance, etc. Implémentation initiale : Kraken d'abord, Coinbase en fallback si Kraken 404.
- **Stocks** : interrogation de Yahoo Finance via `query1.finance.yahoo.com/v7/finance/quote` (et v8 chart pour historique) avec un User-Agent navigateur. **Best-effort assumé** : peut échouer ou changer sans préavis.
Le client paie pour **l'infrastructure d'anonymisation**, pas pour la donnée. Cette distinction est centrale au modèle économique : la valeur premium = privacy (proxy mutualisé) + commodité (auto-fill), pas la donnée elle-même.
### Garde-fous obligatoires
1. **Label UX explicite** : sur les catégories de bilan en stocks, le bouton fetch affiche un badge « best-effort » + warning au premier usage. Sur crypto : pas de warning.
2. **Circuit breaker côté maximus-api** : seuil `5 erreurs Yahoo / 60 sec → breaker ouvert pour 15 min`. Notification automatique Telegram/email à `maxime2tremblay@protonmail.com`.
3. **Quota baissé** : 200 req/jour/licence (vs 2000 initialement). Suffit pour ~50 actifs × snapshot mensuel. Réduit l'incitation à abuser.
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
5. **Headers stripping rigoureux** : tous les headers entrants supprimés avant call sortant. Vers Yahoo : UA browser-like (`Mozilla/5.0 ...`). Vers exchanges : UA `maximus-api/<version>`.
6. **Logs séparés** : pas de log conjoint `(license_id, symbol)`. Implémentation via wrapper logger injectable (`src/logger.ts` pino).
### Plan de migration si Yahoo devient inutilisable
Triggers de migration vers un provider payant :
- Plus de 1 incident IP-ban / mois pendant 2 mois consécutifs, OU
- Plus de 30% des requêtes stocks tombent en circuit-breaker `service_degraded` sur 7 jours, OU
- Plainte légale formelle de Yahoo / Verizon Media.
Provider de bascule prioritaire : **Tiingo plan Power** (~10 $/mo, 1000 req/jour, ToS-clean).
- Implémentation : ajouter un module `providers/tiingo.ts` parallèle à `providers/yahoo.ts`. Switch via env var `STOCKS_PROVIDER=yahoo|tiingo`.
- Délai de bascule : ≤ 30 jours après déclenchement d'un trigger.
- Communication : entrée CHANGELOG explicite + email aux licences premium actives.
Si l'audience grandit (>500 licences premium actives), bascule vers Polygon Starter (~29 $/mo) considérée.
## Consequences
### Positives
- **0 $ de coût récurrent au MVP** — pas de cash burn avant que le produit ait validé son marché.
- **Crypto 100% OSS-légal** — voie pérenne, ne nécessitera jamais de migration.
- **Justification premium cohérente** — privacy comme valeur, pas la donnée. Aligne avec les principes du projet.
- **Plan de bascule pré-engagé** — pas pris au dépourvu si Yahoo devient hostile.
### Négatives / Risques actés
- **ToS Yahoo en zone grise** — le proxy commercial de leur data publique n'est pas formellement autorisé. Yahoo a déjà émis des cease-and-desist contre yfinance (lib Python). Risque légal théorique mais peu probable à petite échelle.
- **IP-ban probable à un moment ou l'autre** — Yahoo bloque les UA non browser et les patterns de requête trop réguliers. Le circuit breaker absorbe l'événement, mais le feature devient temporairement HS pour tous les premium.
- **Pas de garantie de stabilité de schéma** — Yahoo peut renommer un champ JSON sans préavis. Tests d'intégration `nock` ne capturent pas ça (mock = donnée figée).
- **Charge ops accrue** — il faudra surveiller le taux d'erreur Yahoo et réagir vite si dégradation.
### Neutre
- Première implémentation un peu plus complexe côté serveur (deux providers + circuit breaker), mais le code reste contained dans `src/providers/` et est testable.
## Suivi
- ADR à reviewer dans 6 mois (2026-10-26) ou plus tôt si trigger de migration déclenché.
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`.
- Issue de suivi : créer une issue `ops` dans `maximus-api` pour le monitoring continu une fois deployé.
## References
- [Yahoo Finance ToS](https://legal.yahoo.com/us/en/yahoo/terms/index.html) — sec. 7-8 sur l'usage commercial
- [CoinGecko API ToS](https://www.coingecko.com/en/api/terms) — restrictions free tier
- [Kraken API public market data](https://docs.kraken.com/rest/) — explicite : free public tier, commercial OK pour data publique
- [CCXT (MIT)](https://github.com/ccxt/ccxt) — abstraction multi-exchange, lib OSS
- ADR 0009 — Architecture du proxy
- `docs/api-contract-prices.md` — Contrat figé `/v1/prices`

View file

@ -0,0 +1,104 @@
# ADR 0012 — Modèle à deux niveaux pour le Bilan (véhicules × compositions)
- Status: Proposed
- Date: 2026-05-01
- Issue: #179
## Contexte
Le Bilan modélise actuellement les comptes de manière **plate** : `balance_accounts` est rattaché à exactement une `balance_categories`, qui combine implicitement la **nature fiscale du véhicule** (CELI, REER, non enregistré) et la **classe d'actif** (encaisse, action, fonds, crypto). Les sept catégories seedées par Migration v9 sont des frères/sœurs au même niveau :
```
cash · tfsa · rrsp · fund · other · stock · crypto
```
Cette structure pose une limite expressive : **un véhicule fiscal et une classe d'actif sont deux dimensions orthogonales**, pas une hiérarchie. Un utilisateur qui détient une action d'Apple à l'intérieur d'un CELI n'a aujourd'hui que des choix dégradés :
- créer un compte `priced` rattaché à la catégorie `stock` → l'avantage fiscal CELI disparaît du modèle ;
- créer un compte `simple` rattaché à `tfsa` avec un montant agrégé → la valeur de marché et le rendement réel par titre disparaissent ;
- créer une catégorie utilisateur custom (`tfsa_stock`) → l'arbre explose en N×M permutations.
Cette tension est visible mais reste tolérable au MVP — la plupart des utilisateurs commencent avec des comptes simples (chèque, CELI cotisations, REER cotisations) et n'investissent en titres cotés que plus tard. La question est néanmoins structurante pour la roadmap : un changement de modèle après livraison V1 nécessitera une migration de données massive et une réécriture quasi totale de `/balance`.
L'ADR 0012 documente la réflexion **avant que le besoin devienne bloquant**, sans engager de code.
## Proposition — Modèle à deux niveaux
Remplacer `balance_accounts → balance_categories` par deux tables conceptuelles :
| Table | Rôle | Exemples |
|---|---|---|
| `balance_vehicles` | Véhicule fiscal / contenant | Compte chèque, CELI, REER, FERR, RPDB, non enregistré |
| `balance_compositions` | Classe d'actif détenue dans le véhicule | Encaisse, action, fonds indiciel, obligation, crypto |
Une **ligne de snapshot** devient un triplet `(vehicle_id, composition_id, value)` au lieu de l'actuel `(account_id, value)` :
```
balance_snapshot_lines
├── vehicle_id (FK balance_vehicles)
├── composition_id (FK balance_compositions)
├── quantity, unit_price (NULL pour compositions de type 'simple')
└── value
```
Bénéfices :
- **Expressivité** : un CELI avec 3 actions et un peu d'encaisse devient 4 lignes lisibles, additionnables, filtrables sur l'une OU l'autre dimension.
- **Rapports croisés** : "valeur totale en CELI" (somme par véhicule) ET "valeur totale en actions" (somme par composition) sont deux groupements naturels.
- **Modified Dietz par véhicule** ou **par composition** : les apports/retraits suivent le véhicule, le rendement suit la composition.
## Alternatives considérées
### A. Tagging multi-axes sur le modèle plat actuel
Garder `balance_accounts` plat, ajouter une table `balance_account_tags` libre. L'utilisateur tague chaque compte avec autant d'axes que voulu (`tfsa`, `stock`, `apple`, `tech`).
- ✅ Migration triviale (table additive).
- ❌ Aucune contrainte sur les combinaisons → la cohérence retombe sur l'utilisateur.
- ❌ Les rapports "actions dans CELI" deviennent une intersection de tags, beaucoup plus coûteuse à requêter et à expliquer.
- ❌ Risque d'arbres divergents entre profils — pas de vocabulaire partagé.
### B. Sous-comptes sous comptes
Introduire `balance_accounts.parent_id` (auto-référence). Un compte `Mon CELI` (catégorie `tfsa`, `simple`) pourrait avoir des enfants `Apple Inc.` (catégorie `stock`, `priced`).
- ✅ Modèle hiérarchique familier (similaire aux catégories de transactions).
- ❌ La somme parent = somme enfants devient un invariant à maintenir → friction de saisie.
- ❌ Les snapshots doublent leur taille (ligne parent + lignes enfants) sans gain expressif réel : la nature fiscale du parent et la nature d'actif des enfants restent collées sur un seul axe.
- ❌ Profondeur d'arbre incertaine : on retombe sur le multi-axes mal déguisé.
### C. Statu quo (modèle plat enrichi)
Garder le modèle actuel et accepter que les utilisateurs avancés créent des catégories user-définies pour les permutations qui les intéressent (`tfsa_stock`, `rrsp_fund`).
- ✅ Aucun coût de migration.
- ✅ Suffisant pour 80% des cas d'usage (utilisateurs avec des comptes simples).
- ❌ Friction documentée croissante au fur et à mesure que la base d'utilisateurs détient des portefeuilles diversifiés.
- ❌ La taxonomie utilisateur diverge entre profils, rendant tout futur partage ou agrégation cross-profil très coûteux.
## Impact
Une adoption du modèle à deux niveaux implique, au minimum :
- **Migration v12+** : décomposer chaque `balance_accounts` existant en `(vehicle, composition)` selon une heuristique sur `category.kind` + `category.asset_type`. Migration v9 actuelle (7 catégories seedées) sera scindée en deux seeds.
- **Réécriture complète des écrans `/balance/accounts` et `/balance/snapshot`** : la grille de saisie passe d'une dimension à deux.
- **Adaptation des agrégateurs `balance.service.ts`** : `getSnapshotTotalsByDate` reste valide, mais `getSnapshotTotalsByCategoryAndDate` doit être dédoublé en `getSnapshotTotalsByVehicleAndDate` + `getSnapshotTotalsByCompositionAndDate`.
- **Adaptation du calcul Modified Dietz** : la pertinence du rendement par véhicule vs par composition doit être tranchée.
- **Adaptation des graphiques** : la pile actuelle (stacked-by-category) doit choisir un axe par défaut + offrir une bascule.
Cet impact est massif. La proposition n'est viable qu'après stabilisation du modèle plat actuel et collecte de retours utilisateurs réels confirmant le besoin.
## Décision
**Status: Proposed.** L'équipe gèle la décision jusqu'à ce que les conditions de réévaluation soient réunies :
1. La V1 du Bilan (issues #138#179) est livrée et utilisée en production sans régression majeure pendant au moins un cycle de release ;
2. Au moins trois retours utilisateurs distincts décrivent le cas d'usage "actions à l'intérieur d'un CELI/REER" comme bloquant ;
3. La fonctionnalité de price-fetching (Issue #143, ADR 0009) est livrée — sans elle, le modèle à deux niveaux résoudrait un problème (rendement par titre dans CELI) sans pouvoir l'exploiter.
À la prochaine évaluation, cet ADR passera à `Accepted` (avec plan de migration v12+) ou `Rejected` (au profit du statu quo + tagging optionnel).
## Liens
- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz par compte (modèle plat)
- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK RESTRICT sur transferts (contrainte préservée par les deux modèles)
- Issue #179 — Comptes de départ + cet ADR

View file

@ -0,0 +1,217 @@
# ADR 0013 — Évaluation provider stocks : Alpha Vantage retenu comme cible de bascule (override partiel ADR 0011)
- Status: **Accepted**
- Date: 2026-05-07
- Successor of: ADR 0011 (override partiel — la pré-désignation **Tiingo Power** comme cible de bascule devient invalide ; **Alpha Vantage Premium** la remplace)
- Issue: [maximus-api#41](https://git.lacompagniemaximus.com/maximus/maximus-api/issues/41)
- Phase 1 research note: [maximus-api/docs/research/0013-stocks-providers-phase1.md](https://git.lacompagniemaximus.com/maximus/maximus-api/src/branch/issue-41-stocks-providers-research/docs/research/0013-stocks-providers-phase1.md)
## Context
L'ADR 0011 (2026-04-26) a adopté Yahoo Finance en best-effort pour les stocks, **avec un plan de bascule pré-désigné vers Tiingo Power (~10 $/mo, 1000 req/jour)** déclenché par triggers (1+ IP-ban/mois × 2 mois consécutifs, ou 30 %+ requêtes en service_degraded sur 7 jours, ou plainte légale).
Le smoke test 2026-05-04 (issue #25) a confirmé que Yahoo bloque l'IP du VPS OVH de manière stable. Un trigger ADR 0011 est de fait actif. Le feature stocks est non-fonctionnel en production.
**Décision encadrante (le présent ADR ne la modifie pas)** : on reste dans l'esprit MVP de l'ADR 0011 — **0 $ de cash burn tant que le produit n'a pas validé son marché**. La bascule vers un provider payant est repoussée jusqu'à un trigger plus net (1ère licence payée, OU 1ère plainte client active, OU saturation des plaintes "stocks cassé"). Le scope du présent ADR est de **valider empiriquement quel provider sera la cible de bascule** quand un trigger réel se déclenchera, **pas** de déclencher la bascule maintenant.
Avant de figer mécaniquement Tiingo Power comme dans 0011, l'évaluation a été élargie à 3 providers — Alpha Vantage, Tiingo, Polygon — pour potentiellement override la pré-désignation 0011 si un autre provider domine.
## Phase 1 — Recherche documentaire (sources publiques uniquement)
3 sous-agents WebSearch ont produit une synthèse 6-axes par provider. Synthèse complète dans `maximus-api/docs/research/0013-stocks-providers-phase1.md`.
Findings critiques :
| Critère | Alpha Vantage | Tiingo | Polygon |
|---|---|---|---|
| **TSX coverage** | ✅ via `.TRT` / `.TRV` | ❓ non confirmé publiquement | ❌ non couvert |
| **Plan technique min pour 1500 rpm × 10k/jour** | aucun palier public ≥ 1500 rpm (top 1200 rpm @ ~$249.99/mo) | Power suffit techniquement (~$30/mo, 100k/jour) | Starter suffit techniquement ($29/mo "unlimited") |
| **ToS proxy mutualisé pour clients tiers payants** | ⚠️ zone grise, pas de clause explicite, email business pour cas commercial | ❌ Power = "internal consumption only" → Commercial $500+/mo + redistribution license | ❌ Individuals ToS interdit explicitement → Business négocié obligatoire |
| **HTTP error model** | ⚠️ 200 OK + champ `Note`/`Information` | ⚠️ 200 OK + body non-JSON sur quota | ✅ HTTP 429 standard |
| **Profondeur historique daily** | 20+ ans | 30+ ans US | 5 ans Starter |
| **Free tier viable pour dev** | ✅ 25 req/jour, 5 req/min — OK pour valider l'intégration | ⚠️ 1000 req/jour mais ToS interdit le commercial sans Commercial plan | ⚠️ 5 req/min, EOD only, ToS interdit le commercial sans Business |
**Finding majeur** : la pré-désignation ADR 0011 « Tiingo Power ~10 $/mo » est obsolète sur le prix (réel 2026 ≈ $30/mo) ET invalide sur le ToS (Power = internal-use only). Notre cas d'usage cible (proxy multi-tenant) forcerait Tiingo en plan Commercial ($500+/mo) avec redistribution license négociée. Polygon idem (Business plan, prix non public). Alpha Vantage seul reste en zone grise sans interdiction explicite et offre un free tier exploitable pour valider l'intégration en dev.
## Phase 2 — Smoke test live Alpha Vantage (2026-05-07, free tier)
13 calls réels sur `https://www.alphavantage.co/query` avec une clé free tier. Réponses brutes archivées dans `~/.maximus-research-keys/raw-av.json` (à supprimer après validation de cet ADR).
| # | Test | Résultat | Verdict |
|---|---|---|---|
| 01 | `GLOBAL_QUOTE` AAPL | $287.44, day 2026-05-07 | Happy path ✅ |
| 02 | `TIME_SERIES_DAILY_ADJUSTED` AAPL | `Information` field — premium endpoint | ⚠️ **Adjusted close = premium $49.99/mo minimum** |
| 03 | `GLOBAL_QUOTE SHOP.TRT` | $152.57 CAD | TSX coverage confirmée ✅ |
| 04 | `GLOBAL_QUOTE RY.TRT` | $247.64 CAD | TSX big caps OK ✅ |
| 05 | **`GLOBAL_QUOTE SHOP.TO`** (Yahoo style) | **$152.57 — alias silencieux de `.TRT`** | 🎉 **Pas de mapping table requis** — drop-in Yahoo |
| 06 | `GLOBAL_QUOTE SHOP` (sans suffixe) | $111.74 — listing US différent | Suffixe nécessaire pour désambiguïsation CA vs US |
| 07 | `GLOBAL_QUOTE XYZAB123` (inconnu) | `"Global Quote": {}` (objet vide) | ⚠️ **Pas de `Error Message`** — détection = empty object |
| 08 | `GLOBAL_QUOTE SPX` | objet racine `{}` vide | ❌ Indices broad-market non couverts |
| 09 | `GLOBAL_QUOTE ^GSPC` | objet racine `{}` vide | ❌ Idem |
| 10 | `GLOBAL_QUOTE VTSAX` (mutual fund) | $176.23, day 2026-05-06 | Mutual funds OK ✅ |
| 11 | `GLOBAL_QUOTE BRK.B` (share class) | $475.08 | Format dot natif ✅ |
| 12 | `SYMBOL_SEARCH keywords=shopify` | 5 résultats incluant `SHOP.TRT` Toronto | Convention `.TRT` confirmée par AV |
| 13 | `TIME_SERIES_DAILY_ADJUSTED` AAPL outputsize=full | `Information` field — premium endpoint | ⚠️ Premium-gate global sur l'historique ajusté |
**Headers HTTP** : `Retry-After` absent sur les 13 réponses. Toutes en HTTP 200 (confirme la doc — pas de 4xx propres). Content-Type `application/json` partout.
## Decision
**Override partiel de l'ADR 0011 — uniquement le provider de bascule désigné** :
- L'ADR 0011 reste en vigueur sur tout le reste : Yahoo best-effort en prod, garde-fous (badge UX, circuit breaker, quota 200 req/jour/licence, saisie manuelle toujours active), triggers de migration inchangés.
- **Le provider de bascule désigné passe de Tiingo Power à Alpha Vantage Premium $49.99/mo (75 rpm)** quand un trigger ADR 0011 se déclenchera réellement.
- **Aucune bascule immédiate.** Yahoo best-effort reste en prod tant qu'aucun trigger réel (1ère licence payée, plainte client formelle, saturation des incidents) ne justifie le cash burn.
### Pourquoi Alpha Vantage plutôt que Tiingo (pré-désignation 0011)
1. **Tiingo Power est invalide pour notre cas** : "internal consumption only" dans le ToS. Notre proxy multi-tenant force Tiingo en plan Commercial ~$500/mo + redistribution license — ~10× plus cher que la pré-désignation 0011 ($10/mo). Le rapport coût/bénéfice supposé par 0011 ne tient plus.
2. **Alpha Vantage Premium $49.99/mo** est le palier le moins cher qui (a) couvre le besoin technique avec marge (75 rpm × 1440 = ~108k req/jour, vs cible 10k/jour), (b) inclut `TIME_SERIES_DAILY_ADJUSTED` confirmé empiriquement comme premium-gate, (c) couvre le TSX nativement.
3. **Drop-in Yahoo via `.TO` natif** — découverte Phase 2 majeure : AV accepte le suffixe Yahoo `.TO` silencieusement comme alias de `.TRT`. **Aucune mapping table à coder.** Le code `yahooProvider.ts` existant peut être copié quasi-tel-quel en `alphaVantageProvider.ts`. C'est l'argument décisif pour la bascule rapide quand elle sera déclenchée — délai d'implémentation ~quelques heures, pas quelques jours.
4. **ToS en zone grise = négociable au moment voulu** : pas d'interdiction explicite (vs Polygon Individuals qui interdit, vs Tiingo Power qui interdit, vs Yahoo qui interdit). Email à `support@alphavantage.co` peut être envoyé au moment du déclenchement, pas avant.
### Polygon écarté
Polygon est techniquement supérieur (data quality, "unlimited" rpm sur Starter $29) mais **disqualifié seul par l'absence de couverture TSX**. Une stratégie hybride Polygon US + AV CA serait plus complexe et plus chère pour un bénéfice marginal vs AV seul.
### État du free tier AV (statu quo dev)
La clé free tier obtenue pendant l'évaluation reste active pour :
- **Dev / smoke test continu** : valider l'intégration en local avant tout déploiement payant.
- **Smoke test périodique de la cible de bascule** : 1× par mois, 5-10 calls pour vérifier que la convention `.TO` fonctionne toujours, que les premium-gates n'ont pas changé, que `support@alphavantage.co` n'a pas resserré le free tier.
La clé reste hors-Coolify (jamais déployée en prod), dans `~/.maximus-research-keys/av.txt` côté machine de dev. **À ne pas confondre avec un déploiement de prod** — le free tier 25 req/jour est 400× insuffisant pour servir 50 licences réelles.
## Plan de bascule (déclenché par trigger ADR 0011, pas maintenant)
Quand un trigger réel se déclenchera :
1. **Décision business** : confirmer que la bascule est justifiée vs amender ADR 0011 (rester sur Yahoo + mitigations alternatives).
2. **Email ToS** à `support@alphavantage.co` (draft inclus en annexe ci-dessous) — à envoyer **à ce moment-là**, pas maintenant. Délai de réponse standard 3-5 j ouvrables. Use case précisé : "server-side proxy serving N paying B2B licensees".
3. **Sur réponse positive** : signup Premium $49.99/mo (75 rpm). Clé en var Coolify `ALPHAVANTAGE_API_KEY` (secret). Issue follow-up `feat(api): integrer Alpha Vantage comme provider stocks` créée à ce moment-là.
4. **Sur réponse négative ou zone grise prolongée** : amender cet ADR pour retomber sur Tiingo Commercial ($500+/mo) — financièrement justifiable seulement si l'audience est suffisamment monétisée pour absorber le coût.
5. **Implémentation variante A** (remplacement direct, switch via env var `STOCKS_PROVIDER=alphavantage`). Yahoo retiré du code dans une seconde PR séquencée pour rollback rapide.
6. **Smoke test prod** : `?symbol=AAPL` et `?symbol=SHOP.TO` doivent renvoyer 200 + prix non-stale.
7. **Bascule + monitoring 7 jours**.
8. **Cleanup** : `~/.maximus-research-keys/` supprimé.
## Garde-fous obligatoires (mirror ADR 0011, applicables à AV au moment de la bascule)
1. **Label UX inchangé** : badge "best-effort" reste sur les catégories stocks. AV est plus stable que Yahoo mais reste un provider tiers — pas de SLA contractuel pour notre cas d'usage à $49.99/mo.
2. **Circuit breaker côté maximus-api** : seuil identique 5 erreurs AV / 60 sec → breaker ouvert 15 min. Notification Telegram/email à `maxime2tremblay@protonmail.com`.
3. **Quota côté maximus-api** : 200 req/jour/licence — inchangé. Avec 75 rpm × 1440 min = 108 000 req/jour de capacité, marge ample pour 50 licences.
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
5. **Headers stripping rigoureux** : tous les headers entrants supprimés. Vers AV : UA `maximus-api/<version>` (pas besoin de UA browser-like contrairement à Yahoo). Auth via query string `?apikey=` (limitation AV — pas de header support).
6. **Logs** : URL avec `?apikey=` masquée dans les logs Coolify/Traefik via une regex de filtre côté pino logger.
## Parsing défensif (issu des findings Phase 2 — applicable au moment de l'implémentation)
Le module `alphaVantageProvider.ts` doit gérer **5 cas distincts** sur HTTP 200 :
```typescript
// 1. Happy path — body['Global Quote'] populated
if (body['Global Quote'] && Object.keys(body['Global Quote']).length > 0) { /* ok */ }
// 2. Symbol unknown — body['Global Quote'] = {} empty object
else if (body['Global Quote'] && Object.keys(body['Global Quote']).length === 0) { /* symbol_not_found */ }
// 3. Premium endpoint blocked — body['Information'] field with subscribe message
else if (body['Information']) { /* premium_required or rate_limit */ }
// 4. Error — body['Error Message'] field (param malformed)
else if (body['Error Message']) { /* invalid_request */ }
// 5. Rate limit hit — body['Note'] field (legacy free tier message)
else if (body['Note']) { /* rate_limit */ }
```
Pas de fallback sur HTTP status (toujours 200). Le code de parsing yahoo existant ne couvre pas ces cas — adaptation requise dans la PR follow-up déclenchée par la bascule.
## Consequences
### Positives
- **0 $ de cash burn maintenu** — l'esprit MVP de l'ADR 0011 est préservé. Pas de bascule prématurée à un provider payant.
- **Cible de bascule validée empiriquement**`.TO` natif, TSX confirmé, mutual funds OK, format API simple. Au moment du trigger, la bascule prendra des heures, pas des jours.
- **Override propre de la pré-désignation Tiingo** — la décision 0011 ne s'auto-déclenche pas mécaniquement vers un provider mal calibré.
- **Email ToS reporté** — pas d'effort gaspillé tant qu'il n'y a pas d'enjeu réel.
### Négatives / risques actés
- **Yahoo reste cassé en prod** — feature stocks non-fonctionnel jusqu'au déclenchement du trigger ou résolution Yahoo (improbable). Acceptable tant qu'aucun client payant ne se plaint.
- **Adjusted close = premium endpoint** chez AV : confirmé empiriquement. Au moment de la bascule, le tier Premium $49.99 sera nécessaire (pas de chemin gratuit pour `TIME_SERIES_DAILY_ADJUSTED`).
- **Indices broad-market non couverts via `GLOBAL_QUOTE`** : SPX, ^GSPC, GSPTSE retournent objet vide. Hors-scope tant que la roadmap ne demande pas d'indices ; sinon ETF proxy (`SPY`, `XIC.TO`).
- **HTTP 200 sur toutes les erreurs** : parsing fragile, code défensif obligatoire (5 cas distincts à gérer).
- **Pas de `Retry-After` natif** : exponential backoff côté client requis sur détection de `Note`/`Information`.
- **Auth `?apikey=` query string uniquement** : leak risk dans les logs Coolify/Traefik. Mitigation = regex de masking pino.
- **ToS en zone grise jusqu'à confirmation écrite future** : risque de suspension de clé sans préavis si AV détecte un pattern multi-tenant. Probabilité faible à petite échelle, à monitorer.
- **Profondeur smallcaps TSXV non validée** : risque sur ~5-10 % des positions clients (à mitiger en Phase 4 par smoke test sur échantillon représentatif des holdings réels).
- **Free tier érodé historiquement** (500 → 100 → 25 req/jour) : signal qu'AV peut resserrer aussi les paid tiers à terme. Risque budget sur 12-24 mois.
### Neutre
- Le code reste `src/services/providers/<provider>Provider.ts` parallèle, switch via env var. Pattern déjà en place pour Yahoo, extension triviale.
## Triggers de bascule (rappel ADR 0011, inchangés)
- 1+ incidents IP-ban Yahoo / mois pendant 2 mois consécutifs, OU
- 30 %+ requêtes stocks tombent en `service_degraded` sur 7 jours, OU
- Plainte légale formelle de Yahoo / Verizon Media, OU
- (nouveau) 1ère licence payée active OU 1ère plainte client formelle sur le feature stocks.
Le 4ème trigger est ajouté pour aligner la décision avec la réalité business : tant qu'il n'y a pas de licence payée ou de plainte active, le 0 $ recurring l'emporte sur le service-quality.
## Suivi
- ADR à reviewer dans 6 mois (2026-11-07) ou plus tôt si :
- Trigger de bascule déclenché (réévaluer Yahoo vs AV en fonction du contexte du moment),
- AV resserre le free tier au point que le smoke test mensuel devient impossible,
- Yahoo redevient stable (improbable mais possible).
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`, `paid_licenses_active_count`.
- Smoke test mensuel AV free tier : entrée TODO dans le calendrier ou cron `claude` skill.
## Annexe — Draft email à `support@alphavantage.co` (à envoyer au moment de la bascule, pas maintenant)
```
Subject: Commercial use authorization — server-side proxy for ~N paying B2B licensees
Hello Alpha Vantage support team,
I am evaluating Alpha Vantage Premium for a small B2B SaaS use case and would like
to confirm the licensing model in writing before subscribing.
Use case:
- Server-side proxy (single VPS, single API key) on behalf of ~N paying B2B licensees
- Delayed/EOD US equities (NYSE/NASDAQ/AMEX) and Canadian equities (TSX via .TRT/.TO)
- Mutual funds (limited)
- ~Y 000 requests/day total, ~Z req/min peak
- No client-side redistribution: only the licensee's own portfolio holdings are
fetched, and the response data is consumed by the licensee's own desktop app
(no public-facing data feed, no resale to non-licensees)
Question:
1. Is this use case authorized under the standard Premium ToS, or do we need
a custom commercial agreement / data onboarding process?
2. Which tier would you recommend for the volume above?
3. Are there any per-end-user fees or exchange data fees I should be aware of
for delayed/EOD US + Canadian equities?
I would prefer to have written confirmation before subscribing, to comply with
my own internal documentation requirements (the ToS confirmation will be filed
as part of an internal architecture decision record).
Thanks for your help,
Maxime Tremblay
maxime2tremblay@protonmail.com
```
Réponse écrite à archiver dans `simpl-resultat/docs/adr/0013-attachments/alphavantage-tos-confirmation-YYYY-MM-DD.txt` une fois reçue.
## References
- [Alpha Vantage — API Documentation](https://www.alphavantage.co/documentation/)
- [Alpha Vantage — Premium API Key (pricing)](https://www.alphavantage.co/premium/)
- [Alpha Vantage — Terms of Service](https://www.alphavantage.co/terms_of_service/)
- [Alpha Vantage — Customer Support](https://www.alphavantage.co/support/)
- [Macroption — Alpha Vantage Symbols (suffixes)](https://www.macroption.com/alpha-vantage-symbols/)
- ADR 0009 — Architecture du proxy
- ADR 0011 — Providers best-effort Yahoo (override partiel sur le provider de bascule désigné)
- `maximus-api/docs/research/0013-stocks-providers-phase1.md` — Synthèse 3-way Phase 1
- `~/.maximus-research-keys/raw-av.json` — Réponses brutes Phase 2 smoke test (à supprimer après merge)
- Issue Forgejo `maximus-api#41`

598
docs/api-contract-prices.md Normal file
View file

@ -0,0 +1,598 @@
# Contrat API — `GET /v1/prices`
> **Statut** : Draft v2 (2026-04-26) — décisions de revue intégrées, prêt pour gel après création des issues
> **Producteurs** : `maximus-api` (serveur Hono/Node.js sur VPS OVH)
> **Consommateurs** : `simpl-resultat` (client desktop Tauri)
> **Fichiers de référence (mirror)** :
> - `simpl-resultat/docs/api-contract-prices.md` (ce fichier — source de vérité)
> - `maximus-api/docs/api-contract-prices.md` (copie identique, à synchroniser à chaque modification)
Ce document fige la surface d'API entre le client desktop premium et le proxy de récupération de prix de `maximus-api`. Le but : permettre au client et au serveur d'être développés et testés en parallèle, sans dépendance temporelle, contre des mocks conformes.
## 0. Décisions de revue (2026-04-26)
Synthèse des décisions issues de la revue multi-expert (cf. issue #143 et ce fichier annoté). Ces décisions sont **gelées** et impactent toutes les sections ci-dessous.
| Décision | Choix | Section impactée |
|----------|-------|------------------|
| Providers de prix | **Crypto via exchanges directs (Kraken, Coinbase via CCXT, OSS-légal, gratuit) + Stocks via Yahoo Finance en best-effort assumé** (gratuit, ToS risque acté dans ADR 0011) | §8 |
| Coût mensuel | 0 $ initial. Migration vers Tiingo (~10 $/mo) ou Polygon (~29 $/mo) si Yahoo devient inutilisable (cf. ADR 0011) | §8, §11 |
| Rate-limit infra | **Postgres token-bucket atomique** (`INSERT ... ON CONFLICT DO UPDATE` + `pg_advisory_xact_lock`). Pas de Redis. | §6.1 |
| Versioning + enveloppe | **Migration globale vers `/v1/` + enveloppe nestée `{error: {code, message, retry_after}}`**. `/licenses/*` deviennent aliases deprecated 30 jours pointant sur `/v1/licenses/*`. | §2, §5, §11 |
| Quota par licence | **30/min, 200/jour** (revu à la baisse depuis 2000/jour : Yahoo est gratuit mais fragile, et 200 suffit pour ~50 actifs × snapshot mensuel) | §6.1 |
| Justification du gating premium | Le client premium paie le **proxy d'anonymisation et l'infrastructure**, pas la donnée (qui est gratuite/best-effort). Cohérent avec privacy-first. | §1, ADR 0011 |
| `product` claim binding | Middleware valide `claims.product === 'simpl-resultat'`. | §7.1 |
| Lib mocks | Client TS : `vi.fn()` sur `fetch`. Serveur outbound : `nock`. Serveur inbound : `app.request()` natif Hono. | §12 |
## 1. Objectif fonctionnel
Permettre au client desktop d'obtenir le prix d'un actif coté (action ou crypto) à une date donnée, sans exposer l'identité ni l'IP de l'utilisateur aux fournisseurs de données. Le proxy `maximus-api` fait écran et mutualise les appels.
**Le client premium paie pour l'infrastructure d'anonymisation et de proxy, pas pour la donnée elle-même.** La donnée est gratuite (crypto via exchanges publics, stocks via Yahoo en best-effort). Cette distinction est centrale au modèle économique et préserve la cohérence privacy-first.
**UX explicite — feature stocks en best-effort** : pour les catégories de bilan en stocks (actions cotées), le bouton de fetch affiche un label « best-effort » + warning au premier usage : « Source non garantie, peut être indisponible. La saisie manuelle reste prioritaire et toujours active. » Pas de warning pour crypto (provider stable).
**Ce qui n'est PAS dans cet endpoint** :
- Recherche / autocomplete de symboles (hors scope MVP)
- Historique sur intervalle (un seul couple `(symbol, date)` par requête)
- Conversion de devise
## 2. Endpoint
```
GET /v1/prices?symbol=<symbol>&date=<YYYY-MM-DD>
```
- **Méthode** : `GET` (idempotent, cacheable au sens HTTP — mais voir §10 pour les contraintes côté client)
- **Base URL prod** : `https://api.lacompagniemaximus.com`
- **Base URL dev** : configurable côté client (`MAXIMUS_API_URL` ou équivalent)
- **Versioning** : préfixe `/v1/`. Toute modification non rétrocompatible passe par `/v2/`. Au sein de `/v1/`, seuls les ajouts de champs sont autorisés.
> **🔴 ARCHITECTURE** — Asymétrie de versioning `/v1/prices` vs `/licenses/*`.
> Introduire `/v1/` sur prices alors que `/licenses/*` reste non-versionné crée une surface mixte. La politique §11 ne s'applique alors qu'à un endpoint.
> **Resolution :** Trancher avant gel : soit renommer en `/v1/licenses/*` (avec alias durant deprecation window), soit retirer le préfixe `/v1` sur prices. À acter dans l'ADR 0009.
### 2.1 Paramètres de requête
| Param | Type | Format | Validation | Obligatoire |
|-------|------|--------|------------|-------------|
| `symbol` | string | alphanum + `.` + `-`, 1-20 chars, case-insensitive (normalisé en MAJUSCULES côté serveur) | regex `^[A-Za-z0-9.\-]{1,20}$` | oui |
| `date` | string | ISO 8601 `YYYY-MM-DD` | doit être ≥ `1970-01-01` et ≤ aujourd'hui (UTC) | oui |
Toute autre query string est ignorée silencieusement (le serveur ne se base que sur ces deux paramètres).
> **🔴 ARCHITECTURE** — Binding manquant à la claim `product` du JWT.
> Tous les endpoints existants requièrent `product` (schéma multi-produit) et le JWT activation porte une claim `product`. `/v1/prices` ne la valide pas — un futur 2e produit pourrait hitter prices avec son propre token.
> **Resolution :** Valider `claims.product === 'simpl-resultat'` dans le middleware d'auth (extraction JWT, pas en query). Documenter en §7.1 (étape 5.5).
## 3. Headers de requête
### 3.1 Headers requis (client → maximus-api)
| Header | Valeur | Notes |
|--------|--------|-------|
| `Authorization` | `Bearer <activation_token>` | Token opaque côté client. Le serveur valide la signature Ed25519 + l'état de la licence en DB. |
| `Accept` | `application/json` | |
| `User-Agent` | `simpl-resultat` | **FIXE**, sans version, sans OS, sans architecture. |
> **🟢 SECURITE+TECHNIQUE** — User-Agent fixe empêche la dépréciation d'urgence des clients.
> Aucun moyen de refuser un build vulnérable connu (ex. version qui leak l'`activation_token` dans les logs). Pas de gate de version minimale possible.
> **Resolution :** Envoyer un header séparé `X-Client-Major: 0.x` (major+minor uniquement, pas patch/OS/arch) — préserve la k-anonymity et active les gates de dépréciation. Documenter le tradeoff de privacy.
### 3.2 Headers interdits (client → maximus-api)
Le client **ne doit jamais envoyer** :
- `Accept-Language`
- `X-Forwarded-For`, `X-Real-IP`, ou tout header `X-Forwarded-*`
- Cookies
- Aucun header personnalisé identifiant la machine
Cette règle est testée par un test unitaire côté client (« privacy headers test »). Le serveur tolère leur présence (ne les rejette pas par 400) mais les supprime avant tout traitement.
### 3.3 Comportement du serveur sur les headers entrants
Avant tout appel sortant vers Yahoo / CoinGecko, `maximus-api` strippe **tous** les headers entrants à l'exception de ceux qu'il génère lui-même. Garanti par contrat — vérifié par tests d'intégration côté serveur.
> **🟡 TECHNIQUE** — Headers injectés par l'infra (CF-*, Coolify, Traefik) non couverts par le test §12.2.
> Le proxy Traefik / Coolify ajoute des headers (`X-Forwarded-*`, `X-Real-IP`, etc.) entre le client et l'app — si l'app les propage par accident vers Yahoo/CoinGecko, la promesse §3.3 est cassée.
> **Resolution :** Utiliser un client HTTP nu (`fetch` natif Node, sans propagation d'headers) pour les appels sortants. Test : asserter que la liste de headers reçue par le mock provider == exactement `['user-agent', 'accept', 'host']`.
## 4. Réponse de succès (200 OK)
```json
{
"symbol": "AAPL",
"date": "2026-04-25",
"actual_date": "2026-04-24",
"price": 173.45,
"currency": "USD",
"source": "yahoo",
"fetched_at": "2026-04-25T14:32:11Z",
"cached": true
}
```
### 4.1 Sémantique des champs
| Champ | Type | Description |
|-------|------|-------------|
| `symbol` | string | Symbole tel que normalisé par le serveur (MAJUSCULES) |
| `date` | string | Date demandée (echo du paramètre) |
| `actual_date` | string \| null | Si la date demandée n'a pas de cotation (week-end, jour férié pour les actions), date effective de la cotation retournée. `null` si `actual_date == date`. |
| `price` | number | Prix de clôture pour les actions, prix instantané pour les crypto. Précision : 4 décimales pour les actions, 8 pour les crypto. |
| `currency` | string | ISO 4217 (3 lettres). Exemples : `USD`, `CAD`, `EUR`. |
| `source` | string | `yahoo` ou `coingecko`. Indicatif uniquement — le client ne doit pas faire de logique conditionnelle dessus. |
| `fetched_at` | string | ISO 8601 UTC du moment où le prix a été récupéré du provider (différent de `now` si servi depuis cache). |
| `cached` | boolean | `true` si servi depuis le cache serveur. Indicatif uniquement — n'affecte pas la fraîcheur garantie (cf. §10). |
### 4.2 Headers de réponse (succès)
| Header | Toujours présent | Description |
|--------|------------------|-------------|
| `Content-Type` | oui | `application/json; charset=utf-8` |
| `X-RateLimit-Limit` | oui | Quota total sur la fenêtre courante (entier) |
| `X-RateLimit-Remaining` | oui | Quota restant (entier) |
| `X-RateLimit-Reset` | oui | Unix timestamp (secondes) du reset de quota |
| `Cache-Control` | oui | `private, max-age=0` — le client **ne doit pas** mettre en cache HTTP |
## 5. Réponses d'erreur
### 5.1 Format d'enveloppe (toutes les 4xx/5xx)
```json
{
"error": {
"code": "premium_required",
"message": "Premium license required for price fetching",
"retry_after": 30
}
}
```
| Champ | Type | Description |
|-------|------|-------------|
| `error.code` | string | Code stable, lisible-machine. Snake_case. Liste fermée (cf. §5.2). |
| `error.message` | string | Message lisible-humain en anglais. **Ne pas afficher tel quel à l'utilisateur** — le client doit traduire en FR/EN via i18n à partir du `code`. |
| `error.retry_after` | number \| absent | Présent uniquement sur 429 et 503. Secondes à attendre avant retry. |
> **🔴 ARCHITECTURE** — Enveloppe d'erreur incohérente avec `/licenses/*`.
> Les routes existantes `/licenses/*` retournent un format plat `{ error: "string" }` (parfois avec `details` ou `machines`). Cette spec propose `{ error: { code, message, retry_after } }` — deux shapes dans la même app Hono force les clients à brancher par route.
> **Resolution :** Trancher : soit migrer `/licenses/*` vers la nouvelle enveloppe (versionner en `/v1/licenses/*`), soit aligner `/v1/prices` sur `{ error, code, retry_after }` plat. Documenter le choix dans le README maximus-api.
### 5.2 Codes d'erreur par status HTTP
| Status | `error.code` | Cause | Retry possible ? |
|--------|--------------|-------|------------------|
| **400 Bad Request** | `invalid_symbol` | `symbol` ne matche pas la regex | non |
| 400 | `invalid_date` | `date` mal formée, future, ou pré-1970 | non |
| 400 | `missing_param` | `symbol` ou `date` absent | non |
| **401 Unauthorized** | `missing_token` | Header `Authorization` absent | non |
| 401 | `invalid_token` | Signature Ed25519 invalide | non |
| 401 | `expired_token` | Token expiré | non — re-activation requise |
| **403 Forbidden** | `premium_required` | Licence valide mais `edition != 'premium'` | non — abonnement requis |
| 403 | `license_revoked` | Licence révoquée | non — contact support |
| **404 Not Found** | `symbol_not_found` | Le symbole est inconnu de tous les providers consultés | non |
| **429 Too Many Requests** | `rate_limit_exceeded` | Quota dépassé pour cette licence | oui — après `retry_after` secondes |
| **502 Bad Gateway** | `provider_unavailable` | Yahoo / CoinGecko a échoué (timeout, 5xx) | oui — backoff exponentiel |
| **503 Service Unavailable** | `service_degraded` | Maintenance ou panne interne | oui — après `retry_after` secondes |
| **500 Internal Server Error** | `internal_error` | Bug serveur. Loggé côté server. | oui — backoff |
Le serveur **ne doit jamais** retourner un code HTTP avec un body qui ne respecte pas l'enveloppe `{error: {code, message}}`. Aucune fuite de stack trace, aucun message d'erreur de provider non sanitisé.
## 6. Rate-limiting
### 6.1 Côté serveur
Le quota est appliqué **par licence** (clé = `hash(license_id)`).
| Tier | Fenêtre | Quota |
|------|---------|-------|
| premium | 1 minute glissante | 30 requêtes |
| premium | 1 jour glissant | 200 requêtes |
Le quota est partagé entre toutes les machines activées sur la même licence.
**Implémentation** : table Postgres `rate_limit_buckets(license_id PK, window_start TIMESTAMPTZ, count INT)` avec atomicité via `INSERT ... ON CONFLICT (license_id) DO UPDATE SET count = count + 1, window_start = CASE WHEN now() - window_start > '1 day' THEN now() ELSE window_start END RETURNING count`. Pour la fenêtre minute, idem en table séparée. Pas de Redis (cf. décision §0).
> **🔴 SECURITE+ARCHITECTURE** — Rate-limit in-memory ne peut pas garantir un quota par-licence.
> `maximus-api/src/middleware/rateLimit.ts` keye par IP via `Map` global (5 req/min, hardcoded). Le quota promis (30/min, 2000/jour, partagé entre machines) requiert un store atomique partagé : sur restart Coolify les compteurs se réinitialisent (refill gratuit), et toute scaling horizontale crée une race TOCTOU exploitable.
> **Resolution :** Avant que §6 ne quitte le draft : ajouter Redis OU token-bucket Postgres avec row-level lock. Généraliser `rateLimit.ts` en `{ keyFn, windows: [{ms,max}] }` pour réutiliser une même middleware avec deux configs (IP pour licenses, license-id pour prices). Documenter le backend.
> *Ref : OWASP API4:2023 — Unrestricted Resource Consumption*
> **🔴 TECHNIQUE** — Quota 2000/jour × N licences incompatible avec free tier CoinGecko (30/min).
> Le free tier CoinGecko Demo plafonne à 30 calls/min ; promettre 2000 req/j × N premium dépasse ce plafond dès quelques utilisateurs simultanés. Le quota promis n'est pas adossé à une capacité provider.
> **Resolution :** Soit prévoir CoinGecko Analyst payant (~$129/mois, 500 calls/min) et le mentionner dans la section coûts, soit baisser le quota par licence (ex. 200/j) et le justifier. Test interne que le quota provider est respecté en interne.
### 6.2 Côté client
Le client implémente **en plus** un rate-limit local pour éviter de gaspiller le quota serveur :
- Max 1 requête sortante toutes les 2 secondes
- Déduplication des requêtes en vol identiques (mêmes `symbol` + `date` → une seule requête réseau, plusieurs awaiters)
- Plafond hard : 100 requêtes par session de saisie de snapshot (anti-loop)
Ces limites sont des défenses en profondeur — le contrat ne dépend pas de leur valeur exacte.
> **🟡 TECHNIQUE** — Test "1 req / 2s" requiert fake timers + emplacement du rate-limiter non figé.
> Le test `it("respecte le rate-limit local 1 req / 2s")` est temporel et flaky sans `vi.useFakeTimers()`. Le contrat ne dit pas où implémenter le rate-limiter (hook ? service ?), ce qui laisse une décision d'archi ouverte.
> **Resolution :** Préciser §6.2 : « rate-limit implémenté dans `src/services/priceService.ts` (ou dans `balance.service.ts` section prices). Tests vitest avec `vi.useFakeTimers()`. » Ajouter le service au CLAUDE.md avant gel.
## 7. Authentification et autorisation — détail
### 7.1 Validation côté serveur (ordre)
Implémentée comme middleware partagée `src/middleware/licenseAuth.ts` (analogue à `adminAuth.ts`), réutilisable pour `/v1/quota` et autres endpoints premium futurs.
1. Header `Authorization` présent → sinon 401 `missing_token`
2. Format `Bearer <token>` correct → sinon 401 `invalid_token`
3. Signature Ed25519 valide (clé publique embarquée côté serveur) → sinon 401 `invalid_token`
4. Token non expiré (`exp` claim) → sinon 401 `expired_token`
5. **Claim `product` du JWT = `'simpl-resultat'`** → sinon 401 `invalid_token` (un futur 2e produit aura sa propre clé ou son propre claim ; pas de cross-product)
6. Licence en DB existe et `is_revoked = false` → sinon 403 `license_revoked`
7. Licence `edition = 'premium'` → sinon 403 `premium_required`
8. **Aucun appel provider tant que ces 7 étapes n'ont pas réussi.** Cette règle est testée côté serveur.
La middleware populate `c.set('license', { id, edition, product })` pour les handlers downstream.
> **🟡 SECURITE** — `activation_token` sans `jti`, durée 2 ans → fenêtre de replay non bornée.
> Tokens signés Ed25519 avec `exp` ~2 ans, sans `jti` (vérifié dans `licenseService.ts`). Un token leaké (compromission machine, slip de log, exfil malware) donne un accès premium pendant ~2 ans sans path de révocation hors révocation de la licence entière. `/v1/prices` envoie ce token à chaque appel — multiplie la surface d'exfil.
> **Resolution :** (a) ajouter `jti` + revocation list Redis-backed checkée à chaque `/v1/prices`, OU (b) raccourcir le TTL à 7-14 jours avec refresh-token silencieux, OU (c) bind du token au canal TLS via DPoP. Choix à acter en §7.
> *Ref : CWE-294 (Authentication Bypass by Capture-Replay), OWASP API2:2023*
> **🟡 ARCHITECTURE** — Auth + premium check doit être une middleware partagée.
> Étapes 1-6 = concern auth/authz autonome. Inliner dans le handler prices = duplication quand `/v1/quota` ou autres endpoints premium arriveront.
> **Resolution :** Ajouter `src/middleware/licenseAuth.ts` (analogue à `adminAuth.ts`) qui populate `c.set('license', ...)`. Référencer cette middleware par nom dans §7.1.
### 7.2 Le champ `edition` côté licence
Le serveur expose **déjà** `edition` dans la réponse de `POST /v1/licenses/verify` (depuis maximus-api Phase 1, cf. `licenseService.ts:216`). Valeurs possibles : `"base"` | `"premium"`. Aucun travail backend additionnel sur ce champ. Le client lit ce champ pour afficher / cacher conditionnellement le bouton de price-fetching dans l'UI — mais cette vérif n'est qu'**ergonomique**, jamais un substitut au check serveur.
> **🟡 ARCHITECTURE** — `edition` est déjà exposé par `/licenses/verify` (depuis Phase 1).
> La référence à une « issue maximus-api dédiée à ajouter » est obsolète : `licenseService.ts:216` retourne déjà `edition` dans la réponse verify.
> **Resolution :** Mettre à jour §7.2 : « `edition` est déjà exposé par `/licenses/verify` depuis maximus-api Phase 1. Aucun travail backend nécessaire pour ce champ. »
## 8. Comportement de proxying (côté serveur)
### 8.1 Routage par type de symbole
`maximus-api` détermine le provider en interne :
- **Crypto** : symbole matche le catalogue crypto connu (BTC, ETH, SOL, ADA, DOT, etc. ou suffixe `-USD`/`-USDT`) → exchanges directs via la lib `ccxt`. Tente Kraken d'abord, fallback Coinbase si Kraken 404. Données de marché publiques, ToS-clean, gratuit.
- **Stocks** : tout le reste → Yahoo Finance en best-effort assumé (cf. ADR 0011). Endpoint `query1.finance.yahoo.com/v7/finance/quote` ou `v8/finance/chart`. **Best-effort** : peut échouer / bouger sans préavis.
Si crypto 404 sur Kraken ET Coinbase, retourne `404 symbol_not_found`. Si Yahoo 404 ou indisponible, retourne `404 symbol_not_found` ou `503 service_degraded` (cf. circuit breaker §8.4). Le client doit utiliser la saisie manuelle dans ces cas.
**Pas de fallback cross-asset** : un symbole inconnu de la liste crypto n'est pas réessayé sur Yahoo (et vice-versa). Le client est explicite.
> **🔴 SECURITE+TECHNIQUE** — Yahoo Finance n'a pas d'API publique stable et son ToS interdit la redistribution.
> Endpoints `query1/query2.finance.yahoo.com` non documentés, sujets à blocage IP/CAPTCHA. ToS interdit l'usage commercial et la redistribution. Proxying tous les premium via une IP VPS = kill-switch unique pour le feature payant + risque légal.
> **Resolution :** Soit (a) souscrire à un fournisseur licencié payant (Polygon, Alpha Vantage, Twelve Data, Finnhub) avec droit contractuel de proxy, soit (b) flagger explicitement `source: 'yahoo'` comme best-effort + circuit breaker + fallback provider documenté. Acter dans un ADR avant ship en payant.
> *Ref : Yahoo Finance ToS sec. 7-8 (no commercial reuse)*
> **🔴 SECURITE** — Free tier CoinGecko interdit l'usage commercial / proxying.
> CoinGecko Demo gratuit interdit explicitement le commercial use et le proxy/redistribution ; seul le plan Demo/Pro payant avec API key le permet. Feature premium-payant sur free tier = breach ToS + risque de cutoff soudain.
> **Resolution :** Souscrire à CoinGecko Demo/Pro (API key en env), documenter le tier contractuel en §8, ajouter le header API-key serveur. Refléter le coût dans le pricing model.
> *Ref : CoinGecko ToS — Free Plan restrictions*
### 8.2 Headers sortants vers le provider
**Vers les exchanges crypto (Kraken, Coinbase via CCXT)** :
- `User-Agent: maximus-api/<version>` (version interne, jamais transmise au client)
- `Accept: application/json`
**Vers Yahoo Finance (stocks, best-effort)** : Yahoo bloque les requêtes sans User-Agent navigateur. Le serveur envoie un UA browser-like (`Mozilla/5.0 (X11; Linux x86_64) ... Chrome/...`) — c'est une exception explicite à la règle « UA fixe maximus-api ». Documenté dans ADR 0011.
**Garanties communes** : aucun header issu de la requête entrante n'est répercuté vers les providers. Implémentation via un client HTTP nu (`fetch` natif Node sans propagation), validée par le test §12.2.
### 8.3 IP source du provider
L'IP source vue par les providers est celle du VPS Maximus, mutualisée pour tous les utilisateurs premium. Cette propriété est garantie par la topologie réseau (pas de NAT transparent, pas de proxy SSL inverse vers les providers).
### 8.4 Circuit breaker (Yahoo best-effort)
Yahoo Finance étant un provider best-effort sans API officielle, un circuit breaker est obligatoire :
- **Compteur d'erreurs** : sur les 60 dernières secondes, si `count(5xx | 403 | timeout) >= 5`, le breaker s'ouvre.
- **État ouvert** : pendant 15 minutes, toutes les requêtes stocks retournent immédiatement `503 service_degraded` avec `retry_after: <secondes restantes>`. Aucun appel sortant Yahoo.
- **Half-open** : après 15 min, une seule requête tentée. Si succès, breaker fermé ; si échec, ouvert pour 15 min de plus.
- **Notification** : à l'ouverture du breaker, log structuré `level=warn` + (optionnel) webhook Telegram / email à `maxime2tremblay@protonmail.com`.
Crypto via CCXT n'a pas de circuit breaker dédié — les exchanges sont stables, leurs erreurs sont rares et déterministes.
> **🔴 SECURITE** — Single point of failure : un IP block VPS coupe le feature pour TOUS les premium.
> Mutualiser l'IP VPS est la promesse privacy, mais une seule sanction de Yahoo (qui voit du trafic commercial) bloque l'IP — kill-switch global qui touche 100% des utilisateurs payants en même temps.
> **Resolution :** Documenter dans un ADR le risque + budget pour un fallback provider rotatif. Ajouter un circuit breaker côté maximus-api : sur 5xx ou 403 répétés du provider, marquer automatiquement le service degraded et notifier (Telegram/email).
## 9. Garanties de logging et de privacy
Le serveur garantit par contrat :
1. **Pas de log conjoint** `(IP utilisateur, symbol)`. Les logs d'accès Traefik conservent les IP, mais le log applicatif des prix utilise `hash(license_id, salt_serveur)` à la place de toute info utilisateur.
2. **Pas de log de l'`activation_token` complet**. Seulement le `license_id` extrait du payload après validation de signature.
3. **Le cache prix** ne stocke aucune référence utilisateur — clé = `(symbol, date)`, valeur = `(price, currency, source, fetched_at)`.
4. **Aucun analytics, aucune télémétrie** sur `/v1/prices`. Seuls les logs minimaux d'observabilité.
> **🟡 SECURITE** — Logs Traefik (IP) + log applicatif (license_hash, symbol) → corrélation par timestamp casse §9.1.
> §9.1 promet « pas de log conjoint (IP, symbol) », mais Traefik enregistre `(IP, ts, path?querystring)` et l'app enregistre `(license_hash, symbol, ts)`. Quiconque a accès aux deux logs (ou un backup co-localisé) corrèle par timestamp et reconstitue `(IP, symbol)`. La garantie privacy est structurellement plus faible qu'annoncée.
> **Resolution :** (a) configurer Traefik pour stripper la querystring sur `/v1/prices`, OU (b) ne pas logger `/v1/prices` du tout côté Traefik (rely sur log app + stats privacy-preserving). Mettre à jour §9.1 pour refléter la garantie réelle.
> *Ref : CWE-532 (Insertion of Sensitive Info into Log)*
## 10. Sémantique de cache
### 10.1 Cache serveur
| Type de date | TTL |
|--------------|-----|
| Date passée (< aujourd'hui UTC) | 90 jours (les prix passés sont immuables, mais TTL fini = défense LRU) |
| Aujourd'hui (UTC) | 5 minutes |
| Réponse 404 `symbol_not_found` | 1 heure (TTL court séparé pour éviter pollution) |
**Implémentation** : table Drizzle `pricesCache` dans la même DB Postgres que les licenses (cf. décision §0).
```typescript
// src/db/schema.ts
export const pricesCache = pgTable("prices_cache", {
symbol: text("symbol").notNull(),
date: text("date").notNull(), // YYYY-MM-DD
price: numeric("price", { precision: 20, scale: 8 }), // null si 404
currency: text("currency"),
source: text("source").notNull(), // 'yahoo' | 'kraken' | 'coinbase'
fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
}, (t) => [primaryKey({ columns: [t.symbol, t.date] })]);
```
Pas de FK vers `licenses` (privacy). Job nightly cleanup `DELETE FROM prices_cache WHERE expires_at < now()`.
L'invalidation manuelle du cache n'est **pas** exposée par l'API.
**Défenses anti-pollution** :
- Cap LRU implicite via TTL fini (90 jours max sur les passées)
- 404s en TTL court (1h) sur table séparée pour éviter qu'un attaquant fill l'espace clé avec des symboles inexistants
- Idéalement (optimisation v1.1) : allowlist de symboles connus refresh quotidiennement (catalogue Yahoo + crypto exchanges) — reject les inconnus en 404 avant tout appel provider
> **🟡 ARCHITECTURE+TECHNIQUE** — Emplacement du cache non spécifié + maximus-api utilise Postgres (pas SQLite).
> §10 dit « cache local SQLite » dans l'ADR 0009 mais maximus-api est sur Postgres+Drizzle, pas SQLite. Pas de Redis non plus. Sans précision, soit on stocke en mémoire (perdu au restart Coolify), soit on persiste sans plan.
> **Resolution :** Ajouter §10.3 : « Cache implémenté via table Drizzle `prices_cache(symbol, date PK, price, currency, source, fetched_at, expires_at)` dans la même DB Postgres. Pas de FK à licenses (privacy). » Migration Drizzle nouvelle. Test d'intégration `it('persiste à travers un restart')`.
> **🟡 SECURITE** — Cache illimité avec TTL infini + regex symbole permissive → pollution de l'espace clé.
> TTL « infini » sur dates passées + regex `^[A-Za-z0-9.\-]{1,20}$` sans allowlist. Une licence premium compromise (ou plusieurs en parallèle) peut itérer ~36^20 symboles pour saturer le cache OU brûler le quota provider. Plusieurs machines par licence partagent le quota — abus coordonné dans le quota légal possible.
> **Resolution :** Cap LRU sur la taille du cache. 404s cachés en TTL court (1h max) avec LRU séparé. Idéalement : allowlist de symboles connus (catalogue Yahoo + CoinGecko refresh quotidien) — reject les inconnus en 404 avant tout appel provider.
> *Ref : CWE-770 (Allocation of Resources Without Limits)*
### 10.2 Cache client
Le client **ne doit pas** mettre en cache les réponses au-delà de la session courante :
- Pas de stockage SQLite des prix retournés
- Pas de `localStorage` ni `IndexedDB`
- Le `value` calculé (`quantity × unit_price`) **est** stocké dans `balance_snapshot_lines` — c'est une valeur dérivée denormalisée, pas un cache de l'API
- En mémoire de la session : OK pour la déduplication in-flight (cf. §6.2)
Cette contrainte protège contre l'extraction d'historique de consultation en cas de compromission de la machine cliente.
## 11. Versioning et évolutions
### 11.1 Changements rétrocompatibles autorisés en `/v1/`
- Ajout d'un champ optionnel dans la réponse de succès
- Ajout d'un nouveau code d'erreur (le client doit avoir un fallback sur les codes inconnus)
- Ajout d'un header optionnel
- Élargissement du quota
### 11.2 Changements non rétrocompatibles → `/v2/`
- Renommage / suppression d'un champ
- Changement du format d'enveloppe d'erreur
- Changement du format d'auth
- Restriction des paramètres acceptés
### 11.3 Coexistence
`maximus-api` peut servir simultanément `/v1/` et `/v2/` pendant une période de transition. Le client signale sa version par le path, pas par un header.
### 11.5 Migration `/licenses/*``/v1/licenses/*` (en parallèle de cette spec)
Pour cohérence avec `/v1/prices`, les endpoints existants `/licenses/*` migrent vers `/v1/licenses/*` :
- **Phase 1** (avec ce milestone) : nouveau préfixe `/v1/licenses/*` exposé. Anciens `/licenses/*` deviennent aliases qui renvoient `308 Permanent Redirect` vers `/v1/licenses/*`. Le client desktop continue à fonctionner sans modification.
- **Phase 2** (release simpl-resultat suivante, +30 jours) : le client desktop migre ses appels vers `/v1/licenses/*` directement. Header de réponse `Deprecation` (RFC 8594) sur les anciens paths.
- **Phase 3** (+60 jours) : les anciens paths retournent `410 Gone`. Suppression du code legacy.
L'enveloppe d'erreur de `/v1/licenses/*` est aussi migrée vers `{error: {code, message}}` nesté pour cohérence. Mapping des erreurs existantes vers les codes nouveaux est documenté dans le README maximus-api.
### 11.4 Rotation de clé Ed25519 (cross-cutting)
> **🟡 SECURITE** — Pas de header `kid` → la prochaine rotation Ed25519 force un redeploy complet.
> Le client Rust embed un PEM unique en constante (`license_commands.rs:28`) et le serveur signe sans `kid` dans le header JWT (`crypto/ed25519.ts setProtectedHeader`). La rotation 2026-04-25 (#49) a marché parce qu'aucune licence active n'existait ; la prochaine cassera toutes les licences actives jusqu'à update client. Pas de fenêtre d'overlap.
> **Resolution :** Ajouter `kid` au header JWT protégé. Ship le client Rust avec une map `kid → PEM`. Documenter une procédure de rotation avec fenêtre overlap 30 jours dans un nouvel ADR.
> *Ref : RFC 7515 §4.1.4 (kid header)*
## 12. Tests de conformité (références)
### 12.1 Côté client (`simpl-resultat`)
Tests unitaires obligatoires (`vitest` + `vi.fn()` sur `fetch` natif — pas de lib de mock externe ajoutée) :
- `it("envoie uniquement Authorization, Accept, User-Agent fixe")` — privacy headers
- `it("retourne le price sur 200")`
- `it("traduit chaque error.code en clé i18n")`
- `it("respecte Retry-After sur 429 et 503")`
- `it("ne réessaye pas sur 401, 403, 404, 400")`
- `it("dédoublonne les requêtes in-flight identiques")`
- `it("respecte le rate-limit local 1 req / 2s")` — utilise `vi.useFakeTimers()`
- `it("respecte le plafond hard 100 req / session")`
Le rate-limiter client + la dedup vivent dans `src/services/balance.service.ts` (section `prices`), conformément à la convention « 1 service par domaine » du projet.
> **🔴 TECHNIQUE** — `mockito-rs` n'existe pas comme crate.
> (a) Le crate Rust s'appelle `mockito` (sans suffixe `-rs`). (b) Le client de prix sera en TypeScript (fetch via Tauri vers maximus-api), donc Rust n'est pas le bon endroit pour ces tests. `Cargo.toml` de simpl-resultat ne contient aucun mock HTTP actuellement.
> **Resolution :** Préciser : tests en `vitest` côté TS avec `msw` ou `vi.fn()` sur `fetch`. Si volet Rust nécessaire (ex. tests Tauri command), utiliser `mockito` (sans `-rs`) ou `wiremock` ajouté à `[dev-dependencies]` du Cargo.toml.
### 12.2 Côté serveur (`maximus-api`)
Tests d'intégration obligatoires (`vitest` + `nock` pour mock outbound + `app.request()` natif Hono pour inbound) :
- `it("rejette toute requête sans Authorization → 401 missing_token")`
- `it("rejette une signature invalide → 401 invalid_token")`
- `it("rejette `claims.product !== 'simpl-resultat'` → 401 invalid_token")`
- `it("rejette une licence non-premium → 403 premium_required AVANT tout appel provider")`
- `it("appel sortant Yahoo n'inclut que User-Agent browser-like + Accept + Host")` — assertion sur `nock` interceptor
- `it("appel sortant exchanges (Kraken/Coinbase) n'inclut que User-Agent maximus-api + Accept + Host")`
- `it("logger.spy n'a jamais été appelé avec un payload contenant license_id ET symbol simultanément")` — via wrapper `src/logger.ts` (pino) + `vi.spyOn(logger, 'info')`
- `it("retourne 404 symbol_not_found sans fallback cross-asset")` — crypto inconnu ≠ tenter Yahoo
- `it("sert depuis le cache si disponible — vérifié par 0 appel sortant nock")`
- `it("circuit breaker : ouvre après 5 erreurs Yahoo / minute → 503 service_degraded en réponse")`
- `it("token-bucket Postgres : 31e requête en 1 minute → 429 rate_limit_exceeded")`
- `it("retourne le bon shape d'erreur pour chaque status code")`
> **🟡 TECHNIQUE** — Test « jamais log (license_id, symbol) » non testable simplement.
> Requiert une infra d'inspection de logs (capture stdout, parsing fichier) que maximus-api n'a pas — pas de logger structuré (`pino`, `winston`) dans `package.json`.
> **Resolution :** Reformuler en assertion sur logger injectable : `expect(loggerSpy).not.toHaveBeenCalledWith(stringContaining(licenseId) && stringContaining(symbol))`. Introduire `src/logger.ts` (pino) avant l'implémentation et tester contre lui.
> **🟢 TECHNIQUE** — Aucune lib HTTP mock prévue côté serveur.
> Les tests d'intégration mentionnent « supertest ou équivalent » mais aucune lib n'est dans `devDependencies` actuellement. Pour mocker Yahoo/CoinGecko il faut aussi `nock` ou `msw/node`.
> **Resolution :** Ajouter à `maximus-api/package.json` : `nock` (mock HTTP outbound) + `@hono/testing` ou `supertest` (test inbound). Préciser §12.2 : « `nock` pour les fournisseurs externes, `app.request()` de Hono pour l'API maximus. »
## 13. Décisions ouvertes (à trancher avant gel)
> Cette section disparaît une fois le contrat marqué `Statut: Stable`.
1. **Crypto pricing : prix instantané ou close UTC ?** Pour les snapshots de bilan datés, il faut un prix « représentatif » de la date. Décision proposée : **close UTC 00:00** via les endpoints OHLC des exchanges (Kraken `OHLC` interval=1440, Coinbase `candles` granularity=86400) ; pour `date == today`, prix instantané toléré.
2. **`actual_date` est-il utile ou source de bugs ?** Décision : **garder** car plus explicite que `is_approximation: bool`.
3. **Quota nuit / WE plus permissif ?** Décision : **non**, garder les seuils plats. Simple à expliquer.
4. **Endpoint d'introspection du quota** (`GET /v1/quota`) ? Décision : **out** du MVP. Ajouter en v1.x si demande utilisateur.
Décisions **tranchées en §0** (ne sont plus ouvertes) : provider, infra rate-limit, versioning, enveloppe d'erreur, lib mocks, `product` claim binding.
## Annexe A — Exemples complets
### A.1 Succès
**Requête** :
```http
GET /v1/prices?symbol=AAPL&date=2026-04-25 HTTP/1.1
Host: api.lacompagniemaximus.com
Authorization: Bearer <license-token>
Accept: application/json
User-Agent: simpl-resultat
```
**Réponse** :
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 28
X-RateLimit-Reset: 1714061520
Cache-Control: private, max-age=0
{
"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
}
```
### A.2 Licence non-premium
**Réponse** :
```http
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{
"error": {
"code": "premium_required",
"message": "Premium license required for price fetching"
}
}
```
### A.3 Rate-limit
**Réponse** :
```http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714061580
Retry-After: 42
{
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded for this license",
"retry_after": 42
}
}
```
## Annexe B — Mapping des codes d'erreur vers les clés i18n du client
À implémenter dans `src/i18n/locales/{fr,en}.json` sous `balance.priceFetching.errors.*` :
| `error.code` | Clé i18n | FR | EN |
|--------------|----------|----|----|
| `invalid_symbol` | `errors.invalidSymbol` | « Symbole invalide » | "Invalid symbol" |
| `invalid_date` | `errors.invalidDate` | « Date invalide » | "Invalid date" |
| `missing_param` | `errors.missingParam` | « Paramètre manquant » | "Missing parameter" |
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Activation requise » | "Activation required" |
| `premium_required` | `errors.premiumRequired` | « Abonnement premium requis » | "Premium subscription required" |
| `license_revoked` | `errors.licenseRevoked` | « Licence révoquée — contactez le support » | "License revoked — contact support" |
| `symbol_not_found` | `errors.symbolNotFound` | « Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
| `rate_limit_exceeded` | `errors.rateLimit` | « Trop de requêtes — réessayez dans {{seconds}}s » | "Too many requests — retry in {{seconds}}s" |
| `provider_unavailable` / `service_degraded` / `internal_error` | `errors.serverUnavailable` | « Service indisponible — saisissez le prix manuellement » | "Service unavailable — enter price manually" |
| `service_degraded` (circuit breaker Yahoo) | `errors.bestEffortDegraded` | « Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement » | "Price source temporarily unavailable — retry in {{minutes}} min or enter manually" |
**Règle générale côté UI** : sur n'importe quelle erreur, le champ de saisie manuelle reste actif. Jamais bloquer la saisie d'un snapshot.
---
## Revision — Synthese
> Date : 2026-04-26 | Experts : Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — La privacy posture et la défense en profondeur sont saines, mais le contrat repose sur des prémisses provider (Yahoo, CoinGecko free) en violation de ToS et sur une infra rate-limit/cache non encore en place. À débloquer avant le gel.
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|----|----|----|-------------|
| Securite | 3 | 4 | 1 | ToS providers, rate-limit infra, replay token, log correlation, kid rotation |
| Architecture | 3 | 4 | 1 | Asymétrie versioning, enveloppe d'erreur, claim `product`, middleware partagée, cache storage |
| Technique | 3 | 3 | 1 | `mockito-rs` inexistant, quota CoinGecko vs free tier, log inspection infra, headers infra |
### Actions requises
**🔴 Critiques — bloquantes pour le ship**
1. **Provider de prix légitime** — souscrire à un fournisseur licencié (Polygon / Alpha Vantage / Twelve Data / CoinGecko Pro) avec droit contractuel de proxy, OU acter un fallback documenté. Couvre Yahoo §8.1+§8.3 et CoinGecko §8.1+§6.1.
2. **Rate-limit partagé persistant** — Redis ou token-bucket Postgres (atomique) avant que §6 ne quitte le draft. Généraliser `rateLimit.ts` pour accepter `{ keyFn, windows }`.
3. **Enveloppe d'erreur cohérente** — trancher entre flat (`/licenses/*` actuel) ou nesté (proposé). Migrer un côté avant de figer.
4. **Versioning cohérent** — décider du préfixe `/v1/` global (renommer `/licenses/*` en `/v1/licenses/*`) ou local au prices uniquement.
5. **Binding `product`** — middleware vérifie `claims.product === 'simpl-resultat'`. Documenter en §7.1.
6. **Lib de tests réelle** — remplacer `mockito-rs` (inexistant) par `vitest + msw` côté TS, ou `mockito`/`wiremock` côté Rust si nécessaire.
7. **Quota client réaliste** — réduire 2000/j ou souscrire au tier provider qui le supporte. Cohérence quota promis ↔ capacité provider.
**🟡 Améliorations recommandées**
8. `activation_token` : ajouter `jti` + revocation list, OU réduire TTL à 7-14j avec refresh-token.
9. Cache serveur borné (LRU + cap), 404 TTL court, idéalement allowlist symboles.
10. Stripper la querystring de `/v1/prices` dans Traefik (ou ne pas logger), pour tenir §9.1.
11. `kid` dans le header JWT + map kid→PEM côté client (préparer la prochaine rotation Ed25519).
12. Mettre à jour §7.2 — `edition` est déjà exposé par `/licenses/verify`.
13. Middleware partagée `licenseAuth.ts` (étapes 1-6) au lieu d'inline dans le handler.
14. Cache : table Drizzle `prices_cache` dans le Postgres existant (pas SQLite, pas en mémoire).
15. Logger structuré (pino) injectable pour rendre testable « jamais log conjoint ».
16. Préciser fake-timers + emplacement du rate-limiter client (`priceService.ts`).
17. Client HTTP nu côté serveur pour les appels providers (éviter propagation des headers infra).
**🟢 Suggestions**
18. `X-Client-Major: 0.x` pour permettre la dépréciation d'urgence sans casser k-anonymity.
19. Architecture globale saine — convergence essentiellement sur l'alignement avec le code existant.
20. Ajouter `nock` + `@hono/testing` aux devDependencies de maximus-api.

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-04-13 — Version 0.7.3
> Document mis à jour le 2026-04-25 — Version 0.8.x (Bilan)
## Stack technique
@ -28,6 +28,7 @@ simpl-resultat/
├── src/ # Frontend React/TypeScript
│ ├── components/ # 58 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
@ -72,7 +73,7 @@ simpl-resultat/
## Base de données
### Tables (13)
### Tables (18)
| Table | Description |
|-------|-------------|
@ -89,10 +90,36 @@ simpl-resultat/
| `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) |
| `balance_categories` | Taxonomie des types d'actifs (cash, TFSA, RRSP, fund, stock, crypto, other) — `kind ∈ {simple, priced}`, 7 seedées (`is_seed = 1`) |
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. **Issue #179** : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils via `consolidated_schema.sql`, et proposés aux profils existants via `StarterAccountsModal` (one-shot, pref `balance_starter_proposed`). Le futur passage à un modèle véhicule × composition est décrit dans [ADR 0012](adr/0012-balance-two-level-model.md) (Proposed) |
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
| `balance_snapshot_lines` | Une ligne par `(snapshot, compte)`. Stockage denormalisé : pour `simple` `value` seul, pour `priced` `quantity + unit_price + value`. CHECK kind invariants côté SQL |
| `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains |
### Index (9)
### Index (16)
Index sur : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
Index existants (9) : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
Index Bilan (7, ajoutés en migration v9) :
- `idx_balance_accounts_category` (FK lookup catégorie → comptes)
- `idx_balance_accounts_active` partiel `WHERE is_active = 1` (filtre liste active)
- `idx_balance_snapshot_lines_snapshot` (chargement d'un snapshot)
- `idx_balance_snapshot_lines_account` (historique par compte)
- `idx_balance_account_transfers_account` (cash flows Modified Dietz par compte)
- `idx_balance_account_transfers_transaction` (lookup icône d'attribution dans `TransactionTable`)
- `idx_balance_snapshots_date` (sélecteur de période + agrégation chronologique)
### Invariants Bilan (CHECK + FK)
- `balance_categories.kind``('simple','priced')`
- `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux)
- `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced)
- `balance_account_transfers.direction``('in','out')` ; UNIQUE `(transaction_id, account_id)` (une transaction ne peut pas être liée deux fois au même compte)
- FK `balance_accounts.balance_category_id``balance_categories(id)` `ON DELETE RESTRICT` (empêche suppression de catégorie avec comptes liés)
- FK `balance_snapshot_lines.snapshot_id``balance_snapshots(id)` `ON DELETE CASCADE` (supprimer un snapshot supprime ses lignes)
- FK `balance_snapshot_lines.account_id``balance_accounts(id)` `ON DELETE RESTRICT` (préserve l'historique)
- 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)
## Système de migrations
@ -107,17 +134,19 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
| 5 | v5 | Création de `import_config_templates` |
| 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) |
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
| 8 | v8 | Migration de catégories (cf. release 0.8.x) |
| 9 | v9 | Schéma Bilan : 5 tables + 7 index + seed des 7 catégories standard (cash, TFSA, RRSP, fund, stock, crypto, other) |
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).
## Services TypeScript (17)
## Services TypeScript (18)
| Service | Responsabilité |
|---------|---------------|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
| `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques |
| `transactionService.ts` | CRUD et filtrage des transactions |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) |
| `importSourceService.ts` | Configuration des sources d'import |
| `importedFileService.ts` | Suivi des fichiers importés |
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
@ -125,14 +154,26 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle) |
| `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é) |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
| `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) |
## Hooks (14)
### Service Bilan — `balance.service.ts`
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
1. **CRUD catégories + comptes**`listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount`, `archiveBalanceAccount`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour permettre à la UI d'afficher des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, etc.).
2. **Snapshots + lines**`listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne — choix simple pour < 20 comptes/snapshot), `deleteSnapshot`, helper `validateLineKindInvariants` exporté pour les tests (kind invariants TS en complément du CHECK SQL ; tolérance `PRICED_VALUE_TOLERANCE = 0.01` pour le match `value ≈ quantity × unit_price`).
3. **Returns + transfers**`linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
4. **Prices***(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
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+)
Chaque hook encapsule la logique d'état via `useReducer` :
@ -149,15 +190,19 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode MoM / YoY / budget) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
| `useBalanceAccounts` | Bilan — état de la page `/balance/accounts` : CRUD comptes ET catégories (un seul hook pour les deux onglets, aligné sur la convention "1 hook par page") |
| `useSnapshotEditor` | Bilan — cycle de vie d'un snapshot unique (`/balance/snapshot`) : valeurs simple (string) + valeurs priced (`{quantity, unit_price}` strings), prefill depuis snapshot précédent, save (rewrite-all), delete avec double-confirmation par re-saisie de la date |
| `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) |
| `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
| `useLicense` | État de la licence et entitlements |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
## Commandes Tauri (35)
## Commandes Tauri (36)
### `fs_commands.rs` — Système de fichiers (6)
@ -229,6 +274,14 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
### `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).
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).
**À venir Phase 5** (Issue #143, BLOCKED par maximus-api Phase 2) : commande `fetch_price(symbol, date)` pour le price-fetching premium via proxy maximus-api. L'architecture est documentée dans l'ADR 0009 ; la livraison est différée jusqu'à ce que le serveur de licences (`maximus-api`) expose l'endpoint `GET /v1/prices`.
## Plugins Tauri
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
@ -289,9 +342,18 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
| `/settings` | `SettingsPage` | Paramètres |
| `/docs` | `DocsPage` | Documentation in-app |
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
| `/balance` | `BalancePage` | Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté |
| `/balance/snapshot` | `SnapshotEditPage` | Saisie / édition d'un snapshot daté. Mode `?date=today` (création) ou `?date=YYYY-MM-DD` (édition, date immutable). Lignes groupées par catégorie : `simple` = champ valeur, `priced` = `quantity` × `unit_price` (`value` calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date |
| `/balance/accounts` | `AccountsPage` | CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (`is_seed = 1`) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) |
| `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales |
| `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) |
| `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix |
| `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), historique des versions (`ChangelogContent`), journaux + commentaires (`LogViewerCard`) |
| `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) |
| `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) |
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
| `/changelog` | `ChangelogPage` | Redirige vers `/settings/systems` (rétrocompatibilité release notes) |
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
@ -326,3 +388,22 @@ 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
## Architecture Decision Records (ADRs)
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`.
| # | Titre | Date | Statut |
|---|-------|------|--------|
| [0001](adr/0001-tauri-v2.md) | Choix de Tauri v2 comme framework desktop | 2024-01-01 | Accepted |
| [0002](adr/0002-useReducer-vs-redux.md) | useReducer plutôt que Redux | 2024-01-01 | Accepted |
| [0003](adr/0003-sqlx-migrations.md) | Migrations SQL inline via tauri-plugin-sql | 2024-01-01 | Accepted |
| [0004](adr/0004-aes-256-gcm-encryption.md) | Chiffrement AES-256-GCM pour l'export | 2024-01-01 | Accepted |
| [0005](adr/0005-multi-profile-db.md) | Multi-profils avec bases SQLite séparées | 2024-01-01 | Accepted |
| [0006](adr/0006-oauth-tokens-keychain.md) | Stockage des tokens OAuth via keychain | 2024-01-01 | Accepted |
| [0007](adr/0007-reports-hub-refactor.md) | Refactorisation du hub de rapports | 2024-01-01 | Accepted |
| [0008](adr/0008-modified-dietz-pour-rendement.md) | Modified Dietz pour le calcul de rendement | 2025-01-01 | Accepted |
| [0009](adr/0009-proxy-price-fetching-via-maximus-api.md) | Proxy price-fetching via maximus-api | 2025-01-01 | Accepted |
| [0010](adr/0010-fk-restrict-balance-transfers.md) | FK RESTRICT sur balance_account_transfers | 2025-01-01 | Accepted |
| [0011](adr/0011-providers-best-effort-yahoo.md) | Providers best-effort Yahoo | 2026-04-26 | Accepted |
| [0012](adr/0012-balance-two-level-model.md) | Modèle à deux niveaux pour le Bilan (véhicules × compositions) | 2026-05-01 | Proposed |

View file

@ -260,6 +260,62 @@ Le sélecteur de période en haut à droite est **partagé** entre toutes les pa
- Tableau triable des **top mouvements** (catégories avec la plus forte variation vs mois précédent), ou graphique en barres divergentes centré sur zéro (toggle graphique/tableau)
- Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours
### Rapport Cartes (`/reports/cartes`)
Un tableau de bord condensé qui résume un mois de référence en quatre KPIs, une sparkline 13 mois, les plus gros mouvements de catégories, l'adhésion budgétaire et la saisonnalité. Deux contrôles en en-tête : le **sélecteur de mois de référence** et le **toggle Mensuel / Cumul annuel (YTD)**.
#### Les 4 cartes KPI et leurs formules
- **Revenus** = somme des transactions positives
- **Dépenses** = valeur absolue de la somme des transactions négatives
- **Solde net** = `revenus dépenses`
- **Taux d'épargne** = `(revenus dépenses) ÷ revenus × 100`. Affiché comme « — » quand les revenus sont à zéro (évite la division par zéro et le « 0 % » trompeur)
Chaque carte affiche la valeur courante, deux deltas (vs mois précédent, vs l'an dernier) et une sparkline 13 mois. Le delta est vert si la variation est favorable au KPI (hausse pour Revenus, baisse pour Dépenses), rouge dans le cas contraire.
#### Toggle Mensuel / Cumul annuel (YTD)
Placé à côté du sélecteur de mois, il bascule les 4 cartes entre deux vues :
- **Mensuel** (par défaut) — la valeur courante est celle du mois de référence. Les deltas comparent ce mois à son précédent (MoM) et au même mois de l'an dernier (YoY)
- **Cumul annuel (YTD)** — la valeur courante est la somme depuis le 1er janvier de l'année de référence jusqu'à la fin du mois de référence inclus. Les deltas deviennent :
- **MoM YTD** = cumul actuel (Jan→mois de réf) vs cumul précédent (Jan→mois de réf1) de la même année. **Null en janvier** (pas de fenêtre antérieure dans la même année) — affiché comme « — »
- **YoY YTD** = cumul actuel vs le même cumul (Jan→mois de réf) de l'année précédente
- **Taux d'épargne YTD** = `(revenus YTD dépenses YTD) ÷ revenus YTD × 100`, null si les revenus YTD sont à zéro
Le choix du mode est **persisté** (clé locale `reports-cartes-period-mode`) et restauré au redémarrage. La sparkline 13 mois, elle, reste toujours mensuelle — elle donne le contexte temporel indépendamment du toggle.
#### Sparkline 13 mois
Chaque KPI inclut une mini-courbe des 13 derniers mois (mois de référence + 12 précédents). Les mois sans données comptent comme zéro pour que la courbe reste continue. Non affectée par le toggle Mensuel / YTD.
#### Top mouvements (MoM)
Deux listes : les 5 catégories avec la plus forte **hausse** de dépenses vs mois précédent et les 5 avec la plus forte **baisse**. Triées par variation absolue en dollars, avec la variation en pourcentage à droite. Toujours mensuelles, indépendamment du toggle.
#### Saisonnalité
Compare les dépenses du mois de référence à la **moyenne du même mois calendaire sur les 2 années précédentes**.
- Écart en pourcentage : `(dépenses du mois moyenne historique) ÷ moyenne historique × 100`
- Affiché comme « pas assez d'historique pour ce mois » s'il n'y a aucune donnée historique ou si la moyenne est à zéro
Toujours basée sur le mois de référence, indépendamment du toggle Mensuel / YTD.
#### Adhésion budgétaire
Score `N/M` des catégories dont les dépenses restent sous le budget mensuel (comparaison en valeur absolue pour gérer correctement les budgets de dépenses stockés signés négatifs).
- Seules les catégories de type **dépense** avec un budget non nul sont comptées, feuilles uniquement (les catégories parentes sont ignorées pour éviter le double comptage)
- Suivi des **3 pires dépassements** avec le montant et le pourcentage de dépassement
Toujours mensuelle, indépendamment du toggle.
#### À savoir
- Le sélecteur de période générique (utilisé par les autres rapports) est volontairement absent ici : la Cartes pivote autour d'un mois unique avec comparaisons automatiques, donc seul le sélecteur de mois de référence est exposé
- Le taux d'épargne affiche « — » (pas « 0 % ») quand les revenus sont à zéro, pour distinguer « pas de revenus » de « revenus = dépenses »
### Rapport Tendances (`/reports/trends`)
- **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau
@ -299,7 +355,74 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
---
## 10. Paramètres
## 10. Bilan
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), vous suivez leur évolution dans le temps, et vous calculez le **vrai rendement** de chaque compte d'investissement en liant les transferts (apports / retraits) aux comptes correspondants.
Trois pages composent le module Bilan :
- `/balance` — vue d'ensemble (graphique + tableau des comptes)
- `/balance/snapshot` — saisie / édition d'un snapshot daté
- `/balance/accounts` — CRUD des comptes et catégories
L'entrée **Bilan** dans la barre latérale (icône portefeuille) donne accès à `/balance` ; les deux autres pages s'ouvrent depuis là.
### Fonctionnalités
- 7 catégories standard pré-installées : Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres — renommables, non-supprimables
- Création de catégories personnalisées (ex. FERR, RPDB) avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire)
- Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes
- Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer
- Saisie groupée par catégorie ; pour les catégories `priced`, le `value` est calculé automatiquement (`quantity × unit_price`)
- Bouton **Pré-remplir depuis le snapshot précédent** : copie les valeurs simples + les quantités priced (vous remplissez juste les nouveaux prix)
- Liaison de transactions existantes à un compte de bilan (modal avec filtres par période / catégorie / recherche, sens auto-proposé selon le signe)
- Icône d'attribution dans la page Transactions pour les transactions liées à un transfert
- Graphique d'évolution du bilan (mode courbe simple ou aire empilée par catégorie) avec marqueurs verticaux pour les transferts taggés (vert = in, rouge = out)
- Tableau des comptes avec **3 colonnes de rendement Modified Dietz** (3 mois / 1 an / depuis création) + colonne rendement non-ajusté côte-à-côte
- Avertissement si le dernier snapshot remonte à plus de 60 jours
- Soft-delete des comptes (`Archiver`) : masqués des nouveaux snapshots, conservés dans l'historique
- Suppression d'un snapshot avec double-confirmation (re-saisie de la date)
- Privacy-first : tout est local, aucun appel sortant au MVP
### Comment faire
1. Allez dans `/balance/accounts` → onglet Catégories pour créer si besoin une catégorie supplémentaire (ex. "FERR" en `simple`, ou "Stocks Wealthsimple" en `priced`)
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "TFSA Tangerine" rattaché à CELI, "BTC Ledger" rattaché à Crypto avec symbole `BTC`)
3. Cliquez **+ Nouveau snapshot** depuis `/balance` pour ouvrir `/balance/snapshot` à la date du jour
4. Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée
5. Enregistrez. Le graphique sur `/balance` s'actualise immédiatement
6. Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions du compte → **Lier transferts** → cochez les transactions qui correspondent à des apports / retraits (un dépôt CELI, un achat d'actions, etc.). Le sens (in/out) est proposé automatiquement selon le signe de la transaction
7. Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création. Le rendement non-ajusté à droite vous permet de comparer "valeur du compte" et "vraie performance"
8. Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)
9. Pour supprimer un snapshot, cliquez **Supprimer** dans son éditeur et re-saisissez la date pour confirmer
### Lecture des rendements multi-horizons
- **3 mois** : performance courte, sensible aux mouvements récents
- **1 an** : horizon de référence pour la plupart des décisions d'allocation
- **Depuis création** : performance totale du compte depuis le premier snapshot
- **Non-ajusté (côte-à-côte)** : `(V_fin V_début) / V_début` brut, sans soustraction des apports — utile pour voir la croissance totale (gains + apports). La différence entre les deux colonnes vous montre la part qui vient des apports plutôt que de la performance
Avertissements affichés :
- *Période partielle* — un snapshot manque au début ou à la fin de la période
- *Aucun transfert lié* — le rendement est calculé sans apports identifiés (équivaut au non-ajusté)
- *Performance non significative* — le compte a été vidé puis rechargé, le calcul Modified Dietz produit un résultat instable
### Que faire si je supprime une transaction liée ?
C'est intentionnellement bloqué : si vous tentez de supprimer une transaction qui est liée à un compte de bilan, vous voyez le message **"Cette transaction est liée au compte de bilan _<nom>_"** avec un lien direct vers le compte. Ouvrez le compte → Lier transferts → décochez la transaction → revenez la supprimer. Cette friction préserve la reproductibilité de vos rendements passés (un rendement calculé hier ne peut pas changer aujourd'hui à cause d'une suppression silencieuse).
### Astuces
- Saisissez vos snapshots à un rythme régulier (mensuel ou trimestriel) — la qualité des rendements dépend directement de la régularité
- Utilisez le bouton **Pré-remplir** : ça copie tout, vous mettez juste à jour ce qui a changé
- Le mode **graphique empilé par catégorie** raconte une histoire différente du mode ligne : il montre la composition de votre patrimoine, pas seulement son total
- Les marqueurs verticaux du graphique (transferts taggés) aident à lire les sauts de valeur — un saut suivi d'un marqueur vert n'est pas une "performance", c'est juste un dépôt
- L'avertissement "bilan pas à jour" apparaît si votre dernier snapshot remonte à plus de 60 jours — c'est le signe qu'il est temps d'en saisir un nouveau
- (À venir Phase 5) **Récupération automatique des prix** pour les comptes Actions / Crypto via un proxy privé (premium-only). Le service interroge un serveur Maximus dédié qui anonymise votre requête (votre IP n'est jamais exposée à Yahoo / CoinGecko). La saisie manuelle reste toujours disponible.
---
## 11. Paramètres
Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.
@ -309,6 +432,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
- 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
@ -318,9 +442,10 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Utilisez la section Gestion des données pour exporter ou importer vos données
5. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut
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
### Astuces
@ -329,4 +454,5 @@ 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
- En cas de problème, copiez les journaux et joignez-les à votre signalement
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement

View file

@ -0,0 +1,141 @@
# QA — Refonte seed catégories IPC
Checklist manuelle pour valider le flow complet de migration v2→v1 (spec `spec-refonte-seed-categories-ipc`). À dérouler avant chaque release touchant au module catégories.
## Prérequis
- [ ] Profil de test v2 avec au moins : 10+ catégories seedées, 2-3 catégories custom, 50+ transactions réparties, quelques suppliers et keywords, un budget actif.
- [ ] Profil de test v1 (nouveau profil créé après #115) pour les tests de régression.
- [ ] Variante profil v2 **avec PIN activé** (profil chiffré).
---
## 1. Pré-migration (découverte)
- [ ] Ouvrir l'app sur un profil v2 → bannière dashboard "Découvrez la nouvelle structure" (#118) visible en haut.
- [ ] Cliquer CTA bannière → redirige vers `/settings/categories/standard` (#117).
- [ ] Dismiss la bannière → disparaît immédiatement, reste dismiss après redémarrage de l'app.
- [ ] Créer un nouveau profil v1 (ou charger le profil v1 de test) → bannière **pas** affichée.
## 2. Page Guide des catégories standard (#117)
- [ ] Arbre affiche les 3 niveaux avec les noms traduits (FR et EN).
- [ ] Switcher EN → les noms basculent, l'arbre conserve son état d'expansion.
- [ ] Tooltip au hover d'un nœud affiche `i18n_key`, `type`, `id`.
- [ ] Compteur "N catégories" en haut correspond au nombre total de leaves.
- [ ] Recherche full-text filtre les noms correctement (accent-insensitive).
- [ ] Bouton Print → impression/PDF avec arbre entièrement déplié et toolbar masquée.
- [ ] Lien "Voir les catégories standard" dans Settings > Catégories → même page.
## 3. Page de migration — Étape 1 (Discover)
- [ ] Depuis Settings > Catégories sur un profil v2, cliquer "Migrer vers la nouvelle structure" → `/settings/categories/migrate`.
- [ ] Étape 1 affiche l'arbre v1 (read-only, comme la page Guide).
- [ ] Bouton Next passe à l'étape 2.
## 4. Page de migration — Étape 2 (Simulate)
- [ ] Table 3 colonnes : v2 source | confidence badge | cible v1 + action.
- [ ] Badges corrects : 🟢 haute (keyword), 🔵 moyenne (supplier), 🟠 basse (default), 🔴 aucune (review).
- [ ] Stats summary en haut : total / high / medium / low / none.
- [ ] Section "Catégories personnalisées préservées" liste les custom du profil (si présentes).
- [ ] Cliquer une ligne 🟠 ou 🔴 → panneau latéral "Transactions impactées" affiche jusqu'à 50 transactions liées.
- [ ] Pour une ligne unresolved, ouvrir le picker de cible → sélectionner un leaf v1 → badge passe à ✅ résolu.
- [ ] Bouton Next **disabled** tant qu'il reste des lignes unresolved.
- [ ] Résoudre toutes les unresolved → Next devient actif.
## 5. Page de migration — Étape 3 (Consent)
- [ ] Checklist obligatoire : "J'ai compris…", "Je consens…", "Je sauvegarde avant…".
- [ ] Bouton Apply disabled tant que la checklist n'est pas complète.
- [ ] Sur profil avec PIN, champ PIN demandé.
- [ ] Apply déclenche : loader 4 sous-étapes (1. Sauvegarde, 2. Insertion v1, 3. Remap transactions/budgets, 4. Cleanup v2).
## 6. Migration — cas nominal
- [ ] Après Apply, écran de succès affiche :
- [ ] Chemin du backup SREF (ex: `~/Documents/Simpl-Resultat/backups/MonProfil_avant-migration-2026-04-21T12-34-56.sref`).
- [ ] Récap : nb v1 insérées, nb transactions updatées, nb budgets updatées, nb keywords updatés, nb v2 désactivées, nb custom préservées.
- [ ] Liens vers Dashboard / Voir les catégories.
- [ ] Retour au Dashboard → bannière dashboard #118 disparue.
- [ ] Settings > Catégories → bannière "Sauvegarde disponible" (#122) visible (90 jours).
- [ ] Page Catégories → affiche la structure v1.
## 7. Migration — échec backup
Simuler : rendre le dossier `~/Documents/Simpl-Resultat/backups/` non-writable (Linux : `chmod -w`, Windows : permissions lecture seule) **avant** de lancer Apply.
- [ ] Apply échoue à l'étape 1 (Sauvegarde).
- [ ] Message d'erreur clair : "Impossible de créer la sauvegarde. Aucune modification n'a été effectuée."
- [ ] **Aucune écriture BDD** : via outil SQLite, vérifier que `categories`, `transactions`, `budgets`, `keywords` sont strictement identiques avant/après.
- [ ] Flag `categories_schema_version` reste à `v2`.
- [ ] Bouton retry dispo ; rétablir les droits d'écriture, relancer → succès.
## 8. Migration — échec SQL (rollback)
Plus difficile à reproduire ; le chemin testé unitairement est le `ROLLBACK` déclenché si un `UPDATE` casse une contrainte FK/UNIQUE. Tester en injectant artificiellement une contrainte (ex: DB corrompue) si un outil le permet, sinon revue de code suffit. La checklist minimale :
- [ ] Si un échec est simulé, écran d'erreur affiche "La migration a échoué. Vos données n'ont pas été modifiées. Votre sauvegarde est disponible à <path>."
- [ ] BDD intacte (même check qu'au point 7).
- [ ] Backup SREF créé (toujours disponible sur le disque).
## 9. Bannière post-migration (#122) — 90 jours
- [ ] Migration récente → bannière Settings > Catégories visible.
- [ ] Date d'expiration affichée = timestamp migration + 90 jours.
- [ ] Cliquer "Fermer" → bannière disparaît, flag `categories_migration_banner_dismissed=1`, ne réapparaît plus après redémarrage.
- [ ] Après 90 jours (avancer l'horloge système ou manipuler `last_categories_migration.timestamp` dans la DB) → bannière **plus** affichée, mais entrée permanente "Rétablir une sauvegarde" reste dispo dans Settings.
## 10. Rétablir la sauvegarde (#122)
- [ ] Cliquer "Rétablir la sauvegarde" depuis bannière ou depuis l'entrée permanente.
- [ ] Modale s'ouvre : chemin backup affiché, texte de warning, boutons Annuler / Rétablir (rouge).
- [ ] Sur profil chiffré (PIN), champ PIN demandé.
- [ ] Simuler fichier manquant : renommer/déplacer le `.sref` avant de cliquer Rétablir → erreur claire + file picker de secours.
- [ ] Choisir le fichier via le picker → la restauration procède.
- [ ] Après succès :
- [ ] Toast / message succès.
- [ ] L'app recharge, catégories v2 à nouveau actives.
- [ ] `categories_schema_version` revient à `v2`.
- [ ] `last_categories_migration.reverted_at` renseigné (ISO string).
- [ ] Transactions, budgets, keywords correspondent à l'état pré-migration.
- [ ] Annuler la modale → aucune modification.
## 11. Profil avec catégories custom
- [ ] Migration préserve les 3 custom sous un parent "Catégories personnalisées (migration)" (id 2000).
- [ ] Les transactions liées aux custom conservent leur `category_id`.
- [ ] Les keywords liés aux custom conservent leur `category_id`.
## 12. Profil sans catégories custom
- [ ] Migration **ne crée pas** de parent "Catégories personnalisées (migration)" (section `preserved` vide → skip).
## 13. Régression — fonctionnalités auxiliaires
Tester sur profil v2 ET profil v1 pour vérifier qu'aucune fonctionnalité n'a cassé :
- [ ] **Auto-catégorisation CSV** : importer un CSV de test → catégorisation par keyword/supplier fonctionne identiquement.
- [ ] **Budget vs Actuel** : agrégation parent/enfant cohérente, montants corrects.
- [ ] **Splits** : transactions multi-catégories préservent leurs ratios.
- [ ] **Export/Import SREF** : export d'un profil v1 → re-import dans un nouveau profil → structure identique.
- [ ] **UI CategoryTree et CategoryCombobox** : rendent correctement l'arbre v1 et v2, pas d'affichage cassé ou mélangé.
- [ ] **Rapports** : aucune régression sur les graphiques catégorie (tendances, répartition).
## 14. i18n
- [ ] Toutes les nouvelles pages (Guide, Migration, bannières, modale) supportent FR et EN.
- [ ] Aucune chaîne en dur visible à l'écran.
- [ ] Bascule live FR↔EN ne casse pas l'état local des pages.
---
## Tests automatisés équivalents
Pour référence, les chemins de tests automatisés qui couvrent partiellement cette checklist :
- Unitaires : `src/services/categoryMappingService.test.ts` (100 tests), `src/services/categoryBackupService.test.ts` (23), `src/services/categoryMigrationService.test.ts` (16), `src/services/categoryTaxonomyService.test.ts` (15), `src/services/categoryRestoreService.test.ts` (12), `src/hooks/useCategoryMigration.test.ts` (13).
- Intégration : `src/__integration__/category-migration.test.ts` (flow + échecs + rollback).
- Régression : `src/__integration__/regression-v2-v1.test.ts` (auto-catégorisation, budget agrégation, splits paramétrés v2/v1).
Cette QA manuelle couvre les dimensions UX, erreurs système (permissions, fichiers manquants), profil chiffré et régression visuelle qui ne sont pas automatisables actuellement.

View file

@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
<title>Simpl'Résultat</title>
</head>
<body>

71
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "simpl_result_scaffold",
"version": "0.7.3",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.7.3",
"version": "0.9.1",
"license": "GPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -1431,6 +1431,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@ -2863,9 +2923,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{
@ -2881,6 +2941,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",

View file

@ -1,7 +1,7 @@
{
"name": "simpl_result_scaffold",
"private": true,
"version": "0.8.0",
"version": "0.9.1",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {

66
public/icon.svg Normal file
View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Concept 04b — Calculatrice-robot avec cadenas sur la touche "="
Iteration sur 04 : la touche Entrée/= devient le porteur du symbole privacy.
Robot (yeux + antenne) + comptabilité (calculatrice) + simplicité + privacy (cadenas explicite).
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- Background squircle -->
<rect x="64" y="64" width="896" height="896" rx="200" ry="200" fill="#1E3A8A"/>
<!-- Antenna -->
<line x1="512" y1="120" x2="512" y2="200" stroke="#FCD34D" stroke-width="18" stroke-linecap="round"/>
<circle cx="512" cy="110" r="28" fill="#FCD34D"/>
<!-- Calculator body -->
<rect x="232" y="200" width="560" height="700" rx="60" ry="60" fill="#F1F5F9"/>
<!-- Screen (robot face) -->
<rect x="282" y="250" width="460" height="240" rx="20" ry="20" fill="#0F172A"/>
<!-- Screen highlight (subtle reflection) -->
<rect x="300" y="265" width="200" height="20" rx="10" fill="#1E3A8A" opacity="0.4"/>
<!-- Robot eyes on screen -->
<circle cx="402" cy="370" r="36" fill="#10B981"/>
<circle cx="622" cy="370" r="36" fill="#10B981"/>
<circle cx="412" cy="358" r="10" fill="#F1F5F9"/>
<circle cx="632" cy="358" r="10" fill="#F1F5F9"/>
<!-- Smile -->
<path d="M 432 440 Q 512 470 592 440" fill="none" stroke="#10B981" stroke-width="12" stroke-linecap="round"/>
<!-- Calculator buttons grid (3x4) -->
<g fill="#1E3A8A">
<rect x="302" y="540" width="100" height="80" rx="16"/>
<rect x="412" y="540" width="100" height="80" rx="16"/>
<rect x="522" y="540" width="100" height="80" rx="16"/>
<rect x="632" y="540" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="630" width="100" height="80" rx="16"/>
<rect x="412" y="630" width="100" height="80" rx="16"/>
<rect x="522" y="630" width="100" height="80" rx="16"/>
<rect x="632" y="630" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="720" width="100" height="80" rx="16"/>
<rect x="412" y="720" width="100" height="80" rx="16"/>
<rect x="522" y="720" width="100" height="80" rx="16"/>
<rect x="632" y="720" width="100" height="80" rx="16" fill="#FCD34D"/>
</g>
<!-- "=" / Enter button (wide, accent green) with lock icon -->
<rect x="302" y="810" width="430" height="80" rx="16" fill="#10B981"/>
<!-- Lock icon centered on the Enter key -->
<g transform="translate(517 850)">
<!-- Shackle (arc above body) -->
<path d="M -16 -2 L -16 -14 A 16 16 0 0 1 16 -14 L 16 -2"
fill="none" stroke="#F1F5F9" stroke-width="8" stroke-linecap="round"/>
<!-- Body -->
<rect x="-24" y="-2" width="48" height="32" rx="5" fill="#F1F5F9"/>
<!-- Keyhole circle -->
<circle cx="0" cy="11" r="4.5" fill="#10B981"/>
<!-- Keyhole stem -->
<rect x="-2.5" y="11" width="5" height="11" rx="1.5" fill="#10B981"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,81 @@
# Spec Decisions — Refonte du seed de catégories vers IPC Statistique Canada
> Date: 2026-04-19
> Projet: simpl-resultat
> Statut: Draft
> Slug: refonte-seed-categories-ipc
## Contexte
Le seed actuel (migration v2, `src-tauri/src/database/seed_categories.sql`) comprend 42 catégories sur 2 niveaux, structurées autour d'un axe cosmétique "récurrentes vs ponctuelles" sans logique métier derrière. Cette organisation :
- Ne reflète aucun référentiel comptable standard (pas d'alignement possible avec Statistique Canada, CPA, ou benchmarks internationaux).
- Exploite seulement 2 des 3 niveaux de hiérarchie supportés par le code (`src/services/categoryService.ts:55-60`).
- Contient des catégories "fourre-tout" (Amazon, Projets) qui cassent la cohérence comptable.
- Propose des mots-clés limités aux fournisseurs rencontrés dans un jeu de données historique personnel.
La refonte vise à s'aligner sur la **classification IPC de Statistique Canada (panier 2024)** qui regroupe les dépenses des ménages en 8 composantes principales officielles, étendues de granularité fine inspirée de Monarch Money (~60 catégories standard, 3 niveaux).
Le spike `seed-standard` (archivé sous `~/claude-code/.spikes/archived/seed-standard/`) a livré 4 documents préparatoires :
- `NOTES.md` — synthèse et décisions
- `code/seed-proposal-v1.sql` — taxonomie v1 (139 catégories, 150+ keywords)
- `code/mapping-old-to-new.md` — table de correspondance v2→v1
- `code/preview-page-mockup.md` — wireframes 3-étapes
**Bénéficiaires** : tous les utilisateurs actuels et futurs de Simpl'Résultat — en particulier les ménages québécois/canadiens qui pourront éventuellement comparer leurs dépenses aux moyennes Statistique Canada.
## Objectif
Remplacer le seed de catégories par une taxonomie alignée IPC/Monarch (3 niveaux, 9 racines IPC + Revenus/Finances/Transferts, ~140 catégories, 150+ keywords fournisseurs canadiens), livrer une page de prévisualisation read-only (Livraison 1), puis une page de migration interactive à 3 étapes avec backup obligatoire via SREF (Livraison 2). Les profils existants migrent en opt-in conscient ; les nouveaux profils reçoivent v1 automatiquement.
## Scope
### IN
- Nouveau fichier `consolidated_schema.sql` avec seed v1 IPC appliqué aux **nouveaux profils** automatiquement.
- Livraison 1 (Option E) : page read-only `/paramètres/catégories/standard` — arbre navigable de la taxonomie v1 avec descriptions, compteurs, export PDF. Aucune modification de données.
- Livraison 2 (Option B) : page de migration 3 étapes (Découvrir / Simuler / Consentir) pour les profils existants.
- Étape Simuler : dry-run complet, calcul des mappings automatiques avec badges de confiance, choix manuel pour cas ambigus.
- Étape Consentir : backup SREF obligatoire (format AES-256-GCM existant) + vérification d'intégrité AVANT toute écriture destructive + migration SQL atomique.
- Algorithme de ventillage en 4 passes (keyword → supplier → défaut → revue utilisateur) pour catégories splittées (Transport commun → Bus/Train, Assurances → 4 branches).
- Préservation des catégories custom utilisateur sous un parent "Catégories personnalisées (migration)" créé automatiquement.
- Bannière dashboard one-shot post-MAJ + entrée permanente dans Paramètres pour inviter à découvrir E et potentiellement B.
- Bouton "Rétablir la sauvegarde" accessible pendant 90 jours post-migration.
- i18n des noms de catégories seed via clés techniques (ex: `seed.categories.alimentation.epicerie`) avec fallback au nom brut pour les catégories custom.
- Couverture de tests complète : unitaires (algo mapping), intégration (flow backup→migrate→rollback), régression (transactions/budgets/keywords post-migration), QA manuelle.
### OUT (explicitement exclu)
- Attribut `transactions.frequency` (recurring/one_shot/unknown) et détection auto → issue séparée future.
- Attribut `categories.essentiality` (essential/discretionary/savings) et rapport 50/30/20 → issue séparée future.
- Peuplement de la colonne `categories.icon` (reste NULL, décision reportée à une future issue UI).
- Suppression de la colonne `categories.icon` (non nécessaire, hors scope).
- Comparaisons avec les moyennes Statistique Canada (infrastructure nécessaire pour télécharger des datasets IPC, analyser, afficher — hors scope v1).
- Migration automatique silencieuse des profils existants (principe : consentement explicite obligatoire).
- Suppression physique du fichier SREF backup (privacy-first : l'app ne gère pas les fichiers de l'utilisateur au-delà de la création).
- Traduction EN des catégories custom créées par l'utilisateur (restent dans la langue saisie).
- Version mobile / Simpl'Résultat Web (hors stack du projet aujourd'hui).
## Decisions prises
| Question | Décision | Raison |
|----------|----------|--------|
| Ordre de livraison (E read-only, B migration, ou fusion) | **E puis B en deux livraisons** | Permet de collecter du feedback sur la taxonomie via E avant d'investir dans l'UX migration B. Deux PRs testables indépendamment. |
| i18n des noms de catégories | **Clés i18n pour seed + noms libres pour custom** | Préserve l'expérience bilingue sans table de traduction BDD. Renderer : clé i18n si elle existe, sinon nom brut. |
| Nouveaux profils après MAJ | **v1 automatique** | Pas de friction pour un nouvel utilisateur sans historique. v1 devient le nouveau standard. |
| Rétention bannière "Rétablir la sauvegarde" | **90 jours** | Laisse le temps à plusieurs cycles mensuels pour détecter un problème. Le fichier SREF lui-même n'est jamais supprimé par l'app. |
| Granularité L3 (~90 feuilles) | **Toutes actives par défaut** | Simplicité, prévisibilité. L'utilisateur peut désactiver via `is_active=0` depuis les paramètres. Overhead négligeable (tree virtualisable). |
| Invitation des profils existants | **Bannière dashboard one-shot + entrée permanente dans Paramètres** | Équilibre entre découvrabilité et respect de l'utilisateur. Ni silent (adoption lente), ni modale bloquante (intrusif). |
| Catégories custom pendant la migration | **Préservées sous parent "Catégories personnalisées (migration)"** | Aucune perte de données ou de règles custom. L'utilisateur déplace à son rythme. Friction minimale à la migration. |
| Colonne `categories.icon` | **Garder NULL, décision reportée** | Ne pas élargir le scope. Issue UI séparée décidera du renderer (emojis / lucide-react / SVG). |
| Tests | **Unitaires + intégration + régression + QA manuelle** | Feature destructive sur données utilisateur → couverture complète justifiée. Algo ventillage = unitaires, migration end-to-end = intégration, non-régression = régression. |
## References
| Source | Pertinence |
|--------|------------|
| [IPC Statistique Canada — panier 2024](https://www150.statcan.gc.ca/n1/pub/62f0014m/62f0014m2025003-fra.htm) | Les 8 composantes principales officielles structurent les racines L1 v1. Référence "quasi-PCGR" pour les dépenses des ménages au Canada. |
| [Statistique Canada — Enquête sur les dépenses des ménages (EDM)](https://www23.statcan.gc.ca/imdb/p2SV_f.pl?Function=getSurvey&SDDS=3508) | Fournit la granularité L2/L3 avec >650 codes de classification détaillés. |
| [Monarch Money — Default Categories](https://help.monarch.com/hc/en-us/articles/360048883851-Default-Categories) | Benchmark 3 niveaux, ~60 catégories par défaut. Inspiration pour la granularité fine (ex: Food & Dining → Groceries, Restaurants, Coffee). |
| Spike `seed-standard` (archivé) | Analyse complète du modèle de données, taxonomie v1 draftée, mapping v2→v1 à 88% automatisable, wireframes 3-étapes. Input direct pour Phase 3b. |
| `src-tauri/src/commands/export_import_commands.rs:8-49` | Format SREF v0.1 + AES-256-GCM + Argon2id réutilisable tel quel pour le backup pre-migration. |
| `src/services/dataExportService.ts:7-10, 199` | Mode `transactions_with_categories` + `importCategoriesOnly()` pour le flow backup/restore. |
| `src/services/categoryService.ts:55-60, 68-74, 175-186` | Règles `is_inputable` auto-gérées + limite 3 niveaux enforced. Le nouveau seed doit respecter ces invariants. |

View file

@ -0,0 +1,348 @@
# Spec — Migration des tokens OAuth vers le keychain OS (#66)
## Contexte
Simpl'Résultat utilise depuis la v0.7.0 un flux OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (`access_token`, `refresh_token`, `id_token`) sont actuellement persistés dans le filesystem utilisateur :
- **Chemin** : `<app_data_dir>/auth/tokens.json`
- **Protection Unix** : permissions `0600` (owner-only) via `OpenOptionsExt::mode(0o600)`
- **Protection Windows** : aucune (le fichier est écrit avec `fs::write` sans ACL particulière)
- **Commentaire de code source** (auth_commands.rs:7-11) : la solution actuelle est explicitement documentée comme transitoire, en attendant une migration vers le keychain OS.
**Objectif de la présente spec** : migrer le stockage des tokens OAuth de fichier plat vers le keychain OS natif (Credential Manager sur Windows, Secret Service sur Linux), avec migration transparente pour les utilisateurs existants et fallback gracieux si le keychain est indisponible.
**Référence** : CWE-312 (Cleartext Storage of Sensitive Information), issue Forgejo #66, liée à #51 (OAuth2 PKCE initial).
---
## Problème
### Ce qui est stocké en clair aujourd'hui
Le fichier `auth_commands.rs` écrit et lit 2 fichiers sensibles via la fonction `write_restricted()` (l.77-97) :
| Fichier | Contenu | Sensibilité | Usage |
|---|---|---|---|
| `tokens.json` | `access_token`, `refresh_token`, `id_token`, `expires_at` | **HAUTE** | Tous les appels API authentifiés, rotation via refresh |
| `account.json` | email, nom, picture, `subscription_status` | Moyenne | Affichage UI du compte, gating des features payantes |
| `last_check` | timestamp unix (dernier check abonnement) | Aucune | Throttling du polling Logto (24h) |
### Trou principal : Windows
La fonction `write_restricted()` utilise `OpenOptionsExt::mode(0o600)` uniquement sous `#[cfg(unix)]`. Sur Windows, le fallback est un `fs::write` brut — aucune ACL appliquée. Or Windows est une plateforme cible supportée. Un autre processus utilisateur peut lire le fichier.
### Trou secondaire : tous les OS
Même avec `0600` sur Linux, le contenu reste :
- Accessible à n'importe quel process tournant sous le même UID (malware utilisateur, extensions de navigateur local exfiltrant `$HOME`, outils de debug mal configurés, backups non chiffrés synchronisés sur le cloud)
- Inclus dans les backups de dossier home par défaut
- Lisible si l'attaquant obtient un shell non-root sur la machine
Le refresh token en particulier donne une session longue durée (rotation mais pas d'expiration courte) et représente le pire-cas d'exposition.
### Pourquoi c'est le bon moment
1. **Avant la monétisation** : la session Logto gatera l'accès aux features payantes (#53 machine activation, #50 Stripe). Un refresh token volé = contournement du gating de licence.
2. **La dette est connue** : le code lui-même documente l'intention de migrer (auth_commands.rs:7-8).
3. **Changement bien scopé** : le flux OAuth est stabilisé depuis v0.7.3, l'API de stockage est centralisée dans un seul fichier.
---
## Solution proposée
### Crate `keyring` (v3)
> **🟡 SECURITE** — Pas de pinning de version ni de revue supply-chain. `keyring` v3 tire `secret-service` qui pulls `zbus` et un large graphe D-Bus — surface d'attaque non négligeable pour une app privacy-first.
> **Resolution :** Pinner `keyring = "3.x"` explicitement dans Cargo.toml, ajouter `cargo audit` à check.yml après l'ajout de la dep, et documenter la chaîne de deps transitives dans l'ADR.
> *Ref : OWASP A06:2021*
Wrapper Rust maintenu au-dessus des keychains natifs :
| OS | Backend | Installé par défaut |
|---|---|---|
| Windows | Credential Manager (Win32 API) | Oui |
| Linux | Secret Service API via D-Bus (GNOME Keyring / KWallet) | **Non** — nécessite `libsecret-1-0` |
| macOS | Keychain Services (hors cible) | Oui |
Alternatives considérées :
- **`tauri-plugin-stronghold`** : chiffrement au repos avec une master password. Rejeté car demande à l'utilisateur de saisir une passphrase supplémentaire, ce qui casse l'UX de connexion silencieuse (refresh automatique au démarrage).
- **`tauri-plugin-store` + chiffrement custom** : il faudrait gérer une clé maître quelque part — on déplace le problème.
- **AES-256-GCM avec clé dérivée du PIN** : seulement viable pour les profils avec PIN, pas pour les tokens OAuth qui doivent être lus sans interaction.
`keyring` est le bon compromis : le système d'exploitation gère déjà la clé maître (session utilisateur).
### Scope
> **🟡 SECURITE** — L'exclusion de `account.json` ignore le tampering de `subscription_status`. Un malware local peut écrire `"active"` dedans pour bypass le gating de licence sans toucher au keychain.
> **Resolution :** Re-valider `subscription_status` depuis l'id_token/userinfo à chaque décision de gating, OU signer la valeur en cache, OU inclure account.json dans la migration keychain.
> *Ref : CWE-345*
> **🟢 ARCHITECTURE** — Scope limité aux tokens est le bon compromis : blast radius minimal, `write_restricted()` réutilisé tel quel, valeur sécurité ≈ coût du refactor. Garder tel quel et justifier l'asymétrie dans l'ADR.
**Migration uniquement de `tokens.json`**. Raisons :
- `account.json` contient de l'info d'affichage non-sensible (email déjà visible dans le menu, picture URL HTTP publique). Pas un secret opérationnel.
- `last_check` est un timestamp.
- Limiter le blast radius du changement, garder `write_restricted()` pour le reste.
- Permet un rollback ciblé si le keychain pose problème en production.
### Architecture
Nouveau module `src-tauri/src/auth/token_store.rs` exposant 3 fonctions :
```rust
pub struct StoredTokens { /* identique à aujourd'hui */ }
pub fn save(app: &AppHandle, tokens: &StoredTokens) -> Result<(), String>;
pub fn load(app: &AppHandle) -> Result<Option<StoredTokens>, String>;
pub fn delete(app: &AppHandle) -> Result<(), String>;
```
> **🔴 SECURITE + ARCHITECTURE + TECHNIQUE** — Service keychain ne correspond pas à l'identifiant bundle réel (`com.simpl.resultat` dans tauri.conf.json vs `com.lacompagniemaximus.simpl-resultat` dans la spec).
> **Resolution :** Utiliser `com.simpl.resultat` (l'identifiant canonique de l'app) dans la constante, les commandes `secret-tool` de test et l'ADR. Aligner sinon tauri.conf.json en premier.
> *Ref : CWE-1270*
> **🟡 ARCHITECTURE + TECHNIQUE** — Nouveau top-level module `auth/` casse la convention `commands/`.
> **Resolution :** Placer le module à `src-tauri/src/commands/token_store.rs` et l'enregistrer dans `commands/mod.rs` comme les autres. Pas de nouveau répertoire à créer.
**Conventions :**
- Une seule entrée keychain : `service = "com.lacompagniemaximus.simpl-resultat"`, `user = "oauth-tokens"`
- `value = serde_json::to_string(&StoredTokens)` (JSON compact, pas pretty)
- Le module `auth_commands.rs` ne touche plus jamais `tokens.json` directement — tous les accès passent par `token_store`.
### Implémentation — save()
> **🔴 SECURITE + ARCHITECTURE** — Fallback silencieux annule l'objectif de sécurité. Sur Windows le fallback n'applique aucune ACL, et un user qui installe le `.deb` sans libsecret continuera à stocker ses tokens en clair sans aucun signal visible.
> **Resolution :** Sur Windows, appliquer une DACL restrictive via `SetNamedSecurityInfoW` lors du fallback (ou fail-closed et forcer reauth). Exposer l'état `store_mode: keychain|file` à la frontend pour afficher une bannière de sécurité si le fallback est actif.
> *Ref : CWE-276*
```
1. Essayer keyring::Entry::new(SERVICE, USER).set_password(&json)
2. Si OK : supprimer tokens.json résiduel (migration nettoyage)
3. Si KO : fallback write_restricted() sur tokens.json + log warning
```
### Implémentation — load()
> **🔴 SECURITE** — Migration laisse des tokens en clair récupérables. `fs::remove_file` ne zéroifie pas les blocs disque, et le refresh token (long-lived) reste récupérable via unallocated sectors ou backups existants — précisément le threat model cité dans la spec.
> **Resolution :** Avant `remove_file`, overwrite le contenu avec des zéros et `fsync()`, puis delete. Documenter dans le changelog la recommandation de rotation de session post-migration pour les utilisateurs inquiets des backups passés.
> *Ref : CWE-212*
```
1. Essayer keyring::Entry::new(...).get_password()
2. Si OK et valeur présente : désérialiser et retourner Some(tokens)
3. Si KO ou vide : tenter de lire tokens.json
3a. Si tokens.json présent : migrer (keyring write + file delete) et retourner Some(tokens)
3b. Si tokens.json absent : retourner Ok(None)
```
C'est dans cette fonction que la **migration transparente** se fait : à la première lecture après upgrade, les tokens passent du fichier au keychain.
### Implémentation — delete()
```
1. Supprimer keychain entry (ignorer les erreurs "no entry")
2. Supprimer tokens.json (ignorer si absent)
```
Double-delete pour éviter les états "fantôme" où un reliquat traîne dans l'un des deux stores.
### Refactor d'`auth_commands.rs`
Tous les call sites qui manipulent actuellement `TOKENS_FILE` passent par `token_store` :
| Ligne actuelle | Opération | Nouveau code |
|---|---|---|
| 202 (handle_auth_callback) | write après exchange | `token_store::save(&app, &tokens)` |
| 219-227 (refresh_auth_token) | read | `token_store::load(&app)?` |
| 277 (refresh_auth_token) | write après rotation | `token_store::save(&app, &new_tokens)` |
| 251 (refresh_auth_token) | delete sur échec refresh | `token_store::delete(&app)` |
| 305 (logout) | delete | `token_store::delete(&app)` |
| 320 (check_subscription_status) | exists check | `token_store::load(&app)?.is_some()` |
La constante `TOKENS_FILE` est supprimée du fichier (reste `ACCOUNT_FILE` et `LAST_CHECK_FILE` gérés par `write_restricted` comme avant).
### Fallback gracieux
> **🟡 SECURITE** — Aucune distinction entre "keychain n'a jamais marché" et "keychain marchait hier et échoue aujourd'hui". Un process hostile local pourrait forcer la dégradation.
> **Resolution :** Persister un flag `store_mode` dans `app_data_dir/auth/`. Si le keychain a déjà fonctionné, un échec ultérieur doit refuser le plaintext et forcer reauth au lieu de dégrader silencieusement.
> *Ref : CWE-757*
Le fallback fichier s'active quand :
- L'appel `keyring::Entry::new()` retourne une erreur (keyring crate indisponible)
- `set_password()` / `get_password()` retourne `PlatformFailure` ou équivalent
- Spécifique Linux : D-Bus non démarré, libsecret absent, session sans keyring déverrouillé
Comportement attendu :
- `save()` et `load()` logguent un warning une seule fois par session (pas de spam)
- L'app reste fonctionnelle, juste avec le filet de sécurité `0600` (Unix) ou rien (Windows sans keychain — très improbable puisque Credential Manager est toujours présent)
- Aucune fenêtre d'erreur ne s'affiche à l'utilisateur
### Migration des utilisateurs existants
> **🟡 TECHNIQUE** — Timing incertain. `check_subscription_status` a un throttle 24h et un early-return sur `last_check` récent — un user qui relance souvent l'app peut voir `tokens.json` traîner indéfiniment.
> **Resolution :** Remplacer le check `dir.join(TOKENS_FILE).exists()` à auth_commands.rs:320 par `token_store::load(&app)?.is_some()` — ça force la migration dès la première lecture, sans attendre le throttle.
À la prochaine lecture (`refresh_auth_token` au démarrage via `check_subscription_status`), `token_store::load()` détecte `tokens.json` résiduel, le copie dans le keychain, et supprime le fichier. **Pas de reconnexion forcée, pas de notification.**
Si le keychain est indisponible, le fichier reste en place avec ses permissions `0600` — comportement équivalent à aujourd'hui.
---
## Impact CI/CD et packaging
### Linux — dépendance libsecret
> **🔴 SECURITE** — Cible AppImage oubliée. `tauri.conf.json` inclut `appimage` dans `bundle.targets` — ces builds n'héritent pas des deps apt et ne bundlent pas libsecret par défaut. Chaque user AppImage retombe silencieusement dans le fallback plaintext.
> **Resolution :** Soit bundler libsecret via `linuxdeploy` dans le build AppImage, soit retirer AppImage du scope v0.8, soit documenter `libsecret-1-0` comme pré-requis système dans les release notes AppImage.
> **🔴 TECHNIQUE** — `.forgejo/workflows/release.yml` n'est pas mentionné. Le build Linux de release échouera au linking si libsecret-1-dev n'est pas installé là aussi.
> **Resolution :** Ajouter `libsecret-1-dev` aux steps d'install système Linux de `release.yml` en plus de `check.yml`. Lister explicitement les deux workflows dans la spec.
Le crate `keyring` sous Linux utilise `libsecret` via D-Bus. Il faut :
1. **Dev** : `libsecret-1-dev` installé sur les machines de build (pop-os du dev + workers Forgejo Actions). À vérifier si déjà présent.
2. **Build .deb** : ajouter `libsecret-1-0` aux `depends` dans `tauri.conf.json``bundle.linux.deb.depends`.
3. **Build .rpm** : ajouter `libsecret` aux `depends` dans `bundle.linux.rpm.depends`.
4. **CI `check.yml`** : installer `libsecret-1-dev` avant `cargo check` / `cargo test`.
Sans ces ajouts, l'app **compile** mais au runtime le keychain échoue → fallback fichier → warning log. L'app reste fonctionnelle, mais la valeur de sécurité du changement est annulée pour les utilisateurs qui installent via `.deb` sans la dépendance.
### Windows
Credential Manager est un service Windows built-in, toujours disponible. Aucune dépendance à déclarer.
### Contrainte taille Credential Manager
> **🟢 SECURITE** — Plan B "access_token en RAM only" crée une race au cold-start offline : chaque démarrage doit hit Logto avant toute call authentifiée, cassant la promesse offline-first pour 24h.
> **Resolution :** Si la limite 2.5 KB est atteinte, stocker `access_token` et `refresh_token` dans **deux entrées keychain distinctes** (user=access, user=refresh) plutôt que sortir l'access token du store.
La limite théorique d'un credential Windows est ~2.5 KB (valeur stockée dans le `CRED_BLOB`). Un `StoredTokens` sérialisé pèse typiquement 1-1.8 KB (3 JWT + timestamp). À mesurer avec un vrai payload Logto avant le merge. Si on approche la limite, fallback possible : stocker uniquement le refresh token dans le keychain, garder l'access token en mémoire (il expire en 1h de toute façon).
---
## Tests
### Tests unitaires (impossibles pour la vraie partie keychain)
> **🟡 ARCHITECTURE + TECHNIQUE** — L'injection de trait `Backend` est YAGNI et techniquement infaisable : `keyring` v3 expose une struct `Entry` concrète sans trait public à swapper. Ajouter un wrapper trait juste pour les tests = abstraction sans contrepartie.
> **Resolution :** Drop le trait injection. Tester uniquement le round-trip serde sur `StoredTokens` et le chemin fallback fichier (qui ne touche pas keyring). Marquer `#[ignore]` tous les tests qui nécessitent un vrai keychain.
Le crate `keyring` ne fournit pas de mock officiel. On teste :
- La sérialisation / désérialisation `StoredTokens` (déjà couvert implicitement)
- La logique de migration via un fake backend (trait `Backend` injecté pour les tests)
### Tests manuels obligatoires avant merge
Sur **pop-os** (dev) :
1. **Fresh install** : supprimer `<app_data>/auth/`, lancer l'app, se connecter, vérifier qu'aucun fichier `tokens.json` n'est créé, vérifier via `secret-tool lookup service com.lacompagniemaximus.simpl-resultat user oauth-tokens`
2. **Migration** : créer un `tokens.json` artificiel (en repartant d'une ancienne version), lancer la nouvelle version, vérifier que le fichier est supprimé et le secret présent dans le keychain après le premier refresh
3. **Logout** : vérifier que le keychain entry ET le fichier résiduel sont effacés
4. **Fallback** : masquer D-Bus (`DBUS_SESSION_BUS_ADDRESS=/dev/null`), vérifier que l'app fonctionne et que le fallback fichier s'active
Sur **Windows** (VM ou machine dédiée) :
1. Fresh install + login → vérifier présence dans Credential Manager (`rundll32.exe keymgr.dll,KRShowKeyMgr`)
2. Migration depuis un `tokens.json` artificiel
3. Logout
### Tests CI
> **🔴 TECHNIQUE** — `check.yml` a deux jobs distincts (`rust` et `frontend`, conteneur ubuntu:22.04). La spec ne le précise pas et parle d'"installer avant cargo check" comme si c'était une seule étape.
> **Resolution :** Éditer uniquement le step **"Install system dependencies"** du job `rust` — append `libsecret-1-dev` à la liste `apt-get install` existante. Ne pas toucher le job frontend.
`check.yml` doit :
- Installer `libsecret-1-dev` avant `cargo check` / `cargo test`
- `cargo test` passe (les tests qui nécessitent un vrai keychain sont marqués `#[ignore]`, exécutés manuellement)
---
## Documentation à mettre à jour
> **🟡 ARCHITECTURE + TECHNIQUE** — Format de numérotation ADR incorrect. Les ADRs existants (0001-0005) utilisent un préfixe 4 chiffres sans `adr-`. De plus, `Security` n'est pas une catégorie du CHANGELOG du projet (voir `.claude/rules/changelog.md`).
> **Resolution :** Nommer le fichier `docs/adr/0006-oauth-tokens-keychain.md`. Classer l'entrée changelog sous `Changed` (ou `Fixed` si framed comme correction de vulnérabilité), pas `Security`.
- `docs/architecture.md` — section "Stockage" et "Commandes Tauri" : mentionner `token_store`, mettre à jour le diagramme de stockage auth
- `docs/adr/` — nouvel ADR `adr-006-oauth-tokens-keychain.md` décrivant la décision (contexte, options considérées, fallback)
- `CHANGELOG.md` / `CHANGELOG.fr.md` — section `Security` :
- EN : `Migrated OAuth tokens storage from plaintext JSON file to OS keychain (Credential Manager on Windows, Secret Service on Linux). Existing users are migrated transparently on first token refresh.`
- FR : `Migration du stockage des tokens OAuth d'un fichier JSON en clair vers le keychain du système (Credential Manager sous Windows, Secret Service sous Linux). Les utilisateurs existants sont migrés de façon transparente au premier rafraîchissement du token.`
---
## Critères d'acceptance (issue #66)
- [x] Tokens stockés dans le keychain OS → via `keyring` crate
- [x] Fallback gracieux si keychain indisponible → fallback `write_restricted()` avec warning logué
- [x] Migration automatique des fichiers existants → dans `token_store::load()` au premier appel
- [ ] Linux packaging : `libsecret-1-0` ajouté aux dépendances `.deb` / `.rpm`
- [ ] CI `check.yml` : `libsecret-1-dev` installé avant les tests
- [ ] ADR rédigé et mergé dans `docs/adr/`
- [ ] Tests manuels passés sur pop-os + Windows (3 scénarios chacun)
---
## Estimation
> **🟢 TECHNIQUE** — Estimation optimiste. N'inclut pas les debug cycles CI, ni les surprises de linking pkg-config dans le conteneur ubuntu:22.04 du job `rust`, ni le first-run libsecret de validation.
> **Resolution :** Monter à **4-5h** et prévoir un premier push CI dédié à valider que libsecret compile avant d'écrire les tests.
- Module `token_store` + Cargo.toml : 45 min
- Refactor `auth_commands.rs` : 20 min
- Mise à jour packaging (tauri.conf.json + check.yml) : 15 min
- Tests manuels pop-os : 30 min
- Tests manuels Windows (si VM dispo) : 30 min
- ADR + changelog + PR : 20 min
**Total : ~2h30 à 3h**
---
## Risques identifiés
| Risque | Probabilité | Sévérité | Mitigation |
|---|---|---|---|
| `libsecret` absent sur install `.deb` minimale → fallback silencieux annule le bénéfice | Moyenne | Moyenne | Ajouter aux deps `.deb`, documenter dans changelog |
| Credential Manager dépasse la limite 2.5 KB | Basse | Haute | Mesurer avant merge, plan B : refresh token only au keychain |
| Migration échoue silencieusement (fichier gardé) | Basse | Basse | Double-write acceptable, logué en warning |
| Régression du flux OAuth existant (utilisateurs v0.7.x) | Basse | Haute | Tests manuels exhaustifs des 3 scénarios sur 2 OS |
| Keyring Linux demande un déverrouillage GNOME Keyring au démarrage | Moyenne | Basse | C'est le comportement attendu — documenter dans le changelog |
---
## Questions ouvertes
1. **Scope account.json** — on laisse hors scope comme proposé, ou on migre aussi ? Recommandation : hors scope.
2. **libsecret dans `.deb`** — ajout immédiat ou follow-up ? Recommandation : immédiat, sinon la migration n'a aucune valeur pour la majorité des utilisateurs Linux.
3. **ADR format** — réutiliser le gabarit existant de `docs/adr/` (à vérifier s'il y en a un).
---
## Revision — Synthese
> Date: 2026-04-13 | Experts: Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — La spec est solide sur les principes, mais 6 trous critiques sont à colmater avant implémentation : identifiant bundle incohérent, fallbacks silencieux qui annulent le gain de sécurité, plaintext récupérable post-migration, AppImage et release.yml oubliés, structure CI mal comprise.
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|-----|-----|-----|-------------|
| Securite | 3 | 3 | 1 | Fallback silencieux, plaintext résiduel, AppImage, subscription tampering, supply chain |
| Architecture | 1 | 3 | 1 | Bundle id, module path, YAGNI trait, ADR numbering, scope OK |
| Technique | 3 | 2 | 1 | release.yml oublié, check.yml mal lu, migration timing, estimation optimiste |
### Actions requises
1. 🔴 **Bundle identifier** — aligner service keychain sur `com.simpl.resultat` (tauri.conf.json)
2. 🔴 **Fallback save() non-silencieux** — DACL Windows OU fail-closed, exposer `store_mode` au frontend
3. 🔴 **Zéroification avant delete** — overwrite + fsync avant `fs::remove_file` des tokens migrés
4. 🔴 **AppImage libsecret** — bundler via linuxdeploy, retirer du scope, ou documenter le pré-requis
5. 🔴 **release.yml libsecret-1-dev** — ajouter aux steps Linux sinon le build release casse
6. 🔴 **check.yml job rust uniquement** — append à "Install system dependencies", pas de nouveau step
7. 🟡 **Module path**`src-tauri/src/commands/token_store.rs`, pas de nouveau top-level `auth/`
8. 🟡 **Fallback integrity** — flag `store_mode` persisté, refus du downgrade si keychain a déjà marché
9. 🟡 **Migration timing** — remplacer `TOKENS_FILE.exists()` ligne 320 par `token_store::load()?.is_some()`
10. 🟡 **Pin keyring + cargo audit**`keyring = "3.x"` explicite, `cargo audit` dans CI
11. 🟡 **subscription_status integrity** — re-valider ou signer la valeur en cache
12. 🟡 **Drop trait Backend** — tester uniquement round-trip serde + fallback fichier
13. 🟡 **ADR format**`0006-oauth-tokens-keychain.md`, changelog sous `Changed` (pas `Security`)

588
spec-monetisation.md Normal file
View file

@ -0,0 +1,588 @@
# Spec — Monétisation Simpl'Résultat
> Date: 2026-04-08
> Projet: simpl-resultat
> Statut: En cours (Phase 1 complétée)
> Dépendances: spec-simpl-resultat-web.md (version web/SaaS), Logto IdP (Compte Maximus)
## Contexte
Simpl'Résultat est une app desktop Tauri v2 open-source (GPL-3.0) pour la gestion des finances personnelles, actuellement gratuite. Le moment de monétiser approche. L'objectif est de mettre en place l'infrastructure complète pour vendre le logiciel desktop (achat unique) et offrir une version web SaaS (abonnement mensuel), tout en préservant le principe privacy-first.
**Modèle retenu : Open Core + Hybride**
- Le code desktop reste GPL-3.0 sur Forgejo (open core)
- Le code serveur (API licence, paiement, web/SaaS) est propriétaire
- Achat unique pour l'édition Base desktop
- Abonnement mensuel pour l'édition Premium (version web + sync + features avancées)
## Objectif
Implémenter les 3 piliers de la monétisation : (1) un système de licence offline pour l'app desktop, (2) l'intégration du Compte Maximus optionnel pour les features premium, et (3) l'infrastructure de paiement (achat unique + abonnement). La version web/SaaS est couverte par le spec existant `spec-simpl-resultat-web.md`.
## Scope
### IN
- Système de clé de licence offline (Ed25519, vérification côté Rust)
- API serveur de licences (génération, validation, révocation)
- Connexion optionnelle au Compte Maximus (Logto) dans l'app desktop
- Page d'achat sur lacompagniemaximus.com (processeur de paiement)
- Gestion des abonnements (Stripe Billing ou alternative)
- UI licence et compte dans les paramètres de l'app desktop
- Webhook paiement → génération de licence automatique
- Gestion TPS/TVQ pour les ventes au Canada
### OUT (explicitement exclu)
- Version web/SaaS (couvert par `spec-simpl-resultat-web.md`)
- Sync desktop ↔ web (couvert par `spec-simpl-resultat-web.md`, Issue 5)
- DRM agressif ou protection anti-compilation (incompatible GPL)
- App mobile
- Programme de revente / affiliés
- Facturation papier / comptabilité (usage externe, ex: Wave/QuickBooks)
## Design
### Modèle de tarification
| Tier | Prix | Contenu | Licence |
|------|------|---------|---------|
| **Gratuit** | 0$ | App desktop complète, sans clé, mises à jour manuelles uniquement | Aucune |
| **Base** | ~29-49$ CAD (unique) | App desktop + mises à jour automatiques + support email | Clé offline Ed25519 |
| **Premium** | ~7-12$ CAD/mois | Base + version web + sync desktop↔web + features avancées futures | Compte Maximus (abonnement actif) |
> **Note :** Les prix exacts seront déterminés avant le lancement. Les fourchettes ci-dessus sont des recommandations basées sur le marché des apps de finances personnelles.
### Architecture de licence
#### Clé de licence offline (Édition Base)
Format : JWT signé Ed25519, encodé Base64, vérifiable sans serveur.
```
SR-BASE-<base64url(JWT)>
```
**Payload JWT :**
```json
{
"sub": "user@email.com",
"iss": "lacompagniemaximus.com",
"iat": 1712534400,
"edition": "base",
"features": ["auto-update"],
"machine_limit": 3
}
```
> **🔴 SECURITE** — JWT sans claim `exp` : une licence signée est valide à jamais, même après révocation serveur, car la vérification offline ne peut pas checker le statut de révocation.
> **Résolution :** Ajouter un claim `exp` obligatoire (ex: 2 ans). L'app doit demander une re-validation en ligne avant expiration pour obtenir une clé rafraîchie.
> *Ref : CWE-613 (Insufficient Session Expiration)*
**Flux de validation :**
1. L'utilisateur entre sa clé dans Paramètres > Licence
2. Le backend Rust décode le JWT et vérifie la signature Ed25519 avec la clé publique embarquée
3. Si valide : stocke la clé dans un fichier `license.key` dans le répertoire app data
4. L'app vérifie la clé au démarrage (lecture locale, aucun appel réseau)
5. Features débloquées : mises à jour automatiques (l'updater vérifie la présence d'une licence valide)
**Sécurité :**
- La clé privée Ed25519 réside UNIQUEMENT sur le serveur de licences (VPS)
- La clé publique est embarquée dans le binaire Rust (hardcoded)
- Opérations crypto dans Rust uniquement (jamais dans le WebView)
- `machine_limit` vérifié lors de l'activation en ligne (premier lancement)
> **🔴 SECURITE** — `license.key` en clair est copiable entre machines, contournant `machine_limit`. La vérification offline ne peut pas détecter la copie car il n'y a pas de binding machine dans la signature JWT.
> **Résolution :** Stocker un token d'activation séparé (signé par le serveur avec le `machine_id` inclus). Vérifier à la fois le JWT licence + le token d'activation au démarrage.
> **🔴 TECHNIQUE** — Le spec propose `ed25519-dalek` mais c'est `jsonwebtoken` (avec feature EdDSA) qui décode les JWT. `ed25519-dalek` seul ne gère pas les headers/claims JWT.
> **Résolution :** Clarifier dans l'Issue 1 que `jsonwebtoken` est la dépendance primaire pour la validation JWT. `ed25519-dalek` peut ne pas être nécessaire si `jsonwebtoken` supporte EdDSA nativement.
#### Compte Maximus (Édition Premium)
L'intégration Logto dans l'app desktop utilise le flow OAuth2 PKCE :
1. L'utilisateur clique "Se connecter" dans Paramètres
2. Tauri ouvre le navigateur système vers Logto (OAuth2 Authorization Code + PKCE)
3. Après auth, callback vers `simpl-resultat://auth/callback`
4. L'app échange le code pour un access token + refresh token
5. Les tokens sont stockés de façon sécurisée (fichier chiffré dans app data)
> **🔴 SECURITE** — Le chiffrement des tokens avec une clé dérivée du machine ID est faible. Les machine IDs sont à faible entropie, déterministes et lisibles publiquement (ex: `/etc/machine-id` sur Linux). Équivaut à chiffrer avec une clé connue.
> **Résolution :** Utiliser le stockage natif de l'OS (keyring/credential manager via le crate `keyring` ou `tauri-plugin-store` avec OS keychain). Si basé fichier, dériver la clé du machine ID + un secret aléatoire par installation stocké séparément.
> *Ref : CWE-321 (Hard Coded Cryptographic Key)*
> **🟡 TECHNIQUE** — Le flow OAuth2 PKCE via `simpl-resultat://auth/callback` nécessite une config plateforme spécifique (registre Windows, etc.) non documentée dans le spec. `tauri-plugin-deep-link` v2 requiert des entrées explicites dans `tauri.conf.json` et les manifestes plateforme.
> **Résolution :** Ajouter une sous-tâche à l'Issue 6 pour la registration deep-link par plateforme. Référencer la doc `tauri-plugin-deep-link` v2.
6. L'access token JWT contient les claims : `{ "apps": {"simpl-resultat": "premium"}, "subscription_status": "active" }`
7. Vérification périodique du statut d'abonnement (1x/jour, graceful si offline)
**Dégradation gracieuse :**
- Si le token expire et le refresh échoue → affiche un avertissement mais ne bloque pas l'usage Base
- Si l'abonnement expire → l'app repasse en mode Base (clé offline toujours valide)
- Grace period de 7 jours après expiration de l'abonnement
### Comparaison des processeurs de paiement
| Critère | Stripe | Square | Paddle | LemonSqueezy | FastSpring |
|---------|--------|--------|--------|--------------|------------|
| **Frais** | 2.9% + 0.30$ | 2.9% + 0.30$ | 5% + 0.50$ | 5% | ~8.9% |
| **Merchant of Record** | Non (tu gères les taxes) | Non | Oui | Oui | Oui |
| **Gestion TPS/TVQ** | Via Stripe Tax (add-on) | Manuel | Inclus | Inclus | Inclus |
| **Abonnements** | Stripe Billing (excellent) | Square Subscriptions | Natif | Natif | Natif |
| **Clés de licence** | Non (API custom) | Non | Non | Intégré | Intégré |
| **API/Webhooks** | Excellent | Bon | Bon | Bon | Bon |
| **Desktop software** | Bon (généraliste) | Faible (focus commerce) | Bon | Bon | Excellent (spécialisé) |
| **Canada** | Oui (HQ Montréal inaccessible mais opère au Canada) | Oui | Oui | Oui (Stripe subsidiary) | Oui |
| **Paiement CAD** | Oui | Oui | Oui | Oui | Oui |
**Recommandation : Stripe** pour les raisons suivantes :
- Frais les plus bas (2.9% + 0.30$)
- API la plus robuste et documentée
- Stripe Billing pour les abonnements récurrents
- Stripe Tax pour la collecte TPS/TVQ automatique (0.5% add-on)
- Stripe Checkout pour une page de paiement hébergée (conforme PCI sans effort)
- Webhooks fiables pour automatiser la génération de licences
- LemonSqueezy est maintenant une filiale Stripe — si tu veux le MoR plus tard, migration facile
**Alternative viable : Paddle/LemonSqueezy** si tu veux zéro gestion fiscale (MoR). Plus cher (~5%) mais ils gèrent TPS/TVQ/TVA mondialement. Recommandé si tu vends internationalement dès le départ.
**Square : Non recommandé** pour la vente de logiciel. Orienté commerce de détail/point de vente physique. API d'abonnement moins mature que Stripe. Pas de Merchant of Record. Pas d'intégration licence.
### UX / Interface
#### Paramètres > Licence (nouveau card)
```
┌─────────────────────────────────────────────┐
│ 🔑 Licence │
│ │
│ Édition : Gratuite │
│ │
│ [Entrer une clé de licence] │
│ │
│ ─────────────────────────────────────────── │
│ │
│ Acheter Simpl'Résultat Base — 39$ CAD │
│ → Ouvre le navigateur vers la page d'achat │
│ │
│ Découvrir Premium — 9$/mois │
│ → Ouvre le navigateur vers la page Premium │
└─────────────────────────────────────────────┘
```
#### Paramètres > Compte Maximus (nouveau card, sous Licence)
```
┌─────────────────────────────────────────────┐
│ 👤 Compte Maximus Optionnel │
│ │
│ Non connecté │
│ │
│ [Se connecter] │
│ │
│ Le compte est requis uniquement pour les │
│ fonctionnalités Premium (version web, sync). │
└─────────────────────────────────────────────┘
```
#### État connecté + Premium actif :
```
┌─────────────────────────────────────────────┐
│ 🔑 Licence │
│ │
│ Édition : Premium ✓ │
│ Abonnement actif jusqu'au 2026-05-08 │
│ │
│ [Gérer mon abonnement] │
│ → Ouvre Stripe Customer Portal │
├─────────────────────────────────────────────┤
│ 👤 Compte Maximus │
│ │
│ Connecté : max@example.com │
│ Dernière vérification : il y a 2 heures │
│ │
│ [Se déconnecter] │
└─────────────────────────────────────────────┘
```
### Données
#### Fichiers locaux (app data directory)
```
simpl-resultat/
├── profiles.json # Existant
├── license.key # NOUVEAU — clé de licence Base (JWT signé)
├── auth/ # NOUVEAU
│ ├── tokens.enc # Access + refresh tokens chiffrés (AES-256-GCM, clé dérivée du machine ID)
│ └── account.json # Métadonnées du compte (email, edition, subscription_status)
└── profile_*.db # Existant — bases SQLite par profil
```
> Aucune modification au schéma SQLite existant. La licence et l'auth sont stockées hors des bases de profils.
#### API Serveur de licences (propriétaire, sur VPS)
Nouveau microservice déployé sur le VPS (Coolify), base URL : `https://api.lacompagniemaximus.com/licenses`
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/licenses/generate` | POST | Webhook Stripe → génère une clé signée Ed25519 |
| `/licenses/activate` | POST | Activation (vérifie machine_limit, enregistre machine_id) |
| `/licenses/verify` | POST | Vérification en ligne (optionnelle, pour refresh status) |
| `/licenses/deactivate` | POST | Désactive une machine (libère un slot) |
| `/licenses/revoke` | POST | Révoque une licence (admin) |
| `/subscriptions/status` | GET | Statut d'abonnement (pour l'app desktop, auth JWT) |
| `/subscriptions/webhook` | POST | Webhook Stripe Billing (subscription events) |
> **🟡 SECURITE** — Aucune authentification ou rate limiting décrit sur les endpoints `/licenses/activate` et `/licenses/verify`. Un attaquant pourrait énumérer des clés valides ou brute-forcer les activations.
> **Résolution :** Requérir le JWT licence comme bearer token pour activation/verify. Ajouter rate limiting (ex: 5 req/min par IP). Utiliser des request bodies signés HMAC pour les webhooks.
> *Ref : OWASP API4:2023 (Unrestricted Resource Consumption)*
> **🟡 SECURITE** — Le spec mentionne les idempotency keys mais ne spécifie pas la vérification de signature des webhooks Stripe (`Stripe-Signature` header). Sans cela, n'importe qui peut forger des appels webhook pour générer des licences gratuites.
> **Résolution :** Exiger explicitement la vérification de signature Stripe webhook via le webhook signing secret dans les handlers `/licenses/generate` et `/subscriptions/webhook`.
> *Ref : CWE-345 (Insufficient Verification of Data Authenticity)*
### Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ DESKTOP (Tauri v2) │
│ │
│ React UI Rust Backend │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ LicenseCard │◄────────────────►│ license_commands │ │
│ │ AccountCard │ │ - validate_key() │ │
│ │ SettingsPage │ │ - read_license() │ │
│ └──────────────┘ │ - store_license() │ │
│ │ - get_edition() │ │
│ └───────────────────┘ │
│ ┌───────────────────┐ │
│ │ auth_commands │ │
│ │ - start_oauth() │ │
│ │ - handle_callback()│ │
│ │ - refresh_token() │ │
│ │ - get_account() │ │
│ │ - logout() │ │
│ └───────────────────┘ │
│ │ │
└─────────────────────────────────────────────┼────────────────────┘
│ HTTPS (optionnel)
┌──────────────────────────────────────────────────────────────────┐
│ SERVEUR (VPS, propriétaire) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Logto (IdP) │ │ License API │ │ Stripe Webhooks │ │
│ │ OAuth2/OIDC │ │ Ed25519 sign │ │ payment events │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └────────────────┼─────────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ │ licenses │ │
│ │ activations │ │
│ │ subscriptions│ │
│ └──────────────┘ │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ lacompagniemaximus.com (page d'achat) │ │
│ │ Stripe Checkout / Customer Portal │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### Schéma PostgreSQL (serveur de licences)
```sql
-- Table des licences
CREATE TABLE licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_email TEXT NOT NULL,
edition TEXT NOT NULL DEFAULT 'base', -- 'base' | 'premium'
license_key TEXT NOT NULL UNIQUE, -- JWT signé complet
stripe_payment_id TEXT, -- Référence Stripe (payment_intent ou subscription)
machine_limit INTEGER NOT NULL DEFAULT 3,
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- NULL = perpétuel (Base), date pour Premium
);
-- Activations (machines liées à une licence)
CREATE TABLE license_activations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
machine_id TEXT NOT NULL, -- Hash unique de la machine
machine_name TEXT, -- Nom lisible (ex: "Max-ThinkPad")
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE(license_id, machine_id)
);
-- Abonnements (lié au Compte Maximus)
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- FK vers Logto user
stripe_subscription_id TEXT NOT NULL UNIQUE,
stripe_customer_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- 'active', 'past_due', 'canceled', 'expired'
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_licenses_email ON licenses(user_email);
CREATE INDEX idx_licenses_stripe ON licenses(stripe_payment_id);
CREATE INDEX idx_activations_license ON license_activations(license_id);
CREATE INDEX idx_activations_machine ON license_activations(machine_id);
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
```
## Plan de travail
### Phase 1 — Infrastructure de licence (desktop)
#### Issue 1 — Commandes Tauri pour la gestion de licence [type:feature] ✅ COMPLÉTÉE
Forgejo: #46 (fermée), PR #56 (mergée 2026-04-09)
- [x] Ajouter les dépendances `jsonwebtoken`, `serde_json` au Cargo.toml
- [x] Créer `src-tauri/src/commands/license_commands.rs` (6 commandes)
- [x] Créer `src-tauri/src/commands/entitlements.rs` (système d'entitlements par édition)
- [x] Embarquer la clé publique Ed25519 dans le code Rust (constante)
- [x] Enregistrer les commandes dans `lib.rs`
- [x] Tests unitaires (licence valide, expirée, signature invalide, clé corrompue)
#### Issue 2 — UI Licence dans les paramètres [type:feature] ✅ COMPLÉTÉE
Forgejo: #47 (fermée), PR #57 (mergée 2026-04-10)
- [x] Créer `src/components/settings/LicenseCard.tsx` — affiche l'édition, permet d'entrer une clé
- [x] Créer `src/services/licenseService.ts` — wrapper des commandes Tauri license_*
- [x] Créer `src/hooks/useLicense.ts` — hook useReducer pour l'état de la licence
- [x] Ajouter les clés i18n dans `fr.json` et `en.json` (section `license.*`)
- [x] Intégrer le LicenseCard dans `SettingsPage.tsx`
- [x] Feedback visuel : édition courante, statut de la clé, erreurs de validation
#### Issue 3 — Conditionner les mises à jour auto à la licence [type:task] ✅ COMPLÉTÉE
Forgejo: #48 (fermée), PR #58 (mergée 2026-04-10)
- [x] Modifier `useUpdater.ts` : vérifier l'entitlement `auto-update` via `check_entitlement`
- [x] Si édition "free" → afficher un message "Mises à jour automatiques disponibles avec l'édition Base"
- [x] Si édition "base" ou "premium" → flux normal de mise à jour
- [x] Mettre à jour les traductions i18n
> **🟡 TECHNIQUE** — L'auto-update est le seul feature gaté par la licence Base. Les utilisateurs peuvent télécharger manuellement les nouvelles releases depuis le repo Forgejo public, rendant le paywall trivialement contournable sans modification du code.
> **Résolution :** Soit gater des features additionnelles (rapports avancés, multi-profil, export chiffré), soit assumer un soft paywall et reframer en "édition supporter". Considérer que la valeur réelle est dans le support + web/Premium.
> **🔴 TECHNIQUE** — Le projet est GPL-3.0-only (`Cargo.toml`, `CLAUDE.md`). Gater des features derrière une clé payante dans du code GPL signifie que quiconque peut forker et retirer le check. Le spec ne traite pas cette tension fondamentale.
> **Résolution :** Accepter explicitement que le gate est un honor system (cohérent avec Open Core), ou déplacer la validation de licence vers un composant serveur. Documenter cette décision dans la section "Décisions prises".
### Phase 2 — Serveur de licences + paiement
#### Issue 4 — API serveur de licences [type:feature] 🔜 PROCHAINE
Forgejo: #49 (ouverte, status:ready)
- [ ] Créer le projet Node.js/Express (ou Hono) pour l'API de licences
- [ ] Générer la paire de clés Ed25519 (clé privée sur le serveur uniquement)
- [ ] Implémenter les endpoints : generate, activate, verify, deactivate, revoke
- [ ] Schéma PostgreSQL (tables licenses, license_activations, subscriptions)
- [ ] Middleware auth (API key pour les webhooks Stripe, JWT Logto pour les endpoints utilisateur)
- [ ] Rate limiting
- [ ] Déployer sur Coolify (api.lacompagniemaximus.com)
- [ ] Tests d'intégration
#### Issue 5 — Intégration Stripe (paiement + webhooks) [type:feature]
Forgejo: #50 (ouverte, status:ready). Dépendances : Issue 4
- [ ] Créer le compte Stripe et configurer pour le Canada (TPS/TVQ via Stripe Tax)
- [ ] Configurer Stripe Checkout pour l'achat unique (édition Base)
- [ ] Configurer Stripe Billing pour l'abonnement mensuel (édition Premium)
- [ ] Configurer Stripe Customer Portal (gestion d'abonnement par l'utilisateur)
- [ ] Implémenter les webhooks Stripe :
- `checkout.session.completed` → générer licence Base + envoyer par email
- `invoice.payment_succeeded` → renouveler/activer abonnement Premium
- `customer.subscription.deleted` → marquer abonnement expiré
- `customer.subscription.updated` → mettre à jour statut
- [ ] Page d'achat sur lacompagniemaximus.com (lien vers Stripe Checkout)
- [ ] Email de confirmation avec clé de licence (via Stripe email ou service dédié)
#### Issue 6 — Intégration Logto dans l'app desktop (Compte Maximus) [type:feature]
Forgejo: #51 (ouverte, status:ready). Dépendances : Issue 4, Logto déployé
> **🟡 ARCHITECTURE** — `get_edition()` dans `license_commands` doit aussi vérifier le statut d'abonnement via `auth_commands`, créant un couplage entre les deux modules. Ce n'est pas documenté.
> **Résolution :** Soit ajouter un `get_edition()` top-level dans `mod.rs` qui combine les deux sources, soit documenter explicitement que `license_commands::get_edition()` délègue à `auth_commands` pour la détection Premium.
- [ ] Ajouter `tauri-plugin-deep-link` pour gérer le callback OAuth2 (`simpl-resultat://auth/callback`)
- [ ] Créer `src-tauri/src/commands/auth_commands.rs` :
- `start_oauth() -> Result<String, String>` — génère PKCE, ouvre le navigateur
- `handle_auth_callback(code: String) -> Result<AccountInfo, String>` — échange code → tokens
- `refresh_auth_token() -> Result<AccountInfo, String>` — refresh le token
- `get_account_info() -> Result<Option<AccountInfo>, String>` — lit les infos stockées
- `logout() -> Result<(), String>` — supprime les tokens
- [ ] Stocker les tokens chiffrés dans `auth/tokens.enc` (AES-256-GCM, clé = machine_id dérivé)
- [ ] Créer `src/components/settings/AccountCard.tsx` — connexion/déconnexion, infos compte
- [ ] Créer `src/services/authService.ts` — wrapper des commandes Tauri auth_*
- [ ] Ajouter les clés i18n
- [ ] Vérification périodique du statut d'abonnement (1x/jour au lancement)
### Phase 3 — Page d'achat et lancement
#### Issue 7 — Page d'achat sur le site web [type:feature]
Forgejo: #52 (ouverte, status:ready). Dépendances : Issue 5
- [ ] Créer la page `/simpl-resultat` ou `/acheter` sur lacompagniemaximus.com
- [ ] Présentation des tiers (Gratuit / Base / Premium) avec comparaison
- [ ] Boutons d'achat → Stripe Checkout (Base) ou inscription + Stripe Billing (Premium)
- [ ] FAQ (licence, remboursement, support, vie privée)
- [ ] Mentions légales (TPS/TVQ, politique de remboursement)
- [ ] i18n FR/EN
#### Issue 8 — Activation en ligne et machine limit [type:feature]
Forgejo: #53 (ouverte, status:ready). Dépendances : Issue 1 ✅, Issue 4
- [ ] Au premier lancement avec une clé, appeler `/licenses/activate` avec le machine_id
- [ ] Si machine_limit atteint → message d'erreur avec option de désactiver une autre machine
- [ ] Page de gestion des machines dans le Customer Portal ou sur le site
- [ ] Graceful degradation si le serveur est injoignable (accepter la clé offline, activer plus tard)
### Ordre d'exécution
```
Phase 1 (desktop, offline) Phase 2 (serveur, online)
======================== ========================
Issue 1 (Commandes licence) Issue 4 (API licences)
├── Issue 2 (UI Licence) ├── Issue 5 (Stripe)
├── Issue 3 (Updater conditionnel) ├── Issue 6 (Logto desktop)
└── Issue 8 (Activation) ───────────┘
└── Issue 7 (Page d'achat)
```
Les Phases 1 et 2 peuvent avancer en parallèle. La Phase 1 est autonome (tout offline).
## Fichiers concernés
### Nouveaux fichiers
| Fichier | Raison | Statut |
|---------|--------|--------|
| `src-tauri/src/commands/license_commands.rs` | Commandes Tauri : validation, stockage, lecture de licence | ✅ #46 |
| `src-tauri/src/commands/entitlements.rs` | Système d'entitlements par édition | ✅ #46 |
| `src-tauri/src/commands/auth_commands.rs` | Commandes Tauri : OAuth2 PKCE, tokens, compte | 🔜 #51 |
| `src/components/settings/LicenseCard.tsx` | UI licence dans les paramètres | ✅ #47 |
| `src/components/settings/AccountCard.tsx` | UI compte Maximus dans les paramètres | 🔜 #51 |
| `src/services/licenseService.ts` | Service TypeScript pour les opérations de licence | ✅ #47 |
| `src/services/authService.ts` | Service TypeScript pour l'auth Compte Maximus | 🔜 #51 |
| `src/hooks/useLicense.ts` | Hook useReducer pour l'état de la licence | ✅ #47 |
| `src/hooks/useAuth.ts` | Hook useReducer pour l'auth Compte Maximus | 🔜 #51 |
### Fichiers modifiés
| Fichier | Action | Raison |
|---------|--------|--------|
| `src-tauri/Cargo.toml` | Modifier | Ajouter `jsonwebtoken`, `serde_json` ✅, `tauri-plugin-deep-link` (🔜 #51) |
| `src-tauri/src/lib.rs` | Modifier | Enregistrer les nouvelles commandes et plugins ✅ |
| `src-tauri/tauri.conf.json` | Modifier | Configurer deep-link protocol `simpl-resultat://` |
| `src/pages/SettingsPage.tsx` | Modifier | Intégrer LicenseCard ✅ et AccountCard (🔜 #51) |
| `src/hooks/useUpdater.ts` | Modifier | Conditionner les mises à jour à la licence ✅ |
| `src/i18n/locales/fr.json` | Modifier | Clés `license.*` ✅ et `account.*` (🔜 #51) |
| `src/i18n/locales/en.json` | Modifier | Clés `license.*` ✅ et `account.*` (🔜 #51) |
### Nouveau projet (serveur, hors du repo simpl-resultat)
| Projet | Description |
|--------|-------------|
| `simpl-resultat-api` | API de licences + webhooks Stripe (Node.js, propriétaire) |
## Critères d'acceptation
- [ ] Un utilisateur peut entrer une clé de licence valide et voir son édition passer à "Base"
- [ ] Une clé invalide ou corrompue est rejetée avec un message d'erreur clair
- [ ] Les mises à jour automatiques ne sont proposées qu'aux éditions Base et Premium
- [ ] L'édition "Gratuite" fonctionne sans restriction fonctionnelle (sauf auto-update)
- [ ] La connexion au Compte Maximus est optionnelle et ne bloque aucune fonctionnalité Base
- [ ] Un abonnement Premium actif est détecté et affiche l'édition "Premium"
- [ ] L'expiration d'un abonnement repasse gracieusement en mode Base (7 jours de grâce)
- [ ] L'achat via Stripe Checkout génère automatiquement une clé de licence envoyée par email
- [ ] Le webhook Stripe met à jour le statut d'abonnement en temps réel
- [ ] La TPS/TVQ est correctement collectée pour les ventes au Canada
- [ ] L'app fonctionne 100% offline avec une clé Base validée (aucun appel réseau requis)
- [ ] L'activation machine fonctionne en mode dégradé si le serveur est injoignable
- [ ] Toutes les chaînes UI sont traduites FR/EN
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| L'utilisateur change de machine | Machine limit (3 par défaut) + endpoint de désactivation. UI de gestion des machines. |
| La clé publique Ed25519 est extraite du binaire | Risque accepté (GPL). La clé publique ne permet que la vérification, pas la génération. |
| Le serveur de licences est down | La validation offline fonctionne toujours. L'activation et le refresh Premium échouent gracieusement. |
| L'utilisateur compile le code sans licence | Fonctionnel mais sans auto-update ni features Premium. Acceptable sous GPL. |
| Stripe webhook rate | Implémenter idempotency keys et retry logic. Stripe a un mécanisme de retry intégré (jusqu'à 72h). |
| Remboursement Stripe | Webhook `charge.refunded` → révoquer la licence automatiquement. |
| Changement de prix | Stripe permet de modifier les prix sans affecter les abonnements existants (grandfather). |
| Double achat (même email) | Vérifier si une licence active existe déjà avant d'en générer une nouvelle. |
| Token OAuth expiré en mode offline | Grace period : si le refresh échoue, garder le statut Premium pendant 7 jours (stocké localement). |
| Migration de machine_id (réinstall OS) | Le machine_id change → l'utilisateur doit désactiver l'ancienne machine ou contacter le support. Prévoir un flow self-service. |
> **🔴 SECURITE** — Le PIN hashing existant dans `profile_commands.rs` utilise SHA-256 salé (hash rapide), pas Argon2. Un PIN de 4-6 chiffres avec SHA-256 est brute-forceable trivialement (~1M combinaisons). Avec l'ajout de tokens OAuth, la surface d'attaque locale augmente.
> **Résolution :** Migrer `hash_pin`/`verify_pin` vers Argon2id (déjà une dépendance via le crate `argon2` utilisé dans l'export). Vulnérabilité pré-existante à corriger en Phase 1.
> *Ref : CWE-916 (Use of Password Hash With Insufficient Computational Effort)*
> **🟢 SECURITE** — Le CSP est désactivé (`"csp": null` dans `tauri.conf.json`). Avec des tokens OAuth stockés dans l'app, un XSS dans le WebView pourrait exfiltrer les tokens.
> **Résolution :** Activer un CSP restrictif avant la Phase 2 : `default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com`.
> *Ref : OWASP A03:2021 (Injection)*
> **🟢 TECHNIQUE** — Le spec ne spécifie pas la stratégie `get_machine_id()` cross-plateforme (Windows vs Linux). Différentes approches (`/etc/machine-id`, WMI, SMBIOS UUID) ont des garanties de stabilité différentes.
> **Résolution :** Spécifier la stratégie par plateforme dans l'Issue 1 (ex: crate `machine-uid`). Documenter que la réinstallation de l'OS peut changer l'ID.
## Décisions prises
| Question | Décision | Raison |
|----------|----------|--------|
| Modèle de prix | Hybride : achat unique desktop + abonnement web | Maximise les revenus sur les deux segments, faible friction pour le desktop |
| Modèle de licence | Open Core (GPL desktop + serveur propriétaire) | Cohérent avec l'existant, la valeur est dans le service |
| Processeur de paiement | Stripe (recommandé), avec Stripe Tax pour TPS/TVQ | Meilleure API, frais les plus bas, Stripe Billing pour abonnements |
| Validation de licence | Ed25519 offline (JWT signé) | Privacy-first, fonctionne sans internet, crypto solide |
| Auth | Logto (OAuth2 PKCE, comme prévu dans le spec web) | Déjà planifié, self-hosted, standard OIDC |
| Stockage licence | Fichier `license.key` dans app data (hors SQLite) | Indépendant des profils, simple, pas de migration de schéma |
| Machine limit | 3 machines par licence Base | Standard industrie, équilibre entre flexibilité et protection |
| Grace period Premium | 7 jours après expiration | Évite de bloquer l'utilisateur pour un problème de carte |
| Square | Non retenu | Orienté commerce de détail, API d'abonnement immature, pas de MoR |
## Références
| Source | Pertinence |
|--------|------------|
| [Keyforge — License Tauri App](https://keyforge.dev/blog/how-to-license-tauri-app) | Pattern Ed25519 + JWT pour validation offline dans Tauri, avec stockage sécurisé |
| [Keygen.sh for Tauri](https://keygen.sh/for-tauri-apps/) | Plugin Tauri existant pour licence (alternative SaaS à notre API custom) |
| [tauri-plugin-better-auth-license](https://crates.io/crates/tauri-plugin-better-auth-license) | Device-bound licensing avec X25519 + JWE, offline-verifiable JWTs |
| [Stripe vs Paddle vs LemonSqueezy Comparison](https://appstackbuilder.com/blog/stripe-vs-lemon-squeezy-vs-paddle) | Comparaison détaillée des frais, features, et cas d'usage 2026 |
| [LemonSqueezy 2026 Update (Stripe acquisition)](https://www.lemonsqueezy.com/blog/2026-update) | LemonSqueezy intégré à l'écosystème Stripe depuis 2024 |
| [Stripe Tax — Canada](https://docs.stripe.com/tax/supported-countries/canada) | Documentation Stripe Tax pour la collecte TPS/TVQ automatique au Canada |
| [Monetizing Open Source: Open Core Strategies](https://www.getmonetizely.com/articles/monetizing-open-source-software-pricing-strategies-for-open-core-saas) | Pricing strategies pour Open Core SaaS, validation du modèle hybride |
| [Open Core Business Model Handbook](https://handbook.opencoreventures.com/open-core-business-model/) | Guide structuré du modèle Open Core, délimitation free vs premium |
| [FastSpring — Desktop Software Sales](https://fungies.io/best-subscription-billing-tools-saas-2026/) | FastSpring comme alternative spécialisée pour la vente de logiciels desktop |
## Revision — Synthese
> Date: 2026-04-08 | Experts: Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — Le spec a une architecture solide mais présente des failles de sécurité crypto (JWT sans expiration, chiffrement tokens faible, PIN SHA-256) et des incohérences avec les patterns du codebase existant (hooks manquants).
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|-----|-----|-----|-------------|
| Securite | 4 | 2 | 1 | JWT sans expiry irrevocable, token encryption faible (machine ID), PIN SHA-256, license.key copiable, webhook signature manquante |
| Architecture | 1 | 1 | 0 | Hooks useLicense/useAuth manquants (pattern du projet), couplage get_edition() entre modules |
| Technique | 2 | 2 | 1 | Confusion ed25519-dalek vs jsonwebtoken, tension GPL vs feature gating, auto-update seul gate fragile |
### Actions requises
1. 🔴 Ajouter claim `exp` obligatoire au JWT licence (CWE-613)
2. 🔴 Remplacer le chiffrement machine-ID des tokens par OS keychain ou secret par installation (CWE-321)
3. ✅ ~~Migrer PIN hashing de SHA-256 vers Argon2id~~ — Corrigé dans #54 (PR #55, en attente de merge)
4. 🔴 Ajouter un token d'activation signé avec machine_id pour empêcher la copie de license.key — `store_activation_token()` implémenté dans #46, activation serveur dans #53
5. ✅ ~~Clarifier que `jsonwebtoken` (pas `ed25519-dalek`) est la dépendance primaire pour JWT~~ — Implémenté avec `jsonwebtoken` dans #46
6. 🔴 Documenter explicitement que le feature gating GPL est un honor system (décision Open Core)
7. ✅ ~~Ajouter `src/hooks/useLicense.ts` et `src/hooks/useAuth.ts`~~`useLicense.ts` ajouté dans #47. `useAuth.ts` à créer dans #51
8. 🟡 Ajouter vérification signature Stripe webhook (CWE-345)
9. 🟡 Ajouter rate limiting + auth sur les endpoints licence (OWASP API4)
10. 🟡 Documenter la config deep-link par plateforme pour OAuth2 callback
11. 🟡 Clarifier le couplage `get_edition()` entre license_commands et auth_commands
12. 🟡 Considérer des gates additionnels ou assumer le soft paywall pour l'édition Base

View file

@ -0,0 +1,375 @@
# Spec Plan — Refonte du seed de catégories vers IPC Statistique Canada
> Date: 2026-04-19
> Projet: simpl-resultat
> Statut: Draft
> Slug: refonte-seed-categories-ipc
> Decisions: [spec-decisions-refonte-seed-categories-ipc.md](./spec-decisions-refonte-seed-categories-ipc.md)
## Design
### UX / Interface
#### Livraison 1 — Découverte (Option E)
**Bannière dashboard** (première ouverture post-MAJ)
- Position : haut du dashboard, dismissable
- Texte : "Découvrez la nouvelle structure standard des catégories — inspirée de Statistique Canada"
- CTA : *Voir le guide* → navigue vers `/paramètres/categories/standard`
- Une fois dismiss, ne réapparaît plus (flag `user_preferences.categories_v1_banner_dismissed = true`)
**Page `/paramètres/categories/standard`** (lecture seule)
- Bloc pédagogique en haut : pourquoi cette structure, lien vers source Statistique Canada
- Arbre navigable avec expand/collapse des branches
- Hover sur catégorie : tooltip avec description + exemples de fournisseurs (ex: "Metro, IGA, Maxi, Loblaws...")
- Compteur global : "9 catégories racines, ~40 sous-catégories, ~90 feuilles"
- Bouton recherche full-text
- Bouton export PDF
- **Aucune action destructive** — lecture pure
**Entrée Paramètres**
- Dans `Paramètres > Gestion des catégories`, ajouter lien *Explorer la structure standard*
#### Livraison 2 — Migration (Option B)
Page 3 étapes séquentielles à `/paramètres/categories/migrer` :
**Étape 1 — Découvrir** (reprend la page de Livraison 1 en lecture)
- CTA : *Continuer vers l'aperçu de migration*
**Étape 2 — Simuler** (dry-run)
- Résumé impact : X catégories, Y transactions, Z règles, W budgets
- Table 3 colonnes : *Actuelle* | *Correspondance* | *v1 proposée*
- Badges confiance 🟢/🟡/🟠/🔴
- Panneau latéral cliquable par ligne : liste des transactions affectées + possibilité de changer la cible
- Compteur "N décisions à prendre" + bouton suivant désactivé tant que toutes les 🟠 ne sont pas résolues
- Les choix sont persistés en mémoire (pas encore BDD)
**Étape 3 — Consentir**
- Checklist explicite : "Je comprends que cette opération modifie mes catégories / Une sauvegarde sera créée avant tout changement / Je peux rétablir à tout moment"
- Bouton *Créer la sauvegarde et migrer* désactivé tant que checklist non cochée
- Pendant exécution : loader avec 4 étapes affichées (backup créé → vérifié → migration SQL → commit)
- Écran succès avec chemin du fichier SREF + CTA *Aller au tableau de bord*
- Écran échec backup : abort complet, aucune écriture, message clair + options (changer dossier / réessayer / annuler)
**Bannière post-migration** (Paramètres > Catégories, 90 jours)
- "Migration appliquée le <date>. Sauvegarde : <chemin>"
- Bouton *Rétablir la sauvegarde* → modale confirmation → `importFullProfile()` en mode replace
- Bouton *Ne plus afficher* → flag dans `user_preferences.categories_migration_banner_dismissed`
### Données
#### Nouvelle migration SQL v8 (additive)
Nom : `v8__category_schema_version.sql`
Ajoute :
- Colonne `categories.i18n_key TEXT NULL` — clé i18n technique pour les catégories seedées (ex: `seed.categories.alimentation.epicerie`). NULL pour les catégories custom → le renderer utilise `name` brut.
- Entrée dans `user_preferences` : `categories_schema_version` = `'v1'` ou `'v2'` (détermine quelle taxonomie le profil utilise).
**Important** : cette migration **n'écrase pas** le seed v2 des profils existants. Elle ajoute juste la colonne `i18n_key` (NULL par défaut) et pose `categories_schema_version='v2'` pour tous les profils existants.
#### `consolidated_schema.sql` mis à jour
- Contient le seed v1 complet (issu de `spike-archived/seed-standard/code/seed-proposal-v1.sql`)
- Pose `categories_schema_version='v1'` par défaut
- Les i18n_key sont peuplées au seed
#### Clés i18n ajoutées
Dans `src/i18n/locales/fr.json` et `en.json`, nouveau namespace `categoriesSeed` :
```json
{
"categoriesSeed": {
"revenus": { "root": "Revenus", "emploi": { "root": "Emploi", "paie": "Paie régulière", ... } },
"alimentation": { "root": "Alimentation", "epicerie": { "root": "Épicerie & marché", "reguliere": "Épicerie régulière", ... } },
...
}
}
```
Les `i18n_key` dans la BDD pointent vers ces clés : ex `categoriesSeed.alimentation.epicerie.reguliere`.
#### Rien de neuf en schéma v2 : pas de colonnes `frequency` ni `essentiality`
Ces attributs sont hors scope (cf. spec-decisions).
### Architecture
#### Composants React (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/pages/CategoriesStandardGuidePage.tsx` | Page read-only de Livraison 1 |
| `src/pages/CategoriesMigrationPage.tsx` | Page 3-étapes de Livraison 2 (routeur interne par étape) |
| `src/components/categories-migration/StepDiscover.tsx` | Étape 1 |
| `src/components/categories-migration/StepSimulate.tsx` | Étape 2 avec table 3 colonnes |
| `src/components/categories-migration/StepConsent.tsx` | Étape 3 avec checklist et loader |
| `src/components/categories-migration/MappingRow.tsx` | Ligne de table avec badge confiance + panneau latéral |
| `src/components/categories-migration/TransactionPreviewPanel.tsx` | Panneau latéral montrant transactions impactées |
| `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx` | Bannière dashboard one-shot |
| `src/components/settings/CategoriesMigrationBackupBanner.tsx` | Bannière post-migration (90j) |
#### Services (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/services/categoryTaxonomyService.ts` | Source de vérité de la taxonomie v1 (structure hardcodée en TS, utilisée par StepDiscover + StepSimulate pour afficher l'arbre cible). Import depuis JSON bundle. |
| `src/services/categoryMappingService.ts` | Calcule le mapping v2→v1 avec badge de confiance. Implémente l'algo 4-passes (keyword → supplier → défaut → revue). Retourne une structure `MigrationPlan` en mémoire, sans écriture BDD. |
| `src/services/categoryMigrationService.ts` | Orchestre la migration : prend un `MigrationPlan` + `BackupResult` validé, exécute la transaction SQL atomique (INSERT catégories v1 → UPDATE transactions/budgets/keywords → DELETE catégories v2 non mappées → création parent "Catégories personnalisées (migration)" si custom détectées). |
| `src/services/categoryBackupService.ts` | Wrapper autour de `dataExportService` pour le flow pre-migration : crée un fichier SREF nommé `<ProfileName>_avant-migration-<ISO8601>.sref` dans `~/Documents/Simpl-Resultat/backups/`, vérifie l'intégrité (read-back + checksum), retourne `BackupResult` ou lève une erreur claire. |
#### Hooks (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/hooks/useCategoryTaxonomy.ts` | Charge la taxonomie v1 depuis le service (useMemo). |
| `src/hooks/useCategoryMigration.ts` | useReducer pour l'état de la page de migration (étape courante, mapping plan, backup result, erreurs). |
#### Fichier JSON de taxonomie
`src/data/categoryTaxonomyV1.json` — structure hiérarchique de la taxonomie v1 utilisée par `categoryTaxonomyService.ts`. Régénéré depuis `spec-plan-*/code/seed-proposal-v1.sql` (source de vérité = le SQL, le JSON est dérivé pour l'UI).
#### Flow d'intégration
```
Utilisateur clique "Créer la sauvegarde et migrer"
useCategoryMigration dispatches START_MIGRATION
categoryBackupService.createAndVerify()
→ Tauri: pick_save_file + write_export_file + read_import_file
→ Si échec : dispatch BACKUP_FAILED, abort, aucune écriture BDD
→ Si succès : dispatch BACKUP_VERIFIED, retourne BackupResult
categoryMigrationService.applyMigration(plan, backup)
→ BEGIN TRANSACTION
→ INSERT catégories v1 (IDs 1000+, i18n_key peuplées, is_inputable calculé)
→ UPDATE transactions SET category_id = <mapping[old_id]>
→ UPDATE budgets SET category_id = <mapping[old_id]>
→ UPDATE keywords SET category_id = <mapping[old_id]>
→ Si custom détectées : INSERT parent "Catégories personnalisées (migration)" + re-parent
→ DELETE FROM categories WHERE id IN (<v2 non mappées, non custom>)
→ UPDATE user_preferences SET value='v1' WHERE key='categories_schema_version'
→ INSERT INTO user_preferences (key='last_categories_migration', value=<JSON métadonnées>)
→ COMMIT
→ Si erreur : ROLLBACK, backup reste disponible pour rétablissement
dispatch MIGRATION_SUCCESS, affiche écran succès
```
## Plan de travail
### Issue 1 — Seed v1 + i18n keys pour nouveaux profils [type:task]
Dépendances : aucune
- [ ] Ajouter migration SQL v8 : colonne `categories.i18n_key TEXT NULL` + `user_preferences('categories_schema_version', 'v2')` pour profils existants
- [ ] Mettre à jour `consolidated_schema.sql` avec le seed v1 complet (issu de `spike-archived/seed-standard/code/seed-proposal-v1.sql`) et poser `categories_schema_version='v1'` par défaut
- [ ] Créer `src/data/categoryTaxonomyV1.json` dérivé du SQL seed v1
- [ ] Ajouter les clés i18n FR et EN dans `src/i18n/locales/{fr,en}.json` sous `categoriesSeed.*`
- [ ] Adapter le renderer CategoryTree/CategoryCombobox pour utiliser `i18n_key` si présent, fallback sur `name`
- [ ] Tests : création d'un nouveau profil → vérifier que le seed v1 est appliqué, que `categories_schema_version='v1'`, et que les noms s'affichent traduits FR/EN
### Issue 2 — Service categoryTaxonomyService (source taxonomie v1 en TS) [type:task]
Dépendances : Issue 1
- [ ] Créer `src/services/categoryTaxonomyService.ts` avec `getTaxonomyV1()` retournant l'arbre typé depuis le JSON
- [ ] Créer `src/hooks/useCategoryTaxonomy.ts`
- [ ] Exposer des helpers : `findByPath(path)`, `getLeaves()`, `getParentById(id)`
### Issue 3 — Page "Guide des catégories standard" (Livraison 1) [type:feature]
Dépendances : Issue 2
- [ ] Créer route `/paramètres/categories/standard` dans `src/App.tsx`
- [ ] Créer `src/pages/CategoriesStandardGuidePage.tsx`
- [ ] Implémenter l'arbre navigable avec expand/collapse, tooltips, compteur global
- [ ] Bouton recherche full-text
- [ ] Bouton export PDF (via `window.print()` avec feuille style dédiée, ou lib PDF léger)
- [ ] Ajouter lien dans `src/components/settings/CategoriesCard.tsx` (ou équivalent)
### Issue 4 — Bannière dashboard one-shot + découverte [type:feature]
Dépendances : Issue 3
- [ ] Créer `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx`
- [ ] Ajouter flag `categories_v1_banner_dismissed` dans `user_preferences`
- [ ] Intégrer au `Dashboard.tsx` : affichée si `categories_schema_version='v2'` AND flag non-dismiss
- [ ] CTA dismissable vers la page Guide
### Issue 5 — Service categoryMappingService (algo ventillage 4 passes) [type:task]
Dépendances : Issue 2
- [ ] Créer `src/services/categoryMappingService.ts`
- [ ] Implémenter l'algo 4 passes (keyword → supplier → défaut → revue)
- [ ] Types : `MigrationPlan`, `MappingRow`, `ConfidenceBadge`
- [ ] Fonction `computeMigrationPlan(profileData): MigrationPlan` — pure, sans effet de bord BDD
- [ ] Mapping table encodée depuis `spike-archived/seed-standard/code/mapping-old-to-new.md`
- [ ] Détection des catégories custom (non présentes dans le seed v2)
### Issue 6 — Service categoryBackupService + wrapper SREF pre-migration [type:task]
Dépendances : aucune (peut aller en parallèle avec Issue 5)
- [ ] Créer `src/services/categoryBackupService.ts`
- [ ] Fonction `createPreMigrationBackup(profile): Promise<BackupResult>` :
- Génère nom fichier `<ProfileName>_avant-migration-<ISO8601>.sref`
- Emplacement par défaut `~/Documents/Simpl-Resultat/backups/`
- Appelle `dataExportService.performExport('transactions_with_categories', 'json', password)`
- Écrit via `write_export_file` (commande Tauri existante)
- Vérifie intégrité via `read_import_file` + checksum SHA-256
- Retourne `BackupResult { path, size, checksum, verifiedAt }` ou throw
- [ ] Gérer erreurs : espace disque, permissions, chiffrement si profil a un PIN
### Issue 7 — Page de migration 3-étapes (Livraison 2) [type:feature]
Dépendances : Issue 5, Issue 6
- [ ] Créer route `/paramètres/categories/migrer`
- [ ] Créer `src/pages/CategoriesMigrationPage.tsx` avec routeur interne (step 1/2/3)
- [ ] Créer `src/components/categories-migration/` avec StepDiscover, StepSimulate, StepConsent, MappingRow, TransactionPreviewPanel
- [ ] Créer `src/hooks/useCategoryMigration.ts` (useReducer)
- [ ] Créer `src/services/categoryMigrationService.ts` avec `applyMigration(plan, backup)` :
- Transaction SQL atomique (BEGIN/COMMIT/ROLLBACK)
- INSERT v1 + UPDATE transactions/budgets/keywords + création parent custom + DELETE v2 non mappées
- Journal dans `user_preferences.last_categories_migration`
- [ ] Écran succès/échec avec chemin backup et options de rollback
### Issue 8 — Bouton "Rétablir la sauvegarde" (90 jours) [type:feature]
Dépendances : Issue 6, Issue 7
- [ ] Créer `src/components/settings/CategoriesMigrationBackupBanner.tsx`
- [ ] Afficher dans `Paramètres > Catégories` si `last_categories_migration.timestamp` < 90 jours et flag `banner_dismissed=false`
- [ ] Modale de confirmation
- [ ] Appel à `dataImportService.importFullProfile(path, { mode: 'replace' })`
- [ ] Post-rollback : mettre à jour `categories_schema_version='v2'` et `last_categories_migration.reverted_at`
### Issue 9 — Tests complets [type:test]
Dépendances : Issues 1, 2, 5, 6, 7
- [ ] Tests unitaires `categoryMappingService` (algo 4 passes, badges confiance, détection custom)
- [ ] Tests unitaires `categoryBackupService` (création, vérification, erreurs)
- [ ] Tests intégration : flow complet `plan → backup → migrate → verify → rollback`
- [ ] Tests régression : transactions/budgets/keywords préservés post-migration avec IDs remappés
- [ ] Tests régression : fixtures paramétrées (ancien seed v2 ET nouveau seed v1) sur budget, transactions, splits, auto-catégorisation
- [ ] QA manuelle : checklist dans `docs/qa-refonte-seed-categories-ipc.md` couvrant les 3 étapes UI, les cas nominal/échec/rollback
### Ordre d'exécution
```
Livraison 1 (E read-only + seed nouveaux profils):
Issue 1 → Issue 2 → Issue 3 → Issue 4
Livraison 2 (B migration profils existants):
Issue 2 → Issue 5 ─┐
├→ Issue 7 → Issue 8
Issue 6 ───────────┘
Tests:
Issues 1,2,5,6,7 → Issue 9
```
Livraison 1 = Issues 1-4 (PR #1). Livraison 2 = Issues 5-9 (PR #2 ou series).
## Fichiers concernés
| Fichier | Action | Raison |
|---------|--------|--------|
| `src-tauri/src/lib.rs` | Modifier | Ajouter migration v8 (colonne `i18n_key` + pref `categories_schema_version`) |
| `src-tauri/src/database/migrations/v8__category_schema_version.sql` | Créer | Migration additive |
| `src-tauri/src/database/consolidated_schema.sql` | Modifier | Seed v1 complet pour nouveaux profils |
| `src/i18n/locales/fr.json` | Modifier | Nouveau namespace `categoriesSeed` + clés UI migration |
| `src/i18n/locales/en.json` | Modifier | Nouveau namespace `categoriesSeed` + clés UI migration |
| `src/data/categoryTaxonomyV1.json` | Créer | Dérivé du SQL seed v1 |
| `src/services/categoryTaxonomyService.ts` | Créer | Source taxonomie v1 côté TS |
| `src/services/categoryMappingService.ts` | Créer | Algo 4 passes |
| `src/services/categoryBackupService.ts` | Créer | Wrapper SREF pre-migration |
| `src/services/categoryMigrationService.ts` | Créer | Orchestration migration SQL atomique |
| `src/hooks/useCategoryTaxonomy.ts` | Créer | |
| `src/hooks/useCategoryMigration.ts` | Créer | useReducer état page migration |
| `src/pages/CategoriesStandardGuidePage.tsx` | Créer | Livraison 1 |
| `src/pages/CategoriesMigrationPage.tsx` | Créer | Livraison 2 |
| `src/components/categories-migration/*` | Créer | 5 composants (step 1/2/3 + mapping row + preview panel) |
| `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx` | Créer | Bannière one-shot |
| `src/components/settings/CategoriesMigrationBackupBanner.tsx` | Créer | Bannière post-migration 90j |
| `src/components/settings/CategoriesCard.tsx` | Modifier | Ajouter lien vers page Guide + page Migrer |
| `src/pages/Dashboard.tsx` | Modifier | Intégrer la bannière découverte |
| `src/App.tsx` | Modifier | Nouvelles routes |
| `src/components/categories/CategoryTree.tsx` | Modifier | Support `i18n_key` fallback `name` |
| `src/components/categories/CategoryCombobox.tsx` | Modifier | Idem |
| `docs/architecture.md` | Modifier | Documenter nouveaux services, pages, migration v8 |
| `docs/adr/NNNN-refonte-seed-categories-ipc.md` | Créer | ADR pour le choix IPC + pattern prévisualisation-consentement |
| `docs/qa-refonte-seed-categories-ipc.md` | Créer | Checklist QA manuelle |
| `CHANGELOG.md` | Modifier | Entrée sous `[Unreleased]` — Added/Changed |
| `CHANGELOG.fr.md` | Modifier | Idem FR |
## Plan de tests
### Tests unitaires
- `categoryMappingService.computeMigrationPlan()` : chaque règle de mapping v2→v1 (18 haute / 12 moyenne / 3 basse / 1 aucune) retourne le bon badge et la bonne cible.
- Algo 4 passes :
- Pass 1 (keyword match) avec diverses combinaisons
- Pass 2 (supplier propagation)
- Pass 3 (fallback défaut)
- Pass 4 (flag "à réviser")
- Détection des catégories custom (absentes du seed v2) → bucket `preserved`.
- Détection des splits (ex: Transport en commun 28 v2 → Bus 1521 OR Train 1522 v1).
- `categoryBackupService.createPreMigrationBackup()` avec mocks Tauri :
- Succès normal : retourne BackupResult valide
- Échec write_export_file : throw erreur "Impossible de créer la sauvegarde"
- Échec integrity check : throw erreur "Sauvegarde corrompue"
- Profil avec PIN : chiffrement appliqué
### Tests d'intégration
- Flow complet `plan → backup → migrate → verify` sur profil fixture v2 réaliste :
- Catégories v2 mappées correctement
- Transactions re-liées aux nouvelles catégories v1
- Keywords re-liés
- Budgets re-liés
- Catégories custom regroupées sous "Catégories personnalisées (migration)"
- Flow `rollback` après migration : import SREF restaure l'état v2 exact (transactions, keywords, budgets, categories).
- Échec backup → abort → aucune écriture BDD (profil v2 intact).
- Échec migration SQL → ROLLBACK → profil v2 intact, backup reste disponible.
### Tests de régression
Fixtures paramétrées v2 ET v1 pour couvrir :
- Auto-catégorisation (`categorizationService.applyKeywordToTransaction`)
- Budgets mensuels et agrégation parent/enfant (`budgetService.getBudgetVsActual`)
- Splits de transactions sur catégories multiples (`transactionService.splitTransaction`)
- Import CSV avec matching supplier/keyword
- Export/Import SREF (pas de régression sur le format)
- UI : `CategoryTree` et `CategoryCombobox` rendent correctement v2 et v1
## Critères d'acceptation
### Livraison 1
- [ ] Tout nouveau profil créé après la MAJ a le seed v1 appliqué (vérifié par SELECT sur la BDD d'un fresh profile).
- [ ] La bannière dashboard s'affiche sur les profils v2 existants au premier lancement post-MAJ.
- [ ] La bannière disparaît après dismiss et ne réapparaît plus (persistant).
- [ ] La page `/paramètres/categories/standard` affiche correctement l'arbre complet v1 avec FR/EN.
- [ ] Recherche full-text trouve les catégories par nom ou par mot-clé associé.
- [ ] Export PDF produit un document lisible de la taxonomie.
- [ ] Aucun changement aux données des profils v2 existants (test : avant/après MAJ, `SELECT * FROM categories` identique).
### Livraison 2
- [ ] Page `/paramètres/categories/migrer` est accessible depuis la page Guide et depuis Paramètres.
- [ ] Étape 2 affiche le bon badge de confiance pour chaque catégorie (validation sur fixture).
- [ ] Toutes les catégories 🟠 "split requis" bloquent l'avancement tant que non résolues.
- [ ] Backup SREF est créé et vérifié AVANT toute écriture BDD.
- [ ] Échec backup → abort → aucune écriture BDD (profil v2 intact).
- [ ] Migration succès → transactions, budgets, keywords tous re-liés correctement.
- [ ] Catégories custom préservées sous "Catégories personnalisées (migration)".
- [ ] Bannière post-migration visible pendant 90 jours, dismissable.
- [ ] Bouton *Rétablir la sauvegarde* fonctionne : restaure exactement l'état v2.
### Global
- [ ] Tous les tests unitaires et intégration passent (`npm test`, `cargo test`).
- [ ] Type-check clean (`npm run build`).
- [ ] CHANGELOG mis à jour FR et EN.
- [ ] `docs/architecture.md` mis à jour.
- [ ] ADR rédigé.
- [ ] QA manuelle exécutée selon checklist `docs/qa-refonte-seed-categories-ipc.md`.
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| Profil v2 avec 0 catégorie custom → mapping simple | Testé par fixture minimale |
| Profil v2 avec ≥50 catégories custom (utilisateur power) | UI pagine la liste dans l'étape 2 ; parent "Catégories personnalisées (migration)" absorbe tout |
| Utilisateur abandonne étape 2 en plein milieu | Aucune écriture BDD, le plan en mémoire est perdu — OK, aucun effet secondaire |
| Utilisateur abandonne étape 3 après checklist cochée mais avant backup | Bouton *Annuler* abort propre, aucune écriture |
| Espace disque insuffisant pour backup | `categoryBackupService` lève erreur claire → UI montre écran d'échec avec options "changer dossier / réessayer / annuler" |
| Migration SQL échoue au milieu | `ROLLBACK` automatique, backup reste disponible, UI affiche erreur + invite à rétablir |
| Utilisateur lance 2 instances de Simpl'Résultat en parallèle pendant la migration | SQLite lock naturel ; la 2e instance attend ; bas risque (app desktop mono-fenêtre en pratique) |
| Profil protégé par PIN : backup doit être chiffré | `categoryBackupService` récupère le PIN depuis le ProfileContext et passe en password à `write_export_file` |
| Utilisateur renomme/déplace le fichier SREF après migration | Le bouton *Rétablir* affiche un file picker si le chemin enregistré n'est plus valide |
| Déjà-migré : utilisateur re-lance la page de migration | Détection `categories_schema_version='v1'` → message "Votre profil utilise déjà la taxonomie v1" avec option "revoir la sauvegarde" uniquement |
| Utilisateur veut migrer APRÈS les 90 jours (bannière disparue) | Entrée permanente dans Paramètres > Catégories reste disponible → bouton *Explorer / Migrer* |
| Clé i18n manquante (typo dans le JSON) | Fallback sur le nom brut de la catégorie — pas de crash |
| Seed v1 manque une feuille qu'un utilisateur a en v2 (ex: "Projets") | Mapping badge 🔴 + prompt obligatoire étape 2, ou préservé en custom |
| Compatibilité forward : une future v2 du seed (refinement v1.1, v1.2) | `categories_schema_version` permet de détecter et ajouter des colonnes plus tard. Pattern réutilisable. |
| Performance : 139 catégories + 100+ keywords au seed pour un nouveau profil | < 200 ms sur SSD moderne, négligeable |
| Performance : migration de 5000 transactions | Transaction unique, < 2 s sur SSD moderne, loader visible pendant ce temps |

649
spec-refonte-rapports.md Normal file
View file

@ -0,0 +1,649 @@
## Spec — Refonte complete des rapports
> Date : 2026-04-13
> Projet : simpl-resultat
> Statut : Revised post-review (2026-04-13) — prêt pour implémentation
## Contexte
La page `/reports` actuelle expose cinq onglets (`trends`, `byCategory`, `overTime`, `budgetVsActual`, `dynamic`) pensés comme autant de vues analytiques indépendantes. L'ensemble souffre de trois limites :
1. **Pas de récit** — aucune vue ne répond à la question « qu'est-ce qui est important à savoir sur mes finances ce mois-ci ? ». L'utilisateur doit naviguer entre les onglets et reconstituer le tableau d'ensemble lui-même.
2. **Pivot surdimensionné** — le tableau croisé dynamique (`DynamicReport`) est puissant mais complexe, peu utilisé dans la pratique, et ajoute une dette visuelle/cognitive. Son usage se résume en réalité à « zoomer sur une catégorie ».
3. **Classification déconnectée** — les mots-clés s'éditent uniquement depuis `/categories`. Quand l'utilisateur voit une transaction mal classée dans un rapport, il doit quitter son contexte pour aller modifier la règle puis revenir.
La refonte garde la signature visuelle (palette couleurs catégorie + patterns SVG grayscale-friendly) mais réorganise le contenu autour de **quatre axes d'analyse** correspondant à quatre questions utilisateur :
| Rapport | Question utilisateur |
|--|--|
| Faits saillants | « Qu'est-ce qui a bougé ce mois ? » |
| Tendances | « Où je vais sur les 12 derniers mois ? » |
| Comparables | « Comment je me situe vs période précédente ou vs budget ? » |
| Analyse ponctuelle | « Montre-moi tout sur cette catégorie. » |
## Objectif
Refondre `/reports` en un hub unifié qui présente un aperçu des faits saillants + quatre rapports dédiés (tendances, comparables, faits saillants, zoom catégorie), avec toggle graphique/tableau partout, signature visuelle conservée, et édition contextuelle des mots-clés (clic droit sur transaction → preview → appliquer) pour améliorer la classification sans quitter le rapport.
> **🟢 ARCHITECTURE** — Hub + sous-routes est le bon move.
> URLs bookmarkables, back button natif, chaque page charge ses propres données, meilleur code-splitting possible.
> **Resolution :** Procéder, mais résoudre d'abord les 3 critiques architecture (structure pages plate, split useReports, mécanisme de partage de période) pour ne pas bâtir sur des bases désalignées.
## Scope
### IN
- Nouvelle page hub `/reports` affichant en haut un panneau "Faits saillants" (top mouvements, top transactions récentes, solde net mois courant + YTD) suivi de quatre cartes menant aux quatre sous-rapports.
- Quatre sous-pages :
- `/reports/highlights` — version détaillée des faits saillants
- `/reports/trends` — revenus vs dépenses (flux global) + évolution par catégorie
- `/reports/compare` — mois vs mois-1, année vs année-1, réel vs budget (mois et année), navigation tabulaire
- `/reports/category` — zoom sur une catégorie avec rollup automatique des sous-catégories
- Toggle **graphique ↔ tableau** sur tous les sous-rapports, défaut graphique, préférence mémorisée par rapport dans `localStorage`.
- Conservation de la signature visuelle : palette couleurs existante + patterns SVG (`chartPatterns.tsx`) + Recharts. Ajout de deux nouveaux types de charts : sparklines (pour les faits saillants) et donut chart (pour la répartition dans le zoom catégorie).
- Rollup automatique des sous-catégories dans le zoom catégorie (sélectionner `Alimentation` inclut toutes ses enfants, avec sous-total par enfant). Toggle « direct seulement » disponible.
- Édition contextuelle des mots-clés : clic droit sur n'importe quelle transaction (dans n'importe quel rapport ou dans la page Transactions) → menu « Ajouter ce libellé comme mot-clé pour catégorie X » → dialog preview des matches → Appliquer / Annuler.
- Retrait du tableau croisé dynamique de l'UI : les composants `DynamicReport*` restent dans le code mais derrière un feature flag désactivé par défaut (ou route cachée `/reports/_pivot`), au cas où un usage avancé réémergerait.
- Période par défaut à l'ouverture du hub : année civile en cours (1er janvier → 31 décembre).
- Traductions FR/EN complètes pour toutes les nouvelles clés.
- Mise à jour de `CHANGELOG.md` et `CHANGELOG.fr.md` sous `## [Unreleased]`.
### OUT (explicitement exclu)
- Projection / prévision des prochains mois (pas de ML, pas d'extrapolation).
- Moyennes mobiles (mentionnées mais non retenues pour ce sprint).
- Anomalies / alertes automatiques (pas de détection "dépense inhabituelle").
- KPIs dérivés (taux d'épargne, ratio fixe/variable) — à reconsidérer plus tard.
- Édition des mots-clés directement inline dans le panneau du zoom catégorie (on se limite au clic droit contextuel).
- Migration de données : aucune nouvelle table SQL, aucun changement de schéma.
- Remplacement de Recharts par une autre librairie.
- Rapports par fournisseur / par tag / par compte bancaire (hors scope).
## Design
### UX / Interface
#### Hub `/reports`
```
┌─────────────────────────────────────────────────────────┐
│ Rapports [Période : 2026 ▾] │
├─────────────────────────────────────────────────────────┤
│ FAITS SAILLANTS │
│ ┌──────────┬──────────┬────────────┬──────────────┐ │
│ │ Solde │ Solde │ Top hausse │ Top baisse │ │
│ │ avril │ YTD │ Restos │ Épicerie │ │
│ │ +312 $ │ +1 845 $ │ +240 $ │ -85 $ │ │
│ │ [spark] │ [spark] │ vs mars │ vs mars │ │
│ └──────────┴──────────┴────────────┴──────────────┘ │
│ │
│ Top 5 transactions récentes │
│ • 2026-04-10 Loyer avril 1 450,00 $ │
│ • 2026-04-08 Remboursement prêt 680,00 $ │
│ • ... │
├─────────────────────────────────────────────────────────┤
│ EXPLORER │
│ ┌─────────────┬─────────────┬─────────────┬──────────┐ │
│ │ Tendances │ Comparables │ Faits saill.│ Analyse │ │
│ │ 📈 │ ⚖ │ ⭐ │ 🔍 │ │
│ └─────────────┴─────────────┴─────────────┴──────────┘ │
└─────────────────────────────────────────────────────────┘
```
- Le hub charge un `ReportsHighlights` résumé en haut, même données que `/reports/highlights` mais layout condensé.
- Quatre tuiles de navigation au bas mènent aux sous-pages.
- Sélecteur de période global en haut à droite, partagé avec les sous-rapports (contexte conservé à la navigation).
> **🔴 ARCHITECTURE** — Mécanisme de partage de période non spécifié.
> Avec 4 routes séparées, chaque page monte un `useReports` neuf → l'état local est perdu à chaque navigation. « Contexte conservé » n'est pas défini.
> **Resolution :** Utiliser une query string `?from=...&to=...&period=2026` (simple, bookmarkable, pas de contexte global — cohérent avec le reste du projet qui n'utilise pas de contexte React pour l'état UI).
#### `/reports/highlights`
Version détaillée des faits saillants :
- Bloc **Soldes** : grandes tuiles avec solde net mois courant + YTD, chacune avec un sparkline 12 mois.
- Bloc **Top mouvements** : tableau triable des catégories avec la plus forte variation absolue ($) ou relative (%) vs mois précédent. Toggle `$` / `%`.
- Bloc **Top transactions récentes** : liste des 10 plus grosses transactions des 30 derniers jours (configurable 30/60/90 jours).
- Toggle graphique/tableau s'applique aux tops (barres horizontales ou tableau).
#### `/reports/trends`
Deux sous-vues accessibles par un mini-toggle interne :
- **Flux global** — AreaChart revenus/dépenses/solde net sur la période (reprend `MonthlyTrendsChart`, maintenu tel quel visuellement). Version tableau : `MonthlyTrendsTable`.
- **Par catégorie** — sélection multi-catégories + courbes d'évolution (adapte `CategoryOverTimeChart`). Version tableau : `CategoryOverTimeTable`.
Un seul sélecteur graphique/tableau en haut qui s'applique à la sous-vue affichée.
#### `/reports/compare`
Trois modes accessibles par un tab bar secondaire :
- **Mois vs mois précédent** — tableau catégories × 2 colonnes + écart $ / % ; version graphique = diverging bar chart centré sur 0.
- **Année vs année précédente** — même principe sur 12 mois vs 12 mois.
- **Réel vs budget** — reprend la logique de `BudgetVsActualTable` existante ; toggle mensuel / annuel (YTD).
Navigation entre les trois modes conserve la période et les filtres.
#### `/reports/category`
Vue single-category :
- En haut : combobox de sélection de catégorie + toggle **« inclure sous-catégories »** (activé par défaut).
- Zone principale :
- **Donut chart** de la répartition par sous-catégorie (ou pie si pas de rollup), couleurs de catégorie.
- Chart d'évolution mensuelle de la catégorie sur la période (AreaChart).
- Tableau des transactions de la catégorie (sortable, filtrable par date/montant).
- Toggle graphique/tableau cache/montre les visualisations.
#### Édition contextuelle des mots-clés
Trigger : clic droit sur une ligne de transaction dans n'importe quel tableau (rapports, zoom catégorie, mais aussi éventuellement `/transactions`).
```
┌──────────────────────────────────────────┐
│ Ajouter le mot-clé « METRO » ? │
│ │
│ Catégorie cible : [Alimentation ▾] │
│ Priorité : [100 ] │
│ │
│ Ce mot-clé matchera aussi : │
│ ☑ 2026-03-15 METRO #123 45,00 $ │
│ ☑ 2026-03-02 METRO PLUS 67,20 $ │
│ ☐ 2026-02-18 METROPOLITAIN 12,00 $ │
│ │
│ ⚠ 3 transactions seront recatégorisées │
│ │
│ [Annuler] [Appliquer] │
└──────────────────────────────────────────┘
```
**Implémentation arrêtée** (post-review sécurité) :
- **Normalisation** : utiliser `normalizeDescription` et `buildKeywordRegex` depuis `categorizationService.ts` — ces helpers sont actuellement privés, à exporter dans Issue #6.
- **Validation longueur** : keyword obligatoire entre 2 et 64 caractères après `.trim()`, rejet whitespace-only. Prévient ReDoS (CWE-1333).
- **Preview via SQL paramétrée** : `SELECT ... FROM transactions WHERE description LIKE ?1` (jamais d'interpolation de chaîne), puis filtrage en mémoire avec le regex compilé par `buildKeywordRegex`. Prévient injection SQL (CWE-89).
- **Affichage limité à 50 matches** ; au-delà, une checkbox explicite « Appliquer aussi aux N-50 transactions non affichées » s'affiche (off par défaut).
- **Appliquer** = exécution dans une **transaction SQL englobante** (`BEGIN; INSERT keywords; UPDATE transactions; COMMIT;`) via `tauri-plugin-sql`, avec rollback + toast erreur en cas d'échec. Application uniquement aux lignes **cochées visibles** (sauf si l'utilisateur a explicitement coché l'option des N-50 non affichées).
- **Comportement « mot-clé déjà existant pour autre catégorie »** : `UPDATE keywords SET category_id=? WHERE keyword=?` + re-run de la catégorisation **uniquement** sur les matches visibles cochés (jamais rétroactif sur l'historique complet).
- **Rendu XSS-safe** : les descriptions de transaction sont rendues comme enfants React (`{tx.description}`) — jamais `dangerouslySetInnerHTML`. Troncature via CSS uniquement (CWE-79).
- **Annuler** = aucune modification, dialog fermé.
> **🔴 TECHNIQUE** — `normalizeString` n'existe pas dans `categorizationService.ts`.
> Le service n'expose que `buildKeywordRegex` ; il existe un `normalizeDescription` **privé** (non exporté). L'import référencé dans la spec est invalide.
> **Resolution :** Ajouter à Issue 5 une tâche « Exporter `normalizeDescription` et `buildKeywordRegex` depuis `categorizationService.ts` » et corriger le nom dans la spec.
> **🔴 SECURITE** — Preview SQL doit être paramétrée, jamais interpoler le mot-clé.
> SQLite n'a pas d'opérateur regex natif ; une implémentation naïve construirait un `LIKE '%' || keyword || '%'` interpolé à partir d'un texte de transaction potentiellement malveillant (CSV importé), ouvrant une injection SQL locale.
> **Resolution :** Charger les candidats via SQL paramétré (`LIKE ?1` ou scan complet via `tauri-plugin-sql` binding) puis filtrer en mémoire avec le regex compilé par `buildKeywordRegex`. Jamais de `string || keyword`.
> *Ref : OWASP A03:2021 / CWE-89*
> **🔴 SECURITE** — ReDoS / absence de cap sur la longueur du mot-clé.
> `buildKeywordRegex` échappe les metacharactères mais ne limite pas la longueur. Un mot-clé de 5000 caractères serait compilé puis rejoué à chaque import — freeze garanti de l'app.
> **Resolution :** Dans `AddKeywordDialog`, valider `keyword.trim().length` entre 2 et 64 avant INSERT ; rejeter whitespace-only. Ajouter cette règle à la table Edge cases.
> *Ref : CWE-1333*
> **🟡 SECURITE** — L'apply doit tourner en une seule transaction SQL (BEGIN/COMMIT).
> Le critère d'acceptation le mentionne mais la narration décrit INSERT + UPDATE séquentiels. Un crash entre les deux laisse un keyword orphelin ou des transactions non-recatégorisées — sur une app privacy-first sans backup par défaut, la confiance est ébranlée.
> **Resolution :** Envelopper explicitement INSERT keywords + UPDATE transactions dans `BEGIN / COMMIT / ROLLBACK` via `tauri-plugin-sql` ; surfacer les erreurs de rollback via un toast.
> *Ref : CWE-662*
> **🟡 SECURITE** — « Preview 50 + apply sur tous » viole le contrôle utilisateur.
> La Edge case dit « Dialog limite l'affichage à 50 matches avec + N autres ; l'UPDATE s'applique bien à tous ». L'utilisateur coche 50 lignes, l'app en modifie 300 sans undo.
> **Resolution :** Soit appliquer uniquement aux lignes cochées réellement affichées, soit exiger une confirmation explicite « Appliquer aussi aux N-50 transactions non affichées » (checkbox off par défaut).
> *Ref : OWASP ASVS V1.11.2*
> **🟡 SECURITE** — « Remplacer » un mot-clé existant n'est pas défini.
> La edge case dit « Ce mot-clé existe déjà pour catégorie X, remplacer ? » mais ne précise pas si ça UPDATE silencieusement l'ancien keyword (ce qui re-catégoriserait rétroactivement des années de transactions) ou crée un doublon.
> **Resolution :** Décider explicitement : `UPDATE keywords SET category_id=? WHERE keyword=?` + re-run de la catégorisation uniquement sur les matches visibles (pas sur l'historique). Écrire la décision dans la spec.
> **🟡 ARCHITECTURE** — `AddKeywordDialog` et `TransactionContextMenu` ne sont pas du domaine "reports".
> La spec dit explicitement qu'ils seront utilisés dans `/transactions` et les rapports. Les placer sous `components/reports/shared/` viole SRP.
> **Resolution :** Placer `AddKeywordDialog` dans `components/categories/` (domaine édition mot-clé) et `TransactionContextMenu` dans `components/shared/` (cross-page).
> **🟢 SECURITE** — Rendre les descriptions de transaction en enfants React uniquement.
> Les libellés affichés dans le menu et le dialog viennent de CSV imports (untrusted). Une utilisation naïve de `dangerouslySetInnerHTML` ou de `title=` avec HTML réintroduirait XSS dans le webview Tauri.
> **Resolution :** Spécifier dans la spec que les descriptions sont rendues comme enfants React (jamais `dangerouslySetInnerHTML`) et tronquées via CSS uniquement.
> *Ref : CWE-79*
### Données
**Aucune migration SQL.** Toutes les requêtes s'appuient sur les tables existantes :
- `transactions` — agrégats mensuels, tops, filtres date/catégorie.
- `categories` — hiérarchie pour rollup, couleurs, types.
- `keywords` — insertion nouvelle règle via dialog contextuel.
- `budget_entries` — réel vs budget.
- `import_sources` — filtres optionnels.
Nouveaux endpoints dans `reportService.ts` (SQL strictement paramétré, jamais d'interpolation) :
| Fonction | Rôle |
|--|--|
| `getHighlights(from, to)` | Retourne `{ netBalanceCurrent, netBalanceYtd, monthlyBalanceSeries, topMovers: {category, deltaAbs, deltaPct}[], topTransactions: Transaction[] }` |
| `getCompareMonthOverMonth(year, month)` | Retourne `CategoryDelta[]` pour mois cible vs mois précédent |
| `getCompareYearOverYear(year)` | Retourne `CategoryDelta[]` pour année cible vs année précédente |
| `getCategoryZoom(categoryId, from, to, includeSubcategories)` | Retourne `{ rollupTotal, byChild, monthlyEvolution, transactions }` |
**`getCategoryZoom` — cycle guard obligatoire** : le rollup des sous-catégories passe par une CTE SQLite récursive **bornée** pour se protéger contre d'éventuels cycles dans `parent_id` :
```sql
WITH RECURSIVE cat_tree(id, depth) AS (
SELECT id, 0 FROM categories WHERE id = ?1
UNION ALL
SELECT c.id, ct.depth + 1
FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id
WHERE ct.depth < 5
)
SELECT ... WHERE category_id IN (SELECT id FROM cat_tree);
```
Un test unitaire avec fixture cyclique (A→B→A) doit valider la terminaison.
Le service `getBudgetVsActualData` de `budgetService.ts` est réutilisé tel quel pour le mode réel-vs-budget.
> **🔴 SECURITE** — `getCategoryZoom` rollup récursif sans garde-fou cyclique.
> La table `categories` autorise n'importe quel `parent_id` (pas de check FK contre cycles). Une donnée malformée A→B→A fait tourner un walk récursif à l'infini et fige l'UI. `getCategoryDepth` existant a déjà ce risque latent.
> **Resolution :** Implémenter le rollup via une CTE SQLite récursive bornée (`WITH RECURSIVE ... WHERE depth < 5`) ou tracer un `Set<visited>` en JS. Ajouter un test unitaire avec une fixture cyclique.
> *Ref : CWE-835*
### Architecture
#### Nouvelle structure des fichiers
Convention : **`src/pages/` et `src/components/reports/` restent plats** (aucun sous-dossier par domaine), cohérent avec le reste du projet. Distinction par préfixe de nom.
```
src/pages/ # plat, comme le reste du projet
├── ReportsPage.tsx # refonte : devient le hub
├── ReportsHighlightsPage.tsx # NOUVEAU
├── ReportsTrendsPage.tsx # NOUVEAU
├── ReportsComparePage.tsx # NOUVEAU
└── ReportsCategoryPage.tsx # NOUVEAU
src/components/reports/ # plat, préfixes par domaine
# Hub
├── HubHighlightsPanel.tsx # NOUVEAU — panneau condensé pour le hub
├── HubReportNavCard.tsx # NOUVEAU — les 4 tuiles de navigation
├── HubNetBalanceTile.tsx # NOUVEAU — tuile solde + sparkline
├── HubTopMoversTile.tsx # NOUVEAU
├── HubTopTransactionsTile.tsx # NOUVEAU
# Highlights
├── HighlightsTopMoversTable.tsx # NOUVEAU
├── HighlightsTopMoversChart.tsx # NOUVEAU — diverging bar chart
├── HighlightsTopTransactionsList.tsx # NOUVEAU
# Compare
├── CompareModeTabs.tsx # NOUVEAU
├── ComparePeriodTable.tsx # NOUVEAU
├── ComparePeriodChart.tsx # NOUVEAU — diverging bar chart
├── CompareBudgetView.tsx # NOUVEAU — wrap BudgetVsActualTable
# Category zoom
├── CategoryZoomHeader.tsx # NOUVEAU — combobox + toggle rollup
├── CategoryDonutChart.tsx # NOUVEAU — template : dashboard/CategoryPieChart.tsx
├── CategoryEvolutionChart.tsx # NOUVEAU
├── CategoryTransactionsTable.tsx # NOUVEAU
# Shared (intra-reports)
├── ViewModeToggle.tsx # NOUVEAU — toggle graphique/tableau
├── Sparkline.tsx # NOUVEAU — mini chart Recharts
# EXISTANTS réutilisés tels quels
├── MonthlyTrendsChart.tsx # par TrendsPage (flux global)
├── MonthlyTrendsTable.tsx
├── CategoryOverTimeChart.tsx # par TrendsPage (par catégorie)
├── CategoryOverTimeTable.tsx
├── BudgetVsActualTable.tsx # wrapé par CompareBudgetView
├── CategoryBarChart.tsx
├── CategoryTable.tsx
└── ReportFilterPanel.tsx
# Autres emplacements hors src/components/reports/
src/components/shared/ContextMenu.tsx # NOUVEAU — shell générique (click-outside + Escape)
src/components/shared/ChartContextMenu.tsx # REFACTORÉ — compose ContextMenu
src/components/categories/AddKeywordDialog.tsx # NOUVEAU — domaine édition mot-clé, pas reports
# SUPPRIMÉS (pivot retiré franchement, git conserve l'historique)
src/components/reports/DynamicReport.tsx
src/components/reports/DynamicReportPanel.tsx
src/components/reports/DynamicReportTable.tsx
src/components/reports/DynamicReportChart.tsx
```
> **🔴 ARCHITECTURE** — `src/pages/reports/` casse la convention flat du projet.
> Aucune page du projet n'utilise de sous-dossier aujourd'hui (`src/pages/` est strictement plat : `DashboardPage`, `ImportPage`, `BudgetPage`, etc.). Introduire un sous-dossier pour un seul domaine crée une règle ad-hoc.
> **Resolution :** Garder `src/pages/` plat : nommer `ReportsHighlightsPage.tsx`, `ReportsTrendsPage.tsx`, `ReportsComparePage.tsx`, `ReportsCategoryPage.tsx` à côté de `ReportsPage.tsx`. Cohérent avec le reste du projet.
> **🟡 ARCHITECTURE** — Split `components/reports/` en sous-dossiers incohérent.
> La spec crée `hub/`, `highlights/`, `compare/`, `category/`, `shared/` mais laisse `MonthlyTrendsChart`, `BudgetVsActualTable`, `DynamicReport*` à la racine. Mix flat+nested ; les autres dossiers composants (`import/`, `dashboard/`, `profile/`) sont strictement plats.
> **Resolution :** Tout garder plat avec préfixes de nom (`HubNetBalanceTile`, `HighlightsTopMovers`, `CompareModeTabs`, `CategoryDonutChart`, etc.). Moins de churn git, cohérent avec le reste.
> **🟡 TECHNIQUE** — `TransactionContextMenu` duplique `ChartContextMenu` existant.
> `src/components/shared/ChartContextMenu.tsx` implémente déjà click-outside + Escape handling avec un shell menu réutilisable. La spec crée un nouveau composant from scratch sans le référencer.
> **Resolution :** Généraliser `ChartContextMenu` en `ContextMenu` réutilisable (items passés en children/props) et le réutiliser pour les transactions. Ajouter la refactorisation comme tâche explicite d'Issue 5.
> **🟢 TECHNIQUE** — Recharts 3.7 supporte déjà `<Pie innerRadius>` (donut).
> Vérifié : `recharts@^3.7.0` est installé et `src/components/dashboard/CategoryPieChart.tsx` rend déjà un `<Pie>` avec patterns. Le donut est juste `innerRadius`, pas de nouvelle dépendance.
> **Resolution :** Référencer `CategoryPieChart.tsx` comme template de démarrage pour `CategoryDonutChart.tsx` dans Issue 5.
#### Routing (`src/App.tsx`)
Nouvelles routes imbriquées :
```tsx
<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<HighlightsPage />} />
<Route path="/reports/trends" element={<TrendsPage />} />
<Route path="/reports/compare" element={<ComparePage />} />
<Route path="/reports/category" element={<CategoryZoomPage />} />
```
#### Hooks par domaine (refonte de `useReports`)
Le hook monolithique `useReports` est splitté en **hooks par domaine**, conformément au pattern « useReducer par domaine » documenté dans CLAUDE.md. Chaque page monte uniquement son propre hook — pas de god-object ni de refetch de champs hors-section.
| Hook | Rôle |
|---|---|
| `useReportsPeriod` | Lit/écrit la période via **query string** (`?from=YYYY-MM-DD&to=YYYY-MM-DD`) avec `useSearchParams` de react-router. Bookmarkable. Par défaut : année civile en cours. |
| `useHighlights` | Fetch + state du rapport faits saillants, `useReducer` dédié |
| `useTrends` | idem pour tendances (sous-vue flux global / par catégorie) |
| `useCompare` | idem pour comparables (mode MoM / YoY / budget) |
| `useCategoryZoom` | idem pour le zoom catégorie (`zoomedCategoryId`, `rollupChildren`) |
Les préférences `viewMode` (chart / table) sont persistées par section dans `localStorage` via une `storageKey` passée en prop à `ViewModeToggle` (`reports-viewmode-highlights`, `-trends`, `-compare`, `-category`).
Pendant la transition (Issue #2), `useReports` conserve temporairement ses champs legacy (`monthlyTrends`, `categorySpending`, etc.) recâblés sur `useReportsPeriod`, pour que les 4 rapports existants continuent de fonctionner jusqu'à ce qu'Issues #36 les migrent. Une fois tous les rapports migrés (Issue #8), `useReports` est supprimé.
> **🔴 ARCHITECTURE** — Un seul hook partagé entre 4 routes = god-object.
> Avec 4 routes react-router, chaque page monte/démonte son propre hook ; un seul `useReports` portant `section + compareMode + trendsSubView + zoomedCategoryId + rollupChildren + period` perd le bénéfice du routing : chaque page refetche tout et les champs hors-section polluent l'état.
> **Resolution :** Splitter en hooks par domaine : `useReportsPeriod` (partagé via query string), `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`. Cohérent avec le pattern « useReducer par domaine » documenté dans CLAUDE.md.
#### Suppression du pivot (tableau croisé dynamique)
Le pivot est **supprimé franchement** — pas de feature flag, pas de route cachée. Git conserve l'historique si on veut le ressusciter un jour. Concrètement :
- Delete `src/components/reports/DynamicReport.tsx`, `DynamicReportPanel.tsx`, `DynamicReportTable.tsx`, `DynamicReportChart.tsx`
- Retirer `pivotConfig`, `pivotResult`, les actions `setPivotConfig` et la logique `tab === 'dynamic'` de `src/hooks/useReports.ts`
- Retirer toutes les clés `reports.pivot.*` dans `src/i18n/locales/{fr,en}.json`
- Nettoyer le type `ReportTab` (plus de `'dynamic'`)
**Sidebar (`NAV_ITEMS` dans `src/shared/constants/index.ts`)** : l'entrée `/reports` reste seule — les 4 sous-rapports ne sont accessibles que via les cartes du hub.
> **🔴 TECHNIQUE + ARCHITECTURE** — `src/shared/constants.ts` n'existe pas + feature flag probablement YAGNI.
> Le projet utilise `src/shared/constants/index.ts` (dossier avec barrel). En plus, un flag pour du code déjà retiré de l'UI = route jamais atteinte + dette i18n (`reports.pivot.*` conservées) sans bénéfice clair — git garde l'historique.
> **Resolution :** Soit supprimer franchement les `DynamicReport*` et leurs clés i18n (git = historique), soit ajouter le flag dans `src/shared/constants/index.ts` avec un TODO de suppression à 2 versions. Trancher maintenant et écrire le choix dans la spec.
> **🟢 SECURITE** — Pivot flag runtime laisse le code dans le bundle.
> Un constant JS ne tree-shake pas : `getDynamicReportData` et son `FIELD_SQL` dynamique (dont les filtres viennent de l'utilisateur) restent dans le JS shippé = surface d'attaque morte mais live.
> **Resolution :** Si on garde l'option, utiliser un flag build-time via `import.meta.env.VITE_ENABLE_LEGACY_PIVOT` ou un `define` Vite pour permettre au bundler d'éliminer l'import conditionnel.
> *Ref : OWASP A05:2021*
### i18n
Nouveaux espaces dans `src/i18n/locales/{fr,en}.json` :
```json
"reports": {
"hub": {
"title": "Rapports",
"explore": "Explorer",
"highlights": "Faits saillants",
"trends": "Tendances",
"compare": "Comparables",
"categoryZoom": "Analyse par catégorie"
},
"highlights": {
"netBalanceCurrent": "Solde du mois",
"netBalanceYtd": "Solde cumulatif (YTD)",
"topMovers": "Top mouvements",
"topTransactions": "Plus grosses transactions récentes",
"variationAbs": "Écart $",
"variationPct": "Écart %",
"vsLastMonth": "vs mois précédent"
},
"trends": {
"subviewGlobal": "Flux global",
"subviewByCategory": "Par catégorie"
},
"compare": {
"modeMoM": "Mois vs mois précédent",
"modeYoY": "Année vs année précédente",
"modeBudget": "Réel vs budget",
"delta": "Écart",
"current": "Courant",
"previous": "Précédent"
},
"category": {
"selectCategory": "Choisir une catégorie",
"includeSubcategories": "Inclure sous-catégories",
"directOnly": "Directe seulement",
"breakdown": "Répartition",
"evolution": "Évolution",
"transactions": "Transactions"
},
"viewMode": {
"chart": "Graphique",
"table": "Tableau"
},
"keyword": {
"addFromTransaction": "Ajouter comme mot-clé",
"dialogTitle": "Nouveau mot-clé",
"willMatch": "Matchera aussi",
"nMatches_one": "{{count}} transaction matchée",
"nMatches_other": "{{count}} transactions matchées",
"applyAndRecategorize": "Appliquer et recatégoriser",
"tooShort": "Minimum 2 caractères",
"tooLong": "Maximum 64 caractères",
"alreadyExists": "Ce mot-clé existe déjà pour la catégorie « {{category}} ». Remplacer ?"
},
"empty": {
"noData": "Aucune donnée pour cette période",
"importCta": "Importer un relevé"
}
}
```
**Suffixes de pluriel** : i18next v25 + react-i18next v16 exige le format v4 JSON (`_one` / `_other`), pas `_plural`. Les clés `reports.pivot.*` existantes sont **supprimées** avec le code du pivot (pas conservées).
> **🔴 TECHNIQUE** — Suffixe `_plural` incorrect pour i18next v25.
> Le projet tourne avec `i18next` v25 + `react-i18next` v16 qui exige le format v4 JSON (`_one` / `_other`). Les clés existantes utilisent déjà `fileCount_one` / `fileCount_other`. `nMatches_plural` ne résoudra jamais, silencieusement.
> **Resolution :** Remplacer `nMatches` / `nMatches_plural` par `nMatches_one` / `nMatches_other` dans le snippet i18n et dans la task d'Issue 5.
## Plan de travail
Découpage en **8 issues Forgejo** (milestone `spec-refonte-rapports`). Les tâches détaillées vivent dans chaque issue Forgejo — cette section en donne l'index.
### Issue #1 (1a) — Fondation non-breaking [type:task]
**Dépendances :** aucune
Supprime le pivot franchement, ajoute les 4 squelettes de pages, crée les shared components (`ViewModeToggle`, `Sparkline`, `ContextMenu` générique), ajoute les sous-routes, met à jour les clés i18n. N'introduit pas encore le hub ni la refonte `useReports` — rien n'est cassé côté rapports existants.
### Issue #2 (1b) — Refonte `useReports` en hooks par domaine + query string période [type:task]
**Dépendances :** #1
Crée `useReportsPeriod` (query string), `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`. Garde `useReports` en mode legacy temporaire pour que les 4 rapports existants continuent de tourner pendant la transition.
### Issue #3 — Rapport Faits saillants + Hub [type:feature]
**Dépendances :** #2
Implémente `getHighlights` et les tuiles (`HubNetBalanceTile`, `HubTopMoversTile`, `HubTopTransactionsTile`), compose `HubHighlightsPanel`, transforme `/reports` en hub (grille de 4 `HubReportNavCard`), implémente la version détaillée `ReportsHighlightsPage`.
### Issue #4 — Rapport Tendances [type:feature]
**Dépendances :** #2
`ReportsTrendsPage` avec sous-toggle `Flux global` / `Par catégorie`. Réutilise `MonthlyTrendsChart/Table` et `CategoryOverTimeChart/Table` existants, les recâble sur `useTrends` + `useReportsPeriod`.
### Issue #5 — Rapport Comparables [type:feature]
**Dépendances :** #2
`getCompareMonthOverMonth`, `getCompareYearOverYear` (SQL paramétré). `ReportsComparePage` avec `CompareModeTabs` (MoM / YoY / Budget), `ComparePeriodTable`, `ComparePeriodChart` (diverging bar), `CompareBudgetView` (wrap de `BudgetVsActualTable`).
### Issue #6 — Zoom catégorie + édition contextuelle mot-clé (scope limité) [type:feature]
**Dépendances :** #2
`getCategoryZoom` avec **CTE récursive bornée** (cycle guard). `ReportsCategoryPage` avec combobox + rollup, `CategoryDonutChart` (template `dashboard/CategoryPieChart.tsx`), `CategoryEvolutionChart`, `CategoryTransactionsTable`.
Édition contextuelle des mots-clés : exporte `normalizeDescription`, `buildKeywordRegex`, `compileKeywords` depuis `categorizationService.ts` ; crée `src/components/categories/AddKeywordDialog.tsx` avec toutes les contraintes de sécurité (SQL paramétré, validation longueur 264, transaction SQL englobante, apply uniquement aux cochées visibles, rendu XSS-safe). Branche `ContextMenu` **uniquement sur `CategoryTransactionsTable`** dans cette issue. Test unitaire cycle guard avec fixture cyclique.
### Issue #7 — Propagation du clic droit (follow-up) [type:feature]
**Dépendances :** #3, #4, #5, #6
Étend `ContextMenu` + `AddKeywordDialog` aux autres tables : `HighlightsTopMoversTable`, `HighlightsTopTransactionsList`, `ComparePeriodTable`, `MonthlyTrendsTable`, `CategoryOverTimeTable`, et la table principale de `TransactionsPage`. Pas de nouveau code métier — réutilisation pure.
### Issue #8 — Polish, tests, documentation, changelog [type:task]
**Dépendances :** #3, #4, #5, #6, #7
Smoke tests vitest (`getHighlights`, `getCompareMonthOverMonth`, `getCategoryZoom` avec fixture cyclique, validation longueur `AddKeywordDialog`). Validation manuelle des flows. Mise à jour `docs/architecture.md` + `docs/guide-utilisateur.md` + ADR. Entrées `CHANGELOG.md` / `CHANGELOG.fr.md` sous `## [Unreleased]`. Suppression définitive des champs legacy de `useReports`. Build + tests verts.
### Ordre d'exécution
```
#1#2#3 ─┐
#4 ─┤
#5 ─┼→ #7#8
#6 ─┘
```
Issues #3, #4, #5, #6 parallélisables après #2.
## Fichiers concernés
| Fichier | Action | Raison |
|---|---|---|
| `src/pages/ReportsPage.tsx` | Refondre | Devient le hub (#3) |
| `src/pages/ReportsHighlightsPage.tsx` | Créer | Sous-page plat (#1 skeleton, #3 contenu) |
| `src/pages/ReportsTrendsPage.tsx` | Créer | Sous-page (#1 skeleton, #4 contenu) |
| `src/pages/ReportsComparePage.tsx` | Créer | Sous-page (#1 skeleton, #5 contenu) |
| `src/pages/ReportsCategoryPage.tsx` | Créer | Sous-page (#1 skeleton, #6 contenu) |
| `src/App.tsx` | Modifier | Ajout des 4 sous-routes (#1) |
| `src/hooks/useReports.ts` | Refondre → supprimer | Nettoyage pivot (#1), déprécié (#2), supprimé (#8) |
| `src/hooks/useReportsPeriod.ts` | Créer | Période via query string (#2) |
| `src/hooks/useHighlights.ts` | Créer | Hook domaine (#2) |
| `src/hooks/useTrends.ts` | Créer | Hook domaine (#2) |
| `src/hooks/useCompare.ts` | Créer | Hook domaine (#2) |
| `src/hooks/useCategoryZoom.ts` | Créer | Hook domaine (#2) |
| `src/services/reportService.ts` | Étendre | `getHighlights` (#3), `getCompareMoM`/`YoY` (#5), `getCategoryZoom` CTE bornée (#6) |
| `src/services/categorizationService.ts` | Étendre | Exporter `normalizeDescription`, `buildKeywordRegex`, `compileKeywords` (#6) |
| `src/components/reports/*` (plat, préfixes) | Créer | `Hub*`, `Highlights*`, `Compare*`, `Category*`, `ViewModeToggle`, `Sparkline` |
| `src/components/reports/DynamicReport*.tsx` | Supprimer | Pivot supprimé franchement (#1) |
| `src/components/shared/ContextMenu.tsx` | Créer | Shell générique clic droit (#1) |
| `src/components/shared/ChartContextMenu.tsx` | Refactorer | Compose `ContextMenu` (#1) |
| `src/components/categories/AddKeywordDialog.tsx` | Créer | Dialog édition mot-clé avec garanties sécurité (#6) |
| `src/shared/constants/index.ts` | Aucun changement NAV_ITEMS | `/reports` reste seul point d'entrée |
| `src/i18n/locales/fr.json` + `en.json` | Étendre + nettoyer | Ajouter clés hub/highlights/trends/compare/category/keyword/empty/viewMode ; **supprimer** `reports.pivot.*` (#1) |
| `CHANGELOG.md` + `CHANGELOG.fr.md` | Modifier | Entrée `## [Unreleased]` (#8) |
| `docs/architecture.md` | Modifier | Section Rapports mise à jour (#8) |
| `docs/guide-utilisateur.md` | Modifier | Nouveau flow utilisateur (#8) |
| `docs/adr/NNNN-refonte-rapports.md` | Créer | Décision architecturale (#8) |
## Critères d'acceptation
- [ ] La page `/reports` affiche un hub avec un panneau Faits saillants en haut et 4 cartes de navigation.
- [ ] Chacune des 4 sous-pages (`/reports/highlights`, `/trends`, `/compare`, `/category`) est accessible et fonctionnelle.
- [ ] Le toggle graphique/tableau fonctionne sur toutes les sous-pages et la préférence est mémorisée par rapport.
- [ ] Le rapport faits saillants affiche solde mois courant, solde YTD (avec sparklines), top movers par catégorie et top transactions récentes.
- [ ] Le rapport tendances expose flux global et par catégorie via un toggle interne.
- [ ] Le rapport comparables permet de basculer entre MoM, YoY et Réel vs budget en conservant la période.
- [ ] Le rapport zoom catégorie inclut automatiquement les sous-catégories et offre un toggle pour se limiter aux transactions directes.
- [ ] Le donut chart de répartition s'affiche avec les couleurs des catégories et les patterns SVG.
- [ ] Clic droit sur une transaction ouvre un menu permettant d'ajouter un mot-clé.
- [ ] Le dialog d'ajout de mot-clé montre la preview des transactions qui seront recatégorisées, avec cases à cocher individuelles.
- [ ] Appliquer le mot-clé met à jour `keywords` + les transactions cochées dans une **transaction SQL englobante** (BEGIN/COMMIT/ROLLBACK).
- [ ] La validation de longueur du mot-clé (264 caractères, rejet whitespace-only) est active côté dialog.
- [ ] `getCategoryZoom` termine correctement avec une fixture de catégories cyclique (test unitaire).
- [ ] Le tableau croisé dynamique est complètement supprimé du code et des traductions.
- [ ] Les 4 rapports respectent la signature visuelle : palette couleurs catégorie + patterns SVG + Recharts.
- [ ] Toutes les chaînes sont traduites en FR et EN.
- [ ] `CHANGELOG.md` et `CHANGELOG.fr.md` sont mis à jour sous `## [Unreleased]`.
- [ ] `npm run build` et `cargo check` passent verts.
- [ ] Un smoke test vitest couvre `getHighlights` et un rapport comparables.
## Edge cases et risques
| Cas | Mitigation |
|---|---|
| Profil vide (aucune transaction) | Chaque rapport affiche un empty-state i18n avec CTA vers /import |
| Catégorie sans sous-catégories mais rollup activé | Le donut affiche uniquement la catégorie elle-même, pas de plantage |
| Transaction sans catégorie dans top transactions | Afficher "Non catégorisé" avec couleur `#9ca3af` existante |
| Mot-clé trop court / trop long / whitespace-only | Validation dialog : longueur 264 après `.trim()`, rejet avec i18n `reports.keyword.tooShort` / `tooLong` avant INSERT (prévient ReDoS) |
| Mot-clé qui matcherait des centaines de transactions | Dialog limite l'affichage à 50 matches. Si plus, checkbox explicite « Appliquer aussi aux N-50 non affichées » (off par défaut). Apply ne touche QUE les lignes cochées réellement visibles, sauf confirmation explicite |
| Mot-clé avec regex spéciale (ex : `*`, `?`) | `buildKeywordRegex` échappe déjà les metacharactères — couvert par test |
| Clic droit sur transaction déjà dans la catégorie cible | Le dialog propose juste d'ajouter le mot-clé, sans UPDATE inutile ; afficher "déjà classée" |
| Conflit de mot-clé (déjà existant pour autre catégorie) | Dialog affiche `reports.keyword.alreadyExists`. « Remplacer » = `UPDATE keywords SET category_id=? WHERE keyword=?` + re-run catégorisation **uniquement** sur matches visibles cochés (pas rétroactif sur l'historique) |
| Crash au milieu de l'apply (INSERT ok, UPDATE partiel) | Tout l'apply tourne dans une transaction SQL (BEGIN/COMMIT/ROLLBACK). En cas d'échec, rollback complet + toast erreur |
| Catégorie avec cycle dans `parent_id` (A→B→A) | `getCategoryZoom` utilise une CTE récursive bornée `WHERE depth < 5`. Test unitaire avec fixture cyclique |
| Year-over-year sans données année précédente | Afficher empty-state i18n `reports.empty.noData` |
| Budget absent dans Réel vs budget | Réutiliser le comportement existant de `BudgetVsActualTable` |
| Profil existant avec `tab: 'dynamic'` en localStorage | Fallback sur hub à l'ouverture — le pivot n'existe plus |
| Période personnalisée très longue (5+ ans) | Laisser passer, Recharts gère bien jusqu'à ~60 points |
| XSS via description de transaction importée | Descriptions rendues comme enfants React uniquement (jamais `dangerouslySetInnerHTML`), troncature CSS |
## Décisions prises
| Question | Décision | Raison |
|---|---|---|
| Organisation UI | Hub + 4 sous-pages | Offre un récit (faits saillants d'abord) + place pour chaque rapport |
| Contenu faits saillants | Top mouvements + top transactions + solde mois/YTD | Répond à la question "qu'est-ce qui a bougé ?" sans complexifier |
| Contenu comparables | MoM, YoY, Réel vs budget avec navigation facile | Couvre les 3 comparaisons naturelles (temps court, temps long, vs plan) |
| Analyse ponctuelle | Zoom sur catégorie (pas pivot) | 90% des usages du pivot revenaient à zoomer une catégorie |
| Sort du pivot | **Supprimé franchement** (code + i18n + types) | Git conserve l'historique. Feature flag runtime laisserait le code dans le bundle (surface d'attaque morte) + dette i18n inutile. YAGNI |
| Contenu tendances | Flux global + par catégorie uniquement | Retire projection et moyennes mobiles (hors scope) |
| Toggle chart/table | Partout, défaut graphique, mémorisé localStorage | Cohérence + flexibilité + mémoire utilisateur |
| Librairie de charts | Conserver Recharts + patterns SVG | Déjà en place, adapté aux petits datasets, signature visuelle intacte |
| Nouveaux types | Sparklines + donut chart | Utiles pour faits saillants et répartition, faisables en Recharts natif |
| Rollup sous-catégories | Auto activé, toggle pour désactiver | Intuition conforme à l'arbre, reste contrôlable |
| Édition mots-clés | Clic droit contextuel sur transaction | Contexte naturel, pas de duplication d'UI dans categories |
| Reclassification | Preview + apply | Sécurité + feedback + contrôle utilisateur |
| Période par défaut | Année civile en cours | Naturel fiscalement, cohérent avec budget |
| Découpage issues | **8 issues** : #1 fondation non-breaking, #2 refonte hooks, #36 rapports (parallélisables), #7 propagation clic droit, #8 polish | Split fondation en 1a+1b évite de casser les rapports existants pendant la refonte du hook. Issue #7 de follow-up évite que Issue #6 dépende de #3/#4/#5 |
| Partage de période entre routes | Query string `?from=YYYY-MM-DD&to=YYYY-MM-DD` via `useSearchParams` | Bookmarkable, pas de contexte React global, cohérent avec le reste du projet |
| Hook `useReports` | Split en hooks par domaine (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) | Chaque route monte uniquement son hook, pas de god-object, pas de refetch de champs hors-section |
| Structure `src/pages/` | Plate (pas de sous-dossier) | Cohérent avec le reste du projet (`DashboardPage`, `ImportPage`, etc.) |
| Structure `src/components/reports/` | Plate avec préfixes de nom (`HubNetBalanceTile`, etc.) | Cohérent avec les autres dossiers composants (`import/`, `dashboard/`, `profile/`) qui sont plats |
| `AddKeywordDialog` emplacement | `src/components/categories/` | Domaine édition mot-clé, utilisé hors reports (page transactions aussi) |
| `ContextMenu` générique | Créer `src/components/shared/ContextMenu.tsx` + refactorer `ChartContextMenu` pour le composer | Évite de dupliquer la logique click-outside + Escape déjà dans `ChartContextMenu` |
| Sécurité du dialog mot-clé | SQL paramétré + longueur 264 + transaction SQL + apply sur cochées visibles + rendu React children | 3 critiques sécurité (SQL injection, ReDoS, transaction atomique) + 1 XSS latent dans le webview Tauri |
| Cycle guard rollup catégorie | CTE SQLite récursive bornée `WHERE depth < 5` + test fixture cyclique | `categories.parent_id` ne protège pas contre les cycles, risque de boucle infinie |
## Références
| Source | Pertinence |
|---|---|
| [My Take on React Chart Libraries — Kyle Gill](https://www.kylegill.com/essays/react-chart-libraries) | Confirme que Recharts reste un choix solide pour React + petits datasets ; pas de raison de migrer |
| [Top React Chart Libraries 2026 — Querio](https://querio.ai/articles/top-react-chart-libraries-data-visualization) | Compare Recharts / Visx / ECharts ; ECharts pertinent uniquement >100k points, pas notre cas |
| [Recharts — Create a Donut Chart (GeeksforGeeks)](https://www.geeksforgeeks.org/reactjs/create-a-donut-chart-using-recharts-in-reactjs/) | Donut chart = `Pie` avec `innerRadius`, pas de dépendance supplémentaire |
| [MUI X — Sparkline Chart](https://mui.com/x/react-charts/sparkline/) | Pattern sparkline : LineChart compact sans axes, intégrable dans une tuile |
## Revision — Synthese
> Date : 2026-04-13 | Experts : Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — L'orientation produit (hub + 4 rapports) est validée, mais 9 findings critiques doivent être résolus avant d'ouvrir les issues : incohérences avec la structure du projet, références à du code inexistant, et risques de sécurité autour de l'édition contextuelle des mots-clés.
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|----|----|----|-------------|
| Securite | 3 | 3 | 2 | ReDoS + SQL injection sur dialog mot-clé, absence de cycle guard rollup catégorie, transaction SQL manquante |
| Architecture | 3 | 3 | 1 | `pages/reports/` casse la convention flat, `useReports` god-object, partage de période non spécifié |
| Technique | 3 | 3 | 2 | `normalizeString` inexistant, `constants.ts` mauvais path, i18next v25 exige `_one`/`_other` |
### Actions requises
**🔴 Critiques à corriger avant d'ouvrir les issues**
1. 🔴 **Preview SQL doit être paramétrée** — charger les candidats via `LIKE ?` puis filtrer en mémoire avec `buildKeywordRegex` ; jamais de string concat.
2. 🔴 **ReDoS : cap la longueur du mot-clé** — validation 264 caractères dans `AddKeywordDialog`, rejet whitespace-only.
3. 🔴 **Cycle guard sur le rollup catégorie** — CTE récursive bornée (`WHERE depth < 5`) ou `Set<visited>` en JS + test unitaire.
4. 🔴 **Structure `src/pages/` plate** — renommer `ReportsHighlightsPage.tsx`, `ReportsTrendsPage.tsx`, etc. à côté de `ReportsPage.tsx`, pas de sous-dossier.
5. 🔴 **Splitter `useReports`** — un hook par domaine (`useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) + `useReportsPeriod` partagé via query string.
6. 🔴 **Mécanisme de partage de période** — query string `?from=...&to=...`, pas de contexte React global.
7. 🔴 **`nMatches_one` / `nMatches_other`** — pas `_plural` (i18next v25).
8. 🔴 **Corriger `src/shared/constants/index.ts`** — ou mieux : supprimer franchement le pivot, git garde l'historique (trancher YAGNI).
9. 🔴 **Exporter `normalizeDescription` et `buildKeywordRegex`** — et corriger le nom dans la spec (pas `normalizeString`).
**🟡 Améliorations recommandées**
10. 🟡 Wrapper INSERT + UPDATE du dialog mot-clé dans une transaction SQL explicite.
11. 🟡 Decider et écrire le comportement de « remplacer un mot-clé existant ».
12. 🟡 Apply ne modifie que les lignes cochées affichées (ou confirmation explicite pour les N-50 non affichées).
13. 🟡 Garder `components/reports/` plat avec préfixes de nom (`HubNetBalanceTile`, etc.), comme les autres dossiers composants.
14. 🟡 Déplacer `AddKeywordDialog``components/categories/`, `TransactionContextMenu``components/shared/`.
15. 🟡 Splitter Issue 1 en 1a (routing + skeletons) et 1b (refactor `useReports`) pour éviter de casser les 4 rapports existants.
16. 🟡 Trancher l'exposition Sidebar des sous-routes (probablement : seule `/reports` reste dans le menu).
17. 🟡 Généraliser `ChartContextMenu` existant plutôt que dupliquer en `TransactionContextMenu`.
18. 🟡 Scope du clic droit dans Issue 5 : limiter à `CategoryTransactionsTable` + issue de follow-up pour propagation.

559
spec-simpl-resultat-web.md Normal file
View file

@ -0,0 +1,559 @@
# Spec — Simpl-Resultat Web
> Date: 2026-03-30
> Projet: simpl-resultat
> Statut: Draft
> Dependance: Logto IdP (spec-compte-maximus dans la-compagnie-maximus)
## Contexte
Simpl-Resultat est actuellement une app desktop Tauri v2 (Windows/Linux) avec stockage SQLite local et protection par PIN Argon2. L'objectif est de rendre l'application disponible via le web, permettant aux utilisateurs connectes avec un Compte Maximus d'acceder a leurs donnees financieres depuis un navigateur.
L'app desktop continue de fonctionner en offline sans compte. Le compte et le web sont optionnels.
**Decision securite** : Les donnees financieres sont stockees en clair cote serveur (Option D — chiffrement au repos + isolation forte). L'E2EE zero-knowledge a ete evalue et rejete pour la v1 car incompatible avec les fonctionnalites web (recherche serveur, rapports, import CSV). L'hebergement local au Quebec (VPS OVH Beauharnois, Loi 25) est le differenciateur securite, pas le zero-knowledge. L'E2EE reste une option premium future ("mode Coffre-fort").
## Objectif
Permettre aux utilisateurs de gerer leur budget et transactions depuis un navigateur web a `resultat.lacompagniemaximus.com`, avec possibilite de synchronisation avec l'app desktop.
## Scope
### IN
- API REST backend pour toutes les operations CRUD (transactions, categories, budgets, ajustements, imports, fournisseurs, keywords)
- Schema PostgreSQL (migration des 13 tables SQLite, schema `sr_`)
- Frontend web porte depuis le code React/Vite/Tailwind existant (bonne reutilisabilite)
- Import CSV cote serveur (upload → parsing → auto-categorisation → preview → confirmation)
- Auth via le service auth dedie (Compte Maximus)
- Multi-profil par utilisateur (comme l'app desktop)
- Hebergement sur Coolify (resultat.lacompagniemaximus.com)
- i18n FR/EN
- Dark mode
- Securite Option D (chiffrement au repos + isolation)
### OUT
- E2EE zero-knowledge (decision documentee, option premium future)
- Connexion bancaire directe (Plaid/Flinks — future phase)
- App mobile simpl-resultat
- Partage de budget entre utilisateurs (ex: conjoint — future phase)
- Notifications/alertes serveur sur depassements budgetaires (future phase)
- Export PDF de rapports (future phase)
## Design
### Schema PostgreSQL (schema `sr_`)
Migration des 13 tables SQLite existantes vers PostgreSQL. Changements principaux :
- Ajout de `user_id` et `profile_id` pour isoler les donnees par utilisateur et par profil
- Ajout d'une table `sr_profiles` pour gerer les profils multiples par utilisateur (remplace le systeme de fichiers de profils dans Tauri)
- UUID comme cles primaires au lieu de INTEGER AUTOINCREMENT
- TIMESTAMPTZ au lieu de TEXT/DATETIME pour les dates
- NUMERIC(12,2) au lieu de REAL pour les montants (precision financiere)
- Conservation de la structure hierarchique des categories (parent_id)
- Conservation du systeme d'auto-categorisation par mots-cles
- Seed des 54 categories par defaut et 60+ keywords par profil
```sql
CREATE SCHEMA IF NOT EXISTS sr_;
-- Profils utilisateur (remplace le systeme fichiers Tauri)
CREATE TABLE sr_.profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- FK vers le service auth (Compte Maximus)
name TEXT NOT NULL,
pin_hash TEXT, -- Argon2 hash, optionnel sur le web
currency TEXT NOT NULL DEFAULT 'CAD',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, name)
);
-- Sources d'import (configurations de parsing)
CREATE TABLE sr_.import_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
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 JSONB NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, name)
);
-- Fichiers importes (deduplication)
CREATE TABLE sr_.imported_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sr_.import_sources(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
UNIQUE(source_id, filename)
);
-- Categories hierarchiques (parent_id, pre-seeded)
CREATE TABLE sr_.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
parent_id UUID REFERENCES sr_.categories(id) ON DELETE SET NULL,
color TEXT,
icon TEXT,
type TEXT NOT NULL DEFAULT 'expense', -- 'expense' | 'income'
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_inputable BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fournisseurs (normalises pour matching)
CREATE TABLE sr_.suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
category_id UUID REFERENCES sr_.categories(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, normalized_name)
);
-- Mots-cles pour auto-categorisation (pre-seeded)
CREATE TABLE sr_.keywords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
keyword TEXT NOT NULL,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
supplier_id UUID REFERENCES sr_.suppliers(id) ON DELETE SET NULL,
priority INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE(profile_id, keyword, category_id)
);
-- Transactions (avec support split via parent_transaction_id)
CREATE TABLE sr_.transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
description TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
category_id UUID REFERENCES sr_.categories(id) ON DELETE SET NULL,
supplier_id UUID REFERENCES sr_.suppliers(id) ON DELETE SET NULL,
source_id UUID REFERENCES sr_.import_sources(id) ON DELETE SET NULL,
file_id UUID REFERENCES sr_.imported_files(id) ON DELETE SET NULL,
original_description TEXT,
notes TEXT,
is_manually_categorized BOOLEAN NOT NULL DEFAULT FALSE,
is_split BOOLEAN NOT NULL DEFAULT FALSE,
parent_transaction_id UUID REFERENCES sr_.transactions(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Ajustements (ponctuels ou recurrents)
CREATE TABLE sr_.adjustments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Entrees d'ajustement (montant par categorie)
CREATE TABLE sr_.adjustment_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
adjustment_id UUID NOT NULL REFERENCES sr_.adjustments(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
description TEXT
);
-- Entrees budgetaires (grille 12 mois par categorie)
CREATE TABLE sr_.budget_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
month INTEGER NOT NULL, -- 1-12
amount NUMERIC(12,2) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, category_id, year, month)
);
-- Templates de budget (reutilisables)
CREATE TABLE sr_.budget_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, name)
);
-- Entrees de template de budget
CREATE TABLE sr_.budget_template_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES sr_.budget_templates(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
UNIQUE(template_id, category_id)
);
-- Templates de configuration d'import
CREATE TABLE sr_.import_config_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
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 BOOLEAN NOT NULL DEFAULT TRUE,
column_mapping JSONB NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single', -- 'single' | 'dual'
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, name)
);
-- Preferences utilisateur (cle-valeur par profil)
CREATE TABLE sr_.user_preferences (
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (profile_id, key)
);
-- Journal d'audit (securite)
CREATE TABLE sr_.audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
profile_id UUID,
action TEXT NOT NULL, -- 'login', 'export', 'delete', 'import', 'bulk_delete'
entity_type TEXT, -- 'transaction', 'profile', 'category', etc.
entity_id UUID,
metadata JSONB, -- details supplementaires (ex: nombre de lignes importees)
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index
CREATE INDEX idx_sr_profiles_user ON sr_.profiles(user_id);
CREATE INDEX idx_sr_transactions_profile_date ON sr_.transactions(profile_id, date);
CREATE INDEX idx_sr_transactions_category ON sr_.transactions(category_id);
CREATE INDEX idx_sr_transactions_supplier ON sr_.transactions(supplier_id);
CREATE INDEX idx_sr_transactions_source ON sr_.transactions(source_id);
CREATE INDEX idx_sr_transactions_file ON sr_.transactions(file_id);
CREATE INDEX idx_sr_transactions_parent ON sr_.transactions(parent_transaction_id);
CREATE INDEX idx_sr_categories_profile_parent ON sr_.categories(profile_id, parent_id);
CREATE INDEX idx_sr_categories_type ON sr_.categories(profile_id, type);
CREATE INDEX idx_sr_suppliers_profile_category ON sr_.suppliers(profile_id, category_id);
CREATE INDEX idx_sr_suppliers_normalized ON sr_.suppliers(profile_id, normalized_name);
CREATE INDEX idx_sr_keywords_profile_category ON sr_.keywords(profile_id, category_id);
CREATE INDEX idx_sr_keywords_keyword ON sr_.keywords(profile_id, keyword);
CREATE INDEX idx_sr_budget_entries_period ON sr_.budget_entries(profile_id, year, month);
CREATE INDEX idx_sr_adjustment_entries_adjustment ON sr_.adjustment_entries(adjustment_id);
CREATE INDEX idx_sr_imported_files_source ON sr_.imported_files(source_id);
CREATE INDEX idx_sr_audit_log_user ON sr_.audit_log(user_id, created_at);
CREATE INDEX idx_sr_audit_log_profile ON sr_.audit_log(profile_id, created_at);
-- Preferences par defaut (inserees a la creation d'un profil)
-- INSERT INTO sr_.user_preferences (profile_id, key, value) VALUES
-- ($profile_id, 'language', 'fr'),
-- ($profile_id, 'theme', 'light'),
-- ($profile_id, 'currency', 'CAD'),
-- ($profile_id, 'date_format', 'DD/MM/YYYY');
```
### API REST
Base URL: `https://resultat.lacompagniemaximus.com/api`
Auth: Bearer token (JWT depuis Logto)
Tous les endpoints scopes par `user_id` (extrait du JWT) + `profile_id` (header ou parametre)
#### Profils
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/profiles` | Lister les profils de l'utilisateur |
| POST | `/profiles` | Creer un profil (seed categories + keywords) |
| GET | `/profiles/:id` | Details d'un profil |
| PUT | `/profiles/:id` | Modifier un profil (nom, devise, format date) |
| DELETE | `/profiles/:id` | Supprimer un profil et toutes ses donnees |
| POST | `/profiles/:id/verify-pin` | Verifier le PIN (couche securite optionnelle) |
#### Transactions
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/transactions` | Lister avec filtres (date range, category_id, supplier_id, search, min/max amount, page, limit) |
| POST | `/transactions` | Creer une transaction manuelle |
| GET | `/transactions/:id` | Detail d'une transaction |
| PUT | `/transactions/:id` | Modifier (description, categorie, notes, montant) |
| DELETE | `/transactions/:id` | Supprimer |
| POST | `/transactions/:id/split` | Splitter en plusieurs entrees par categorie |
| DELETE | `/transactions/bulk` | Suppression en lot (body: { ids: [] }) |
#### Categories
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/categories` | Arbre hierarchique complet |
| POST | `/categories` | Creer une categorie custom |
| PUT | `/categories/:id` | Modifier (nom, couleur, icone, parent, sort_order) |
| DELETE | `/categories/:id` | Supprimer (SET NULL sur les transactions liees) |
| PUT | `/categories/reorder` | Reorganiser l'ordre des categories |
#### Fournisseurs & Mots-cles
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/suppliers` | Lister les fournisseurs |
| POST | `/suppliers` | Creer un fournisseur |
| PUT | `/suppliers/:id` | Modifier |
| DELETE | `/suppliers/:id` | Supprimer |
| GET | `/keywords` | Lister les mots-cles |
| POST | `/keywords` | Creer un mot-cle |
| PUT | `/keywords/:id` | Modifier |
| DELETE | `/keywords/:id` | Supprimer |
#### Budgets
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/budgets/:year` | Grille budgetaire 12 mois pour une annee |
| PUT | `/budgets` | Mettre a jour des entrees budgetaires (batch) |
| GET | `/budgets/templates` | Lister les templates de budget |
| POST | `/budgets/templates` | Creer un template |
| PUT | `/budgets/templates/:id` | Modifier un template |
| DELETE | `/budgets/templates/:id` | Supprimer un template |
| POST | `/budgets/apply-template` | Appliquer un template a une annee |
#### Ajustements
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/adjustments` | Lister les ajustements |
| POST | `/adjustments` | Creer un ajustement (avec entrees) |
| GET | `/adjustments/:id` | Detail d'un ajustement |
| PUT | `/adjustments/:id` | Modifier |
| DELETE | `/adjustments/:id` | Supprimer |
#### Import CSV
| Methode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/import/upload` | Upload un fichier CSV (stockage temporaire) |
| POST | `/import/parse` | Parser le CSV avec config (delimiter, encoding, date_format, column mapping) |
| GET | `/import/preview/:upload_id` | Preview des transactions parsees avec auto-categorisation |
| POST | `/import/confirm` | Confirmer l'import (inserer les transactions) |
| GET | `/import/sources` | Configurations de sources d'import |
| POST | `/import/sources` | Sauvegarder une config de source |
| PUT | `/import/sources/:id` | Modifier une config |
| DELETE | `/import/sources/:id` | Supprimer une config |
| GET | `/import/history` | Historique des imports |
| GET | `/import/config-templates` | Templates de configuration d'import |
| POST | `/import/config-templates` | Creer un template de config |
#### Rapports (rendus possibles par Option D)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/reports/monthly-summary/:year/:month` | Totaux par categorie, budget vs reel |
| GET | `/reports/trends` | Tendances mensuelles sur une periode (query: start, end, category_ids) |
| GET | `/reports/category-breakdown` | Repartition des depenses par categorie (query: start, end) |
### Frontend Web
C'est le gros avantage : l'app Tauri existante est deja **React 19 + Vite + Tailwind CSS v4 + TypeScript**. Les composants UI, hooks et logique metier sont hautement reutilisables. Changements principaux :
- Remplacer les commandes Tauri (Rust → SQLite) par des appels API (fetch → REST)
- Remplacer `@tauri-apps/plugin-sql` par un client API
- Remplacer les operations systeme de fichiers (import CSV depuis fichier local) par un upload fichier
- Remplacer la selection de profil Tauri → auth web + selecteur de profil
- Conserver tous les composants React : table de transactions, arbre de categories, grille budget, graphiques (Recharts), wizard import
- Conserver `react-router-dom`, `i18next`, `lucide-react`, `recharts`, `papaparse` (pour preview client), `@dnd-kit` (drag-and-drop)
- Retirer les dependances Tauri : `@tauri-apps/api`, `@tauri-apps/plugin-*`
Heberge sur Coolify a `resultat.lacompagniemaximus.com`.
### Securite (Option D — detaillee)
Puisque l'app traite des donnees financieres sensibles, voici le modele de securite complet :
1. **Chiffrement au repos** : Repertoire de donnees PostgreSQL sur volume chiffre (LUKS/dm-crypt sur le VPS)
2. **TLS en transit** : HTTPS obligatoire, headers HSTS
3. **Isolation du datastore** :
- Schema `sr_` separe des autres apps dans PostgreSQL
- Credentials PostgreSQL dedies au service simpl-resultat (pas de superuser partage)
- Le service n'a pas acces a Forgejo, MinIO (sauf pour import CSV temporaire), ni aux autres schemas
4. **Isolation par utilisateur** : Toutes les requetes filtrees par `user_id` + `profile_id`. Pas de requetes cross-user possibles.
5. **Backups chiffres** : `pg_dump` du schema `sr_` chiffre avec une cle stockee hors du VPS (ex: sur la machine locale ou dans un secret manager)
6. **Politique d'acces** : Jamais de consultation des donnees utilisateurs sans consentement explicite. Pas de dashboard admin montrant les donnees financieres des utilisateurs.
7. **Audit log** : Table `sr_.audit_log` (user_id, action, entity, timestamp) pour tracer les acces sensibles (exports, suppressions, imports)
8. **Rate limiting** : Sur tous les endpoints, particulierement import CSV (upload) et auth
9. **Input validation** : Sanitization de toutes les entrees, particulierement les descriptions de transactions et les notes
10. **Session** : JWT configure dans Logto (access token ~1h, refresh token ~14j), SDK gere le refresh automatiquement
**E2EE future ("mode Coffre-fort")** : Option premium ou le profil entier est chiffre cote client (AES-256-GCM, cle derivee du mot de passe utilisateur). Le serveur stocke un blob opaque. Incompatible avec les fonctionnalites serveur (rapports, search, import CSV serveur). L'utilisateur choisit : fonctionnalites completes OU privacy maximale.
### Gestion d'acces
- Acces a l'app web reserve aux utilisateurs avec un Compte Maximus
- Plan gratuit : acces basique (illimite pour la v1, pas de paywall)
- Plan premium (futur) : import CSV illimite, rapports avances, multi-profil, backup automatique
- Verification du JWT claims `apps.simpl-resultat` pour confirmer l'acces
- Profile-level access : un utilisateur peut avoir plusieurs profils (personnel, couple, etc.)
- PIN optionnel par profil sur le web (couche de securite supplementaire, comme sur desktop)
### Sync Desktop <-> Web
Deux approches, la plus simple pour la v1 :
**Option A — Export/Import (v1, simple)** [retenue]
- L'app desktop a deja un export/import SREF (AES-256-GCM chiffre)
- Ajouter un bouton "Synchroniser avec le cloud" dans l'app desktop
- Upload le fichier SREF vers le serveur, le serveur dechiffre et importe
- Pas de sync temps reel, mais suffisant pour un backup/migration
**Option B — Sync continue (v2, complexe)** [future]
- Change tracking sur chaque table (updated_at + sync tokens)
- Sync bidirectionnelle comme simpl-liste
- Conflict resolution par entite
- Plus complexe a cause du nombre de tables (13) et des relations
Recommandation : Option A pour la v1 (reutilise l'infrastructure export/import existante), Option B comme amelioration future.
## Plan de travail
### Issue 1 — Schema PostgreSQL et migrations [type:task]
Dependances: Logto deploye et operationnel
- [ ] Creer le schema `sr_` dans PostgreSQL (15 tables, index, contraintes)
- [ ] Table sr_profiles (multi-profil par utilisateur)
- [ ] Table sr_audit_log
- [ ] Script de seed : 54 categories + 60+ keywords par profil
- [ ] Script de migration
- [ ] Tests du schema (contraintes, cascades, unicite)
### Issue 2 — API REST backend [type:feature]
Dependances: Issue 1
- [ ] Setup projet (Next.js ou Express sur Coolify)
- [ ] Middleware auth (verification JWT, extraction user_id, injection profile_id)
- [ ] Endpoints profils (CRUD + PIN)
- [ ] Endpoints transactions (CRUD + filtres + splits + bulk delete)
- [ ] Endpoints categories (CRUD + arbre hierarchique + reorder)
- [ ] Endpoints fournisseurs + keywords
- [ ] Endpoints budgets (grille mensuelle + templates + apply)
- [ ] Endpoints ajustements (CRUD avec entrees)
- [ ] Endpoints rapports (monthly summary, trends, category breakdown)
- [ ] Rate limiting + input validation
- [ ] Audit logging middleware
### Issue 3 — Import CSV serveur [type:feature]
Dependances: Issue 2
- [ ] Upload CSV vers stockage temporaire (MinIO ou /tmp)
- [ ] Parsing multi-encoding (UTF-8, Windows-1252, ISO-8859-15) comme l'app desktop
- [ ] Auto-detection delimiteur
- [ ] Column mapping configurable
- [ ] Auto-categorisation par keywords (reproduire la logique Rust existante)
- [ ] Preview avant confirmation
- [ ] Deduplication par filename + file_hash (comme l'app desktop)
- [ ] Gestion des import sources et config templates
### Issue 4 — Frontend web [type:feature]
Dependances: Issue 2, Issue 3
- [ ] Setup projet (React + Vite + Tailwind CSS v4, port de la config existante)
- [ ] Client API (wrapper fetch avec auth JWT, gestion erreurs, profile_id)
- [ ] Auth flow (login via Logto, SDK @logto/react ou OIDC standard)
- [ ] Selecteur de profil
- [ ] Page transactions (table, filtres, tri, recherche, pagination)
- [ ] Page detail transaction (edition, re-categorisation, split)
- [ ] Saisie manuelle de transactions
- [ ] Page categories (arbre hierarchique, CRUD, drag-and-drop)
- [ ] Page budget (grille 12 mois, budget vs reel, templates)
- [ ] Page ajustements (CRUD, recurrence)
- [ ] Wizard import CSV (port des 13 etapes existantes vers upload serveur)
- [ ] Page rapports (graphiques Recharts : tendances, repartition par categorie)
- [ ] Page fournisseurs et keywords
- [ ] Dark mode
- [ ] i18n FR/EN (reutiliser les fichiers de traduction existants)
- [ ] Responsive
- [ ] Deployer sur Coolify (resultat.lacompagniemaximus.com)
### Issue 5 — Sync desktop <-> web (v1: export/import) [type:feature]
Dependances: Issue 2
- [ ] Ajouter bouton "Synchroniser avec le cloud" dans l'app desktop Tauri
- [ ] Auth flow desktop : OAuth2 via Logto (OIDC standard, tauri-plugin-oauth ou WebView)
- [ ] Export SREF → upload vers le serveur
- [ ] Le serveur dechiffre le SREF et importe dans le schema sr_
- [ ] Download depuis le serveur → import SREF dans l'app desktop
- [ ] Indicateur de derniere synchronisation
### Issue 6 — Securite et audit [type:task]
Dependances: Issue 2
- [ ] Configurer le chiffrement au repos du volume PostgreSQL (LUKS)
- [ ] Credentials PostgreSQL dedies (pas de superuser partage)
- [ ] Configurer les backups chiffres du schema sr_
- [ ] Implementer sr_audit_log (middleware automatique)
- [ ] Headers de securite (HSTS, CSP, X-Frame-Options, X-Content-Type-Options)
- [ ] Tests de securite (injection SQL, XSS, IDOR cross-user)
### Ordre d'execution
```
Logto (prerequis externe)
└── Issue 1 (Schema PostgreSQL)
├── Issue 2 (API REST)
│ ├── Issue 3 (Import CSV)
│ ├── Issue 5 (Sync desktop)
│ └── Issue 6 (Securite)
└── Issue 4 (Frontend web) — depends on Issue 2 + Issue 3
```
## Criteres d'acceptation
- [ ] Un utilisateur connecte peut acceder a ses finances depuis resultat.lacompagniemaximus.com
- [ ] Les operations CRUD fonctionnent pour les transactions, categories, budgets, ajustements
- [ ] L'import CSV fonctionne cote serveur (upload, parsing, auto-categorisation, preview, confirmation)
- [ ] Les rapports (tendances, repartition) s'affichent correctement avec des graphiques
- [ ] Le multi-profil fonctionne (creer, switcher, supprimer des profils)
- [ ] L'app desktop continue de fonctionner sans compte (offline-first preserve)
- [ ] La sync export/import entre desktop et web fonctionne
- [ ] Le site fonctionne en FR et EN, en light et dark mode
- [ ] Le site est responsive
- [ ] Les donnees financieres sont isolees des autres services (schema separe, credentials dedies)
- [ ] Un audit log trace les acces sensibles
## Estimation
8-12 sessions de travail (hors service auth). C'est le plus gros chantier des apps web en raison de la complexite du modele de donnees (13 tables desktop → 15 tables web) et de la logique metier (import CSV, auto-categorisation, budgets, rapports).
## Reutilisabilite du code existant
| Composant | Reutilisable ? | Notes |
|-----------|---------------|-------|
| Composants React (53 fichiers) | **Oui** | Deja React 19 + Tailwind CSS v4, memes primitives que le web |
| Recharts (graphiques) | **Oui** | Librairie web native, memes graphiques |
| Types TypeScript | **Oui** | Interfaces, enums, types de donnees |
| Hooks custom (12 hooks useReducer) | **Partiellement** | Adapter les appels services → fetch API |
| Services metier (14 services) | **Partiellement** | Remplacer tauri-plugin-sql par client API REST |
| i18n (traductions FR/EN) | **Oui** | Fichiers JSON reutilisables directement |
| @dnd-kit (drag-and-drop) | **Oui** | Librairie web native |
| papaparse (parsing CSV) | **Oui** | Utile pour preview cote client |
| lucide-react (icones) | **Oui** | Librairie web native |
| react-router-dom (routing) | **Oui** | Meme routing |
| Commandes Rust (17 commandes Tauri) | **Non** | Remplacees par l'API REST |
| Crypto Rust (Argon2, AES-256-GCM) | **Non** | PIN optionnel reimplemente en JS si necessaire |
| Import CSV Rust (parsing) | **Non** | Reimplemente cote serveur (Node.js) |
| tauri-plugin-sql | **Non** | Remplace par client API |
| tauri-plugin-updater | **Non** | Non applicable au web |

91
src-tauri/Cargo.lock generated
View file

@ -121,6 +121,16 @@ dependencies = [
"password-hash",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@ -573,6 +583,15 @@ dependencies = [
"inout",
]
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -1970,6 +1989,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
@ -1984,6 +2009,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@ -2622,6 +2648,31 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mockito"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.4",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "muda"
version = "0.17.1"
@ -3644,6 +3695,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@ -3664,6 +3725,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@ -3682,6 +3753,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@ -4421,13 +4501,20 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simpl-result"
version = "0.8.0"
version = "0.9.1"
dependencies = [
"aes-gcm",
"argon2",
"base64 0.22.1",
"chrono",
"ed25519-dalek",
"encoding_rs",
"hmac",
@ -4436,6 +4523,7 @@ dependencies = [
"keyring",
"libsqlite3-sys",
"machine-uid",
"mockito",
"rand 0.8.5",
"reqwest 0.12.28",
"rusqlite",
@ -5517,6 +5605,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"socket2",
"tokio-macros",

View file

@ -1,6 +1,6 @@
[package]
name = "simpl-result"
version = "0.8.0"
version = "0.9.1"
description = "Personal finance management app"
license = "GPL-3.0-only"
authors = ["you"]
@ -41,6 +41,10 @@ rand = "0.8"
jsonwebtoken = "9"
machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] }
# Date arithmetic for the Modified Dietz return calculator (Issue #142):
# we need day-precision diffs to weight cash flows W_i = (T - t_i) / T.
# `serde` feature lets `NaiveDate` cross the Tauri command boundary in JSON.
chrono = { version = "0.4", default-features = false, features = ["serde", "std"] }
tokio = { version = "1", features = ["macros"] }
hostname = "0.4"
urlencoding = "2"
@ -60,3 +64,5 @@ hmac = "0.12"
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
# for Ed25519.
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
mockito = "1.6"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 963 B

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

66
src-tauri/icons/icon.svg Normal file
View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Concept 04b — Calculatrice-robot avec cadenas sur la touche "="
Iteration sur 04 : la touche Entrée/= devient le porteur du symbole privacy.
Robot (yeux + antenne) + comptabilité (calculatrice) + simplicité + privacy (cadenas explicite).
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- Background squircle -->
<rect x="64" y="64" width="896" height="896" rx="200" ry="200" fill="#1E3A8A"/>
<!-- Antenna -->
<line x1="512" y1="120" x2="512" y2="200" stroke="#FCD34D" stroke-width="18" stroke-linecap="round"/>
<circle cx="512" cy="110" r="28" fill="#FCD34D"/>
<!-- Calculator body -->
<rect x="232" y="200" width="560" height="700" rx="60" ry="60" fill="#F1F5F9"/>
<!-- Screen (robot face) -->
<rect x="282" y="250" width="460" height="240" rx="20" ry="20" fill="#0F172A"/>
<!-- Screen highlight (subtle reflection) -->
<rect x="300" y="265" width="200" height="20" rx="10" fill="#1E3A8A" opacity="0.4"/>
<!-- Robot eyes on screen -->
<circle cx="402" cy="370" r="36" fill="#10B981"/>
<circle cx="622" cy="370" r="36" fill="#10B981"/>
<circle cx="412" cy="358" r="10" fill="#F1F5F9"/>
<circle cx="632" cy="358" r="10" fill="#F1F5F9"/>
<!-- Smile -->
<path d="M 432 440 Q 512 470 592 440" fill="none" stroke="#10B981" stroke-width="12" stroke-linecap="round"/>
<!-- Calculator buttons grid (3x4) -->
<g fill="#1E3A8A">
<rect x="302" y="540" width="100" height="80" rx="16"/>
<rect x="412" y="540" width="100" height="80" rx="16"/>
<rect x="522" y="540" width="100" height="80" rx="16"/>
<rect x="632" y="540" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="630" width="100" height="80" rx="16"/>
<rect x="412" y="630" width="100" height="80" rx="16"/>
<rect x="522" y="630" width="100" height="80" rx="16"/>
<rect x="632" y="630" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="720" width="100" height="80" rx="16"/>
<rect x="412" y="720" width="100" height="80" rx="16"/>
<rect x="522" y="720" width="100" height="80" rx="16"/>
<rect x="632" y="720" width="100" height="80" rx="16" fill="#FCD34D"/>
</g>
<!-- "=" / Enter button (wide, accent green) with lock icon -->
<rect x="302" y="810" width="430" height="80" rx="16" fill="#10B981"/>
<!-- Lock icon centered on the Enter key -->
<g transform="translate(517 850)">
<!-- Shackle (arc above body) -->
<path d="M -16 -2 L -16 -14 A 16 16 0 0 1 16 -14 L 16 -2"
fill="none" stroke="#F1F5F9" stroke-width="8" stroke-linecap="round"/>
<!-- Body -->
<rect x="-24" y="-2" width="48" height="32" rx="5" fill="#F1F5F9"/>
<!-- Keyhole circle -->
<circle cx="0" cy="11" r="4.5" fill="#10B981"/>
<!-- Keyhole stem -->
<rect x="-2.5" y="11" width="5" height="11" rx="1.5" fill="#10B981"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,63 @@
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
/// Subdirectory under the user's Documents folder where pre-migration backups
/// are written by default. Keeping the location predictable makes it easy for
/// users to find their backup files even if the app is uninstalled.
const BACKUP_SUBDIR: &str = "Simpl-Resultat/backups";
fn resolve_backup_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let documents = app
.path()
.document_dir()
.map_err(|e| format!("Cannot resolve Documents directory: {}", e))?;
Ok(documents.join(BACKUP_SUBDIR))
}
/// Resolve `~/Documents/Simpl-Resultat/backups/` and create it if missing.
/// Returns the absolute path as a string. Used by the pre-migration backup
/// flow to place SREF files in a predictable, user-visible location.
#[tauri::command]
pub fn ensure_backup_dir(app: tauri::AppHandle) -> Result<String, String> {
let dir = resolve_backup_dir(&app)?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| {
// Surface permission issues explicitly — the TS layer maps this to
// a user-facing i18n key.
if e.kind() == std::io::ErrorKind::PermissionDenied {
format!("permission_denied: {}", dir.to_string_lossy())
} else {
format!("create_dir_failed: {}: {}", dir.to_string_lossy(), e)
}
})?;
}
Ok(dir.to_string_lossy().to_string())
}
/// Return the size of a file on disk in bytes. Used to report the size of a
/// freshly-written backup to the UI. Returns a clear error if the file does
/// not exist or cannot be read.
#[tauri::command]
pub fn get_file_size(file_path: String) -> Result<u64, String> {
let metadata =
fs::metadata(&file_path).map_err(|e| format!("Cannot stat file {}: {}", file_path, e))?;
Ok(metadata.len())
}
/// Return true when the given path points to an existing regular file. Used
/// by the post-migration restore flow to detect that the recorded backup
/// path is still reachable before opening the confirmation modal — when the
/// file was moved or deleted, the UI falls back to a manual file picker.
/// Never throws on a missing file (just returns `false`); only returns an
/// error for unexpected I/O conditions other than "not found".
#[tauri::command]
pub fn file_exists(file_path: String) -> Result<bool, String> {
match fs::metadata(&file_path) {
Ok(meta) => Ok(meta.is_file()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(format!("Cannot stat file {}: {}", file_path, e)),
}
}

View file

@ -0,0 +1,532 @@
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155.
//!
//! Commands:
//! - `compute_account_return` (Issue #142): Modified Dietz return for one
//! account over a period. Reads snapshot endpoints + linked transfer amounts
//! in a single Rust pass.
//! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for
//! a given `(symbol, date)` pair. Privacy-strict: sends only
//! `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`.
//! - 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
//! avoids re-reading `profiles.json` on every call.
//! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements,
//! drops at end of function. No connection pooling needed (commands run
//! on the Tauri async runtime, one at a time per invocation).
use chrono::NaiveDate;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use tauri::Manager;
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
// ---------------------------------------------------------------------------
// fetch_price types (Issue #155)
// ---------------------------------------------------------------------------
/// Successful price response from `GET /v1/prices`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PriceResponse {
pub symbol: String,
pub date: String,
pub actual_date: Option<String>,
pub price: f64,
pub currency: String,
pub source: String,
pub fetched_at: String,
pub cached: bool,
}
/// Typed error returned by `fetch_price`. Serialized as JSON to cross the
/// Tauri command boundary (the JS layer `JSON.parse`s the error string).
///
/// The `tag = "code"` + `rename_all = "snake_case"` combination produces
/// `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}`, etc. —
/// matching the `error.code` shape defined in `docs/api-contract-prices.md §5`.
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum FetchPriceError {
Auth,
PremiumRequired,
SymbolNotFound,
RateLimit { retry_after_s: u64 },
ProviderUnavailable,
Network,
Internal,
}
impl std::fmt::Display for FetchPriceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FetchPriceError::Auth => write!(f, "auth"),
FetchPriceError::PremiumRequired => write!(f, "premium_required"),
FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"),
FetchPriceError::RateLimit { retry_after_s } => {
write!(f, "rate_limit (retry after {}s)", retry_after_s)
}
FetchPriceError::ProviderUnavailable => write!(f, "provider_unavailable"),
FetchPriceError::Network => write!(f, "network"),
FetchPriceError::Internal => write!(f, "internal"),
}
}
}
/// Serialize a `FetchPriceError` to the stable JSON string returned across
/// the Tauri boundary. Falls back to `{"code":"internal"}` on serialization
/// failure (which should never happen in practice).
fn price_error_to_string(err: &FetchPriceError) -> String {
serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string())
}
/// API base URL for maximus-api. Overridable via `MAXIMUS_API_URL` for tests
/// and development environments.
fn base_url() -> String {
std::env::var("MAXIMUS_API_URL")
.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)
.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.
///
/// 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,
symbol: &str,
date: &str,
) -> Result<PriceResponse, FetchPriceError> {
fetch_price_inner(token, symbol, date, &base_url()).await
}
async fn fetch_price_inner(
token: &str,
symbol: &str,
date: &str,
api_base: &str,
) -> Result<PriceResponse, FetchPriceError> {
let url = format!(
"{}/v1/prices?symbol={}&date={}",
api_base,
urlencoding::encode(symbol),
urlencoding::encode(date),
);
// Build client with User-Agent set on the builder — NOT as a manual header.
// This satisfies the privacy contract (§3.1): UA is set at the transport
// level, not injected as an explicit per-request header alongside
// Accept-Language, cookies, or other identifying headers.
let client = reqwest::Client::builder()
.user_agent("simpl-resultat")
.build()
.map_err(|_| FetchPriceError::Internal)?;
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/json")
// DO NOT add User-Agent here — it is already set on the client builder.
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|_| FetchPriceError::Network)?;
let status = resp.status();
match status.as_u16() {
200 => {
let price_resp: PriceResponse = resp
.json()
.await
.map_err(|_| FetchPriceError::Internal)?;
Ok(price_resp)
}
401 => Err(FetchPriceError::Auth),
403 => Err(FetchPriceError::PremiumRequired),
404 => Err(FetchPriceError::SymbolNotFound),
429 => {
// Parse `error.retry_after` from the JSON body.
let body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::Value::Null);
let retry_after_s = body
.pointer("/error/retry_after")
.and_then(|v| v.as_u64())
.unwrap_or(60);
Err(FetchPriceError::RateLimit { retry_after_s })
}
s if s >= 500 => Err(FetchPriceError::ProviderUnavailable),
_ => Err(FetchPriceError::Internal),
}
}
/// 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`.
///
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
/// as a manual header — so no fingerprinting headers leak.
#[tauri::command]
pub async fn fetch_price(
app: tauri::AppHandle,
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)
.await
.map_err(|e| price_error_to_string(&e))
}
/// Compute the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. Reads:
/// - `value_start`: latest snapshot line for the account whose
/// `snapshot_date <= period_start` (None if no prior snapshot).
/// - `value_end`: latest snapshot line for the account whose
/// `snapshot_date <= period_end` (None if no snapshot in range).
/// - cash flows: every linked transfer in `[period_start, period_end]`,
/// sign applied per direction (`in` → `+`, `out` → ``).
///
/// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn`
/// (Serialize) ready to ship across the Tauri boundary.
#[tauri::command]
pub fn compute_account_return(
app: tauri::AppHandle,
db_filename: String,
account_id: i64,
period_start: String,
period_end: String,
) -> Result<AccountReturn, String> {
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);
if !db_path.exists() {
return Err(format!(
"Profile database not found: {}",
db_path.display()
));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Cannot open database: {}", e))?;
let value_start = read_value_at_or_before(&conn, account_id, &period_start)?;
let value_end = read_value_at_or_before(&conn, account_id, &period_end)?;
let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?;
Ok(modified_dietz(
value_start,
value_end,
&cash_flows,
start_date,
end_date,
))
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
fn parse_iso_date(input: &str, field: &str) -> Result<NaiveDate, String> {
NaiveDate::parse_from_str(input, "%Y-%m-%d")
.map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e))
}
/// Reads the value of the snapshot line for `account_id` at the most recent
/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no
/// such snapshot exists for this account.
fn read_value_at_or_before(
conn: &Connection,
account_id: i64,
as_of_date: &str,
) -> Result<Option<f64>, String> {
// Single-row query: pick the latest snapshot date for this account that
// is on or before `as_of_date`, then return that line's value. Indexed
// on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
let mut stmt = conn
.prepare(
"SELECT l.value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = ?1
AND s.snapshot_date <= ?2
ORDER BY s.snapshot_date DESC
LIMIT 1",
)
.map_err(|e| format!("prepare value query: {}", e))?;
let mut rows = stmt
.query(rusqlite::params![account_id, as_of_date])
.map_err(|e| format!("execute value query: {}", e))?;
match rows.next().map_err(|e| format!("read value row: {}", e))? {
Some(row) => Ok(Some(
row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?,
)),
None => Ok(None),
}
}
/// Reads every linked transfer for `account_id` whose underlying
/// transaction's `transaction_date` falls inside `[period_start, period_end]`.
/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction`
/// (`in` → `+`, `out` → ``). Amounts come from the linked transaction.
fn read_cash_flows(
conn: &Connection,
account_id: i64,
period_start: &str,
period_end: &str,
) -> Result<Vec<(NaiveDate, f64)>, String> {
// NOTE: the transactions table column is `date` (not `transaction_date`).
// See `src-tauri/src/database/schema.sql:67`.
let mut stmt = conn
.prepare(
"SELECT t.date,
ABS(t.amount) AS abs_amount,
bat.direction
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
WHERE bat.account_id = ?1
AND t.date BETWEEN ?2 AND ?3
ORDER BY t.date",
)
.map_err(|e| format!("prepare flows query: {}", e))?;
let rows = stmt
.query_map(
rusqlite::params![account_id, period_start, period_end],
|row| {
// `transactions.date` may come back as String (TEXT) — keep
// the decoder generic enough.
let date_str: String = row.get(0)?;
let amount: f64 = row.get(1)?;
let direction: String = row.get(2)?;
Ok((date_str, amount, direction))
},
)
.map_err(|e| format!("execute flows query: {}", e))?;
let mut flows: Vec<(NaiveDate, f64)> = Vec::new();
for row_result in rows {
let (date_str, amount, direction) =
row_result.map_err(|e| format!("decode flow row: {}", e))?;
// `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column —
// see consolidated_schema.sql). Defensive trim of any trailing
// time component just in case.
let iso = date_str.split('T').next().unwrap_or(&date_str).to_string();
let date = parse_iso_date(&iso, "transaction_date")?;
let signed = match direction.as_str() {
"in" => amount,
"out" => -amount,
other => {
return Err(format!(
"Invalid transfer direction stored in DB: {}",
other
));
}
};
flows.push((date, signed));
}
Ok(flows)
}
// =============================================================================
// Tests for fetch_price (Issue #155)
// =============================================================================
//
// Strategy: use `mockito::Server::new_async()` as an in-process HTTP server.
// 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.
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn it_returns_price_on_200() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
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}"#,
)
.create_async()
.await;
let result = fetch_price_inner("test-token", "AAPL", "2026-04-25", &server.url()).await;
assert!(result.is_ok(), "expected Ok, got {:?}", result);
let resp = result.unwrap();
assert_eq!(resp.symbol, "AAPL");
assert!((resp.price - 173.45).abs() < f64::EPSILON);
assert_eq!(resp.currency, "USD");
assert!(!resp.cached);
}
#[tokio::test]
async fn it_returns_auth_error_on_401() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"invalid_token","message":"Invalid token"}}"#)
.create_async()
.await;
let result = fetch_price_inner("bad-token", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "auth");
}
#[tokio::test]
async fn it_returns_premium_required_on_403() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"premium_required","message":"Premium required"}}"#)
.create_async()
.await;
let result = fetch_price_inner("base-token", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "premium_required");
}
#[tokio::test]
async fn it_returns_symbol_not_found_on_404() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"symbol_not_found","message":"Unknown symbol"}}"#)
.create_async()
.await;
let result = fetch_price_inner("tok", "BOGUS", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "symbol_not_found");
}
#[tokio::test]
async fn it_parses_retry_after_on_429() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(429)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded","retry_after":42}}"#)
.create_async()
.await;
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "rate_limit");
assert_eq!(parsed["retry_after_s"], 42);
}
#[tokio::test]
async fn it_returns_provider_unavailable_on_502() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(502)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"provider_unavailable","message":"Yahoo unavailable"}}"#)
.create_async()
.await;
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "provider_unavailable");
}
/// Privacy assertion: the request must only carry `Authorization`, `Accept`,
/// `User-Agent`, and `Host`. No `Accept-Language`, no cookies, no `X-*`
/// tracking headers.
///
/// mockito's `match_header` with `Matcher::Missing` asserts that a header
/// is absent from the request. We assert absence for each forbidden header.
#[tokio::test]
async fn it_sends_only_authorization_accept_user_agent() {
let mut server = mockito::Server::new_async().await;
// Forbidden headers — must be absent from every request.
let forbidden = [
"cookie",
"accept-language",
"x-forwarded-for",
"x-real-ip",
"x-custom-tracking",
];
let mut mock_builder = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"symbol":"BTC","date":"2026-04-25","actual_date":null,"price":60000.0,"currency":"USD","source":"kraken","fetched_at":"2026-04-25T10:00:00Z","cached":true}"#,
);
for header in &forbidden {
mock_builder = mock_builder.match_header(*header, mockito::Matcher::Missing);
}
// Also assert the required headers ARE present.
mock_builder = mock_builder
.match_header("authorization", mockito::Matcher::Regex("^Bearer ".to_string()))
.match_header("accept", "application/json")
.match_header("user-agent", "simpl-resultat");
let _m = mock_builder.create_async().await;
let result =
fetch_price_inner("test-privacy-token", "BTC", "2026-04-25", &server.url()).await;
assert!(
result.is_ok(),
"expected Ok for privacy test, got {:?}",
result
);
// If any forbidden header was present, mockito would return 501 and the
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
assert_eq!(result.unwrap().symbol, "BTC");
}
}

View file

@ -0,0 +1,159 @@
// Feedback Hub client — forwards user-submitted feedback to the central
// feedback-api service. Routed through Rust (not direct fetch) so that:
// - CORS is bypassed (Tauri origin is not whitelisted server-side by design)
// - The exact payload leaving the machine is auditable in a single place
// - The pattern matches the other outbound calls (OAuth, license, updater)
//
// The feedback-api contract is documented in
// `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops
// any context key outside its whitelist, so this module only sends the
// fields declared in `Context` below.
use serde::{Deserialize, Serialize};
use std::time::Duration;
fn feedback_endpoint() -> String {
std::env::var("FEEDBACK_HUB_URL")
.unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string())
}
/// Context payload sent with a feedback submission. Keys MUST match the
/// server whitelist in `feedback-api/index.js` — unknown keys are dropped
/// silently. Each field is capped at 500 chars server-side.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeedbackContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Serialize)]
struct FeedbackPayload<'a> {
app_id: &'a str,
content: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<FeedbackContext>,
}
#[derive(Debug, Serialize)]
pub struct FeedbackSuccess {
pub id: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
struct FeedbackResponse {
id: String,
created_at: String,
}
/// Return a composed User-Agent string for the context payload, e.g.
/// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't
/// pull in an extra Tauri plugin just for this.
#[tauri::command]
pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String {
let version = app.package_info().version.to_string();
let os = std::env::consts::OS;
format!("Simpl'Résultat/{} ({})", version, os)
}
/// Submit a feedback to the Feedback Hub. Error strings are stable codes
/// ("invalid", "rate_limit", "server_error", "network_error") that the
/// frontend maps to i18n messages.
#[tauri::command]
pub async fn send_feedback(
content: String,
user_id: Option<String>,
context: Option<FeedbackContext>,
) -> Result<FeedbackSuccess, String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("invalid".to_string());
}
let payload = FeedbackPayload {
app_id: "simpl-resultat",
content: trimmed,
user_id,
context,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|_| "network_error".to_string())?;
let url = format!("{}/api/feedback", feedback_endpoint());
let res = client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|_| "network_error".to_string())?;
match res.status().as_u16() {
201 => {
let body: FeedbackResponse = res
.json()
.await
.map_err(|_| "server_error".to_string())?;
Ok(FeedbackSuccess {
id: body.id,
created_at: body.created_at,
})
}
400 => Err("invalid".to_string()),
429 => Err("rate_limit".to_string()),
_ => Err("server_error".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_skips_none_fields() {
let ctx = FeedbackContext {
page: Some("/settings".to_string()),
locale: Some("fr".to_string()),
theme: None,
viewport: None,
user_agent: None,
timestamp: None,
};
let json = serde_json::to_value(&ctx).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 2);
assert!(obj.contains_key("page"));
assert!(obj.contains_key("locale"));
}
#[test]
fn context_serializes_user_agent_camelcase() {
let ctx = FeedbackContext {
user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()),
..Default::default()
};
let json = serde_json::to_string(&ctx).unwrap();
assert!(json.contains("\"userAgent\""));
assert!(!json.contains("\"user_agent\""));
}
#[tokio::test]
async fn empty_content_is_rejected_locally() {
let res = send_feedback(" \n\t".to_string(), None, None).await;
assert_eq!(res.unwrap_err(), "invalid");
}
}

View file

@ -22,10 +22,11 @@ use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification.
//
// Production key generated 2026-04-10. The corresponding private key lives ONLY
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM.
// Production key generated 2026-04-25 alongside the maximus-api scaffold.
// The matching private key lives ONLY on the license server as env var
// ED25519_PRIVATE_KEY_PEM (see maximus-api/.env on Coolify).
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\
MCowBQYDK2VwAyEAmUTcl7xjt01uc2FhPgvP0at0I/Pie0JLh73AApNy+o8=\n\
-----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key";
@ -294,9 +295,9 @@ fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
}
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
// License server API base URL. Overridable via MAXIMUS_API_URL env var for development.
fn api_base_url() -> String {
std::env::var("SIMPL_API_URL")
std::env::var("MAXIMUS_API_URL")
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}

View file

@ -1,15 +1,25 @@
pub mod account_cache;
pub mod auth_commands;
pub mod backup_commands;
pub mod balance_commands;
pub mod entitlements;
pub mod export_import_commands;
pub mod feedback_commands;
pub mod fs_commands;
pub mod license_commands;
pub mod profile_commands;
// Modified Dietz return calculator — private helper module used by
// `balance_commands.rs`. Kept out of the wildcard re-export below because
// nothing outside `commands/` should depend on it.
pub(crate) mod return_calculator;
pub mod token_store;
pub use auth_commands::*;
pub use backup_commands::*;
pub use balance_commands::*;
pub use entitlements::*;
pub use export_import_commands::*;
pub use feedback_commands::*;
pub use fs_commands::*;
pub use license_commands::*;
pub use profile_commands::*;

View file

@ -114,10 +114,13 @@ pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(
#[tauri::command]
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
Ok(vec![
database::CONSOLIDATED_SCHEMA.to_string(),
database::SEED_CATEGORIES.to_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()])
}
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
@ -205,7 +208,7 @@ fn hex_encode(bytes: &[u8]) -> String {
}
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
if !hex.len().is_multiple_of(2) {
return Err("Invalid hex string length".to_string());
}
(0..hex.len())

View file

@ -0,0 +1,378 @@
//! Modified Dietz return calculator (Issue #142 / Bilan #4).
//!
//! Computes the time- and contribution-weighted return of a single account
//! over a period, given:
//! - the account value at `period_start` (snapshot lookup, may be missing),
//! - the account value at `period_end` (snapshot lookup, may be missing),
//! - the cash flows during the period (linked transfers — `+` for IN,
//! `-` for OUT; the caller already applies the direction sign).
//!
//! Modified Dietz formula:
//!
//! ```text
//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i))
//! ```
//!
//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start
//! to flow date`. A flow on day 0 is fully invested for the whole period
//! (W_i = 1) and a flow on the last day contributes nothing (W_i = 0).
//!
//! Annualization: `(1 + R)^(365 / T) - 1` for periods of strictly positive
//! length. A zero-length period (`period_start == period_end`) skips the
//! annualization step (would divide by zero).
//!
//! Edge cases (each surface as a typed flag on `AccountReturn` so the UI can
//! render an explicit warning instead of an opaque empty value):
//! - `value_start == None` → `is_partial = true`, `return_pct = None`
//! - `value_end == None` → `is_partial = true`, `return_pct = None`
//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
//! return collapses to the simple `(V_end - V_start) / V_start`
//! - `period_start == period_end` → no annualization (stays = return_pct)
//! - V_start = 0 and first flow > 0 → account created mid-period; the
//! denominator is `0 + W_first * CF_first`, which is positive as long as
//! the flow lands strictly before period_end
//! - account depleted then refilled → mathematically defined; the function
//! does not panic but the magnitude can look extreme — that is the
//! inherent Modified Dietz behaviour on accounts with near-zero invested
//! capital.
//!
//! Module is **private to the crate** (`pub(crate)`) and lives under
//! `commands/` per the spec — reused only by `balance_commands.rs`.
use chrono::NaiveDate;
use serde::Serialize;
/// Result of a Modified Dietz computation, ready to ship across the Tauri
/// boundary. Optional fields are `None` whenever the calculation cannot be
/// completed (missing snapshot endpoints) — the UI renders a dash + a tooltip
/// pointing at `is_partial` / `has_no_transfers_warning`.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct AccountReturn {
/// Account value at `period_start` (latest snapshot ≤ period_start).
pub value_start: Option<f64>,
/// Account value at `period_end` (latest snapshot ≤ period_end).
pub value_end: Option<f64>,
/// Sum of signed cash flows during the period (`+` IN, `-` OUT).
pub net_contributions: f64,
/// Modified Dietz return as a fraction (0.05 = +5%). `None` if either
/// endpoint snapshot is missing or the denominator is non-positive.
pub return_pct: Option<f64>,
/// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length
/// periods or whenever `return_pct` is `None`.
pub annualized_pct: Option<f64>,
/// `true` when at least one snapshot endpoint is missing — the UI labels
/// the result as "partial / non-significatif".
pub is_partial: bool,
/// `true` when the account had zero linked transfers during the period —
/// Modified Dietz collapses to the simple `(V_end - V_start) / V_start`,
/// but the UI surfaces a warning so the user can verify whether real
/// transfers were forgotten (untagged contributions skew the return).
pub has_no_transfers_warning: bool,
}
impl AccountReturn {
/// Default partial return when an endpoint is missing — keeps the
/// constructor calls in the algorithm body terse.
fn partial(
value_start: Option<f64>,
value_end: Option<f64>,
net_contributions: f64,
has_no_transfers_warning: bool,
) -> Self {
Self {
value_start,
value_end,
net_contributions,
return_pct: None,
annualized_pct: None,
is_partial: true,
has_no_transfers_warning,
}
}
}
/// Computes the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. See module docs for the full formula and
/// edge-case handling.
///
/// `cash_flows` is `(date, signed_amount)`. The caller is responsible for
/// applying the direction sign (`in` → `+`, `out` → ``) and for filtering
/// flows to the period; flows outside `[period_start, period_end]` are
/// skipped here too as a safety net.
pub(crate) fn modified_dietz(
value_start: Option<f64>,
value_end: Option<f64>,
cash_flows: &[(NaiveDate, f64)],
period_start: NaiveDate,
period_end: NaiveDate,
) -> AccountReturn {
// Filter flows to the period (defensive — caller already does this via
// SQL, but keep the guarantee here so the math never sees out-of-range
// weights).
let in_period: Vec<(NaiveDate, f64)> = cash_flows
.iter()
.copied()
.filter(|(d, _)| *d >= period_start && *d <= period_end)
.collect();
let net_contributions: f64 = in_period.iter().map(|(_, cf)| *cf).sum();
let has_no_transfers_warning = in_period.is_empty();
// Endpoint guards — without both V_start and V_end we cannot return a
// numeric result.
let v_start = match value_start {
Some(v) => v,
None => {
return AccountReturn::partial(
value_start,
value_end,
net_contributions,
has_no_transfers_warning,
);
}
};
let v_end = match value_end {
Some(v) => v,
None => {
return AccountReturn::partial(
value_start,
value_end,
net_contributions,
has_no_transfers_warning,
);
}
};
// Period length in days. `(period_end - period_start)` returns
// `chrono::Duration`; `.num_days()` is `i64`. A zero-length period
// (same-day) skips weighting and annualization.
let total_days = (period_end - period_start).num_days();
let denominator: f64 = if total_days <= 0 {
// Same-day period: weights collapse to either 0 or undefined; treat
// every flow as fully invested (W = 1) so the denominator is
// V_start + sum(CF). This keeps the function defined when callers
// pass `period_start == period_end`.
v_start + net_contributions
} else {
let total = total_days as f64;
let weighted_sum: f64 = in_period
.iter()
.map(|(date, cf)| {
let t_i = (*date - period_start).num_days() as f64;
let w_i = (total - t_i) / total;
w_i * cf
})
.sum();
v_start + weighted_sum
};
// A non-positive denominator means we have no invested base to annualize
// against (e.g. depleted then refilled with a single late flow). Return
// the raw V_end - V_start - CF as the numerator and flag is_partial so
// the UI can show "Performance non significative" — but only when V_start
// is also 0 / negative; if V_start > 0 we keep the standard math.
if denominator <= 0.0 {
return AccountReturn {
value_start: Some(v_start),
value_end: Some(v_end),
net_contributions,
return_pct: None,
annualized_pct: None,
is_partial: true,
has_no_transfers_warning,
};
}
let numerator = v_end - v_start - net_contributions;
let return_pct = numerator / denominator;
// Annualization only makes sense for strictly positive periods.
let annualized_pct = if total_days > 0 {
let exponent = 365.0 / total_days as f64;
Some((1.0 + return_pct).powf(exponent) - 1.0)
} else {
None
};
AccountReturn {
value_start: Some(v_start),
value_end: Some(v_end),
net_contributions,
return_pct: Some(return_pct),
annualized_pct,
is_partial: false,
has_no_transfers_warning,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Small helper that turns a `YYYY-MM-DD` string literal into a
/// `NaiveDate` — keeps the test bodies readable.
fn d(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("test date parses")
}
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn nominal_two_flows_at_one_quarter_and_three_quarters() {
// 100-day period (2026-01-01 → 2026-04-11). V_start = 1000, V_end =
// 1100. CF1 = +50 at day 25, CF2 = +30 at day 75.
let start = d("2026-01-01");
let end = d("2026-04-11"); // 100 days later
let flows = vec![(d("2026-01-26"), 50.0), (d("2026-03-17"), 30.0)];
let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);
// Sanity / shape
assert_eq!(r.value_start, Some(1000.0));
assert_eq!(r.value_end, Some(1100.0));
assert_eq!(r.net_contributions, 80.0);
assert!(!r.is_partial);
assert!(!r.has_no_transfers_warning);
// Hand calc:
// T = 100, t1 = 25, t2 = 75
// W1 = 75/100 = 0.75, W2 = 25/100 = 0.25
// numerator = 1100 - 1000 - 80 = 20
// denominator = 1000 + 0.75*50 + 0.25*30 = 1045
// R = 20 / 1045 ≈ 0.01913876
let r_pct = r.return_pct.expect("nominal case has a return");
assert!(
approx(r_pct, 20.0 / 1045.0, 1e-9),
"return_pct = {} (expected ≈ {})",
r_pct,
20.0 / 1045.0
);
// Annualization: (1 + R)^(365/100) - 1
let expected_ann = (1.0_f64 + 20.0 / 1045.0).powf(365.0 / 100.0) - 1.0;
let ann = r.annualized_pct.expect("nominal case is annualized");
assert!(approx(ann, expected_ann, 1e-9), "annualized = {}", ann);
}
#[test]
fn no_prior_snapshot_marks_partial() {
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-02-01"), 200.0)];
let r = modified_dietz(None, Some(1500.0), &flows, start, end);
assert_eq!(r.value_start, None);
assert_eq!(r.value_end, Some(1500.0));
assert!(r.is_partial, "missing V_start must flag is_partial");
assert_eq!(r.return_pct, None);
assert_eq!(r.annualized_pct, None);
assert!(!r.has_no_transfers_warning);
// Still surface the contributions sum for the UI breakdown card.
assert_eq!(r.net_contributions, 200.0);
}
#[test]
fn no_end_snapshot_marks_partial() {
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-02-15"), -100.0)];
let r = modified_dietz(Some(2000.0), None, &flows, start, end);
assert_eq!(r.value_start, Some(2000.0));
assert_eq!(r.value_end, None);
assert!(r.is_partial);
assert_eq!(r.return_pct, None);
assert_eq!(r.annualized_pct, None);
assert_eq!(r.net_contributions, -100.0);
}
#[test]
fn account_created_mid_period_with_first_flow() {
// V_start = 0, single +500 flow at day 30 of a 90-day period, V_end
// = 510. The flow's weight is W = (90-30)/90 = 2/3.
let start = d("2026-01-01");
let end = d("2026-04-01"); // 90 days
let flows = vec![(d("2026-01-31"), 500.0)];
let r = modified_dietz(Some(0.0), Some(510.0), &flows, start, end);
// numerator = 510 - 0 - 500 = 10
// W = (90-30)/90 ≈ 0.6666667
// denominator = 0 + 0.6666667 * 500 ≈ 333.3333
// R ≈ 10 / 333.3333 = 0.03
let expected = 10.0 / ((90.0 - 30.0) / 90.0 * 500.0);
let r_pct = r.return_pct.expect("account-created case computes");
assert!(
approx(r_pct, expected, 1e-9),
"return_pct = {} (expected ≈ {})",
r_pct,
expected
);
assert!(!r.is_partial);
assert!(!r.has_no_transfers_warning);
}
#[test]
fn depleted_then_refilled_does_not_panic() {
// Pathological: V_start = 100, then -100 flow on day 1 (account
// emptied), then +200 flow on day 60 of a 90-day period, V_end =
// 210. Modified Dietz handles this without panicking; the value
// may look extreme but the function must stay defined.
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-01-02"), -100.0), (d("2026-03-02"), 200.0)];
let r = modified_dietz(Some(100.0), Some(210.0), &flows, start, end);
// Whatever the math says, the call must complete cleanly. We don't
// assert a precise return — the goal is "no panic, finite output if
// the denominator is positive, else partial flag".
if let Some(rp) = r.return_pct {
assert!(rp.is_finite(), "return must be a finite f64");
}
// Net flows = -100 + 200 = 100
assert_eq!(r.net_contributions, 100.0);
// Not flagged "no transfers" since we have two flows.
assert!(!r.has_no_transfers_warning);
}
#[test]
fn no_transfers_collapses_to_simple_return() {
// No cash flows → R should equal (V_end - V_start) / V_start exactly.
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows: Vec<(NaiveDate, f64)> = vec![];
let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);
assert!(r.has_no_transfers_warning);
assert_eq!(r.net_contributions, 0.0);
let r_pct = r.return_pct.expect("simple-return case has a value");
let simple = (1100.0 - 1000.0) / 1000.0; // = 0.1
assert!(approx(r_pct, simple, 1e-12), "simple return mismatch: {}", r_pct);
}
#[test]
fn annualization_on_90_day_period_matches_compound_formula() {
// Direct check of the annualization branch with a clean R.
let start = d("2026-01-01");
let end = d("2026-04-01"); // 90 days
let flows: Vec<(NaiveDate, f64)> = vec![];
// V_start = 1000, V_end = 1050 → R = 0.05
let r = modified_dietz(Some(1000.0), Some(1050.0), &flows, start, end);
let expected_ann = (1.0_f64 + 0.05).powf(365.0 / 90.0) - 1.0;
let ann = r.annualized_pct.expect("90-day period annualizes");
assert!(
approx(ann, expected_ann, 1e-12),
"annualized = {} (expected {})",
ann,
expected_ann
);
}
}

View file

@ -0,0 +1,153 @@
-- Balance sheet schema (Bilan) — Migration v9
-- Created: 2026-04-25
-- Issue: #138 (Bilan #1a — Schema migration + balance.service skeleton + AccountsPage)
--
-- Adds 5 tables, 7 indexes, and seeds 7 standard categories (5 simple + 2 priced).
-- Conventions aligned with consolidated_schema.sql:
-- - INTEGER PRIMARY KEY AUTOINCREMENT
-- - REAL for monetary amounts (matches transactions.amount)
-- - snake_case
-- - FK with explicit ON DELETE policies
-- - is_* INTEGER NOT NULL DEFAULT for booleans
-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps
--
-- MVP constraints (decisions-log + spec-decisions-bilan.md):
-- - balance_accounts.currency hardcoded to 'CAD' via CHECK — v2 will lift this
-- - balance_account_transfers.transaction_id ON DELETE RESTRICT (preserves
-- reproducibility of Modified Dietz returns calculated on past periods)
-- - balance_snapshot_lines kind invariants: (quantity, unit_price) both NULL
-- (simple kind) OR both NOT NULL (priced kind)
-- =========================================================================
-- balance_categories — taxonomy of asset types
-- =========================================================================
-- Seeded with 7 standard categories (is_seed = 1). Users can add custom
-- categories with their own kind ('simple' or 'priced'). Seeded categories
-- can be renamed but never deleted.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE, -- 'cash', 'tfsa', 'rrsp', 'fund', 'stock', 'crypto', 'other'
i18n_key TEXT NOT NULL, -- 'balance.category.cash', etc.
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
);
-- =========================================================================
-- balance_accounts — user's specific holdings
-- =========================================================================
-- A "TFSA at Wealthsimple", a "BTC in Ledger", etc.
-- For priced categories, `symbol` identifies the security/coin.
-- For simple categories, `symbol` is NULL.
-- MVP: currency hardcoded to 'CAD' — v2 lifts the CHECK and adds a rate table.
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
);
-- =========================================================================
-- balance_snapshots — point-in-time captures
-- =========================================================================
-- One snapshot per `snapshot_date` (UNIQUE). Editing a snapshot = updating
-- its lines, not creating a duplicate.
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
);
-- =========================================================================
-- balance_snapshot_lines — one row per (snapshot, account)
-- =========================================================================
-- Storage shape:
-- - simple kind: value is set, quantity/unit_price are NULL
-- - priced kind: quantity + unit_price are set, value = quantity * unit_price
-- Stored denormalized (value always set, even for priced rows) so reports
-- are reproducible without re-fetching prices and the user can override a
-- fetched price.
-- The CHECK enforces kind invariants at SQL level for direct-write safety.
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, -- 'manual' | 'maximus-api' | NULL for simple
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)
)
);
-- =========================================================================
-- balance_account_transfers — links transactions to accounts (capital flows)
-- =========================================================================
-- Used by the Modified Dietz return calculator (Issue #142 / Bilan #4) to
-- separate contributions from gains. Direction follows the account's
-- perspective: 'in' = capital added (deposit/buy), 'out' = capital removed
-- (withdrawal/sell). The amount is taken from the linked transaction (no
-- duplication).
--
-- transaction_id ON DELETE RESTRICT: preserves reproducibility of past
-- Modified Dietz returns. The UI must force unlink before allowing the
-- transaction to be deleted.
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)
);
-- =========================================================================
-- Indexes (7 total)
-- =========================================================================
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);
-- =========================================================================
-- Seed (7 standard categories — idempotent via INSERT OR IGNORE on `key`)
-- =========================================================================
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
('fund', 'balance.category.fund', 'simple', 40, 1),
('other', 'balance.category.other', 'simple', 50, 1),
('stock', 'balance.category.stock', 'priced', 60, 1),
('crypto', 'balance.category.crypto', 'priced', 70, 1);

View file

@ -1,6 +1,9 @@
-- 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)
-- 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,
@ -39,6 +42,7 @@ CREATE TABLE IF NOT EXISTS categories (
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
);
@ -177,8 +181,584 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon
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
-- ============================================================================
-- 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

@ -1,3 +1,4 @@
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");

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.8.0",
"version": "0.9.1",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",
@ -18,7 +18,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com https://feedback.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
}
},
"bundle": {
@ -26,6 +26,7 @@
"targets": ["nsis", "deb", "rpm"],
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",

View file

@ -14,7 +14,17 @@ import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import SettingsPage from "./pages/SettingsPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsLayout from "./pages/settings/SettingsLayout";
import SettingsHomePage from "./pages/settings/SettingsHomePage";
import UsersSettingsPage from "./pages/settings/UsersSettingsPage";
import DataSettingsPage from "./pages/settings/DataSettingsPage";
import SystemsSettingsPage from "./pages/settings/SystemsSettingsPage";
import AccountsPage from "./pages/AccountsPage";
import BalancePage from "./pages/BalancePage";
import SnapshotEditPage from "./pages/SnapshotEditPage";
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
@ -109,7 +119,24 @@ export default function App() {
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<SettingsHomePage />} />
<Route path="users" element={<UsersSettingsPage />} />
<Route path="data" element={<DataSettingsPage />} />
<Route path="systems" element={<SystemsSettingsPage />} />
</Route>
<Route path="/balance" element={<BalancePage />} />
<Route path="/balance/accounts" element={<AccountsPage />} />
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
<Route
path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />}
/>
<Route
path="/settings/categories/migrate"
element={<CategoriesMigrationPage />}
/>
<Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
</Route>

View file

@ -0,0 +1,180 @@
/**
* Profile fixtures for the v2 v1 categories migration tests.
*
* These fixtures are intentionally loose they match the minimal shapes
* consumed by categoryMappingService.ProfileData and the broader migration
* test helpers (budget/categorization regression). They are *not* a mock of
* the full Tauri DB layer; tests that need DB access still mock `getDb`.
*
* Three flavours are provided:
* - `makeV2Profile()` realistic v2-seeded profile (~30 cats)
* - `makeV1Profile()` same user data but already on v1 taxonomy
* - `makeV2ProfileWithCustom()` v2 profile with 3 user-created categories
*/
import type {
ProfileData,
V2CategoryInput,
V2KeywordInput,
V2TransactionInput,
V2SupplierInput,
} from "../services/categoryMappingService";
// ---------------------------------------------------------------------------
// v2 seed (excerpt matching DEFAULT_MAPPINGS keys)
// ---------------------------------------------------------------------------
const V2_STRUCTURAL_PARENTS: V2CategoryInput[] = [
{ id: 1, name: "Revenus", parent_id: null },
{ id: 2, name: "Dépenses récurrentes", parent_id: null },
{ id: 3, name: "Dépenses ponctuelles", parent_id: null },
{ id: 4, name: "Maison", parent_id: null },
{ id: 5, name: "Placements", parent_id: null },
{ id: 6, name: "Autres", parent_id: null },
];
const V2_SEEDED_CATS: V2CategoryInput[] = [
// Revenus
{ id: 10, name: "Paie", parent_id: 1 },
{ id: 11, name: "Autres revenus", parent_id: 1 },
// Dépenses récurrentes
{ id: 20, name: "Loyer", parent_id: 2 },
{ id: 21, name: "Électricité", parent_id: 2 },
{ id: 22, name: "Épicerie", parent_id: 2 },
{ id: 23, name: "Dons", parent_id: 2 },
{ id: 24, name: "Restaurant", parent_id: 2 },
{ id: 25, name: "Frais bancaires", parent_id: 2 },
{ id: 26, name: "Jeux, Films & Livres", parent_id: 2 },
{ id: 27, name: "Abonnements Musique", parent_id: 2 },
{ id: 28, name: "Transport en commun", parent_id: 2 },
{ id: 29, name: "Internet & Télécom", parent_id: 2 },
{ id: 30, name: "Animaux", parent_id: 2 },
{ id: 31, name: "Assurances", parent_id: 2 },
{ id: 32, name: "Pharmacie", parent_id: 2 },
// Dépenses ponctuelles
{ id: 40, name: "Voiture", parent_id: 3 },
{ id: 41, name: "Amazon", parent_id: 3 },
{ id: 42, name: "Électroniques", parent_id: 3 },
{ id: 43, name: "Alcool", parent_id: 3 },
{ id: 44, name: "Cadeaux", parent_id: 3 },
{ id: 45, name: "Vêtements", parent_id: 3 },
{ id: 47, name: "Voyage", parent_id: 3 },
{ id: 48, name: "Sports & Plein air", parent_id: 3 },
{ id: 49, name: "Spectacles & sorties", parent_id: 3 },
// Maison
{ id: 50, name: "Hypothèque", parent_id: 4 },
{ id: 51, name: "Achats maison", parent_id: 4 },
{ id: 52, name: "Entretien maison", parent_id: 4 },
{ id: 53, name: "Électroménagers & Meubles", parent_id: 4 },
// Autres
{ id: 70, name: "Impôts", parent_id: 6 },
{ id: 71, name: "Paiement CC", parent_id: 6 },
{ id: 72, name: "Retrait cash", parent_id: 6 },
];
const V2_KEYWORDS: V2KeywordInput[] = [
{ id: 101, keyword: "PAIE DEPOT", category_id: 10 },
{ id: 102, keyword: "IGA", category_id: 22 },
{ id: 103, keyword: "METRO PLUS", category_id: 22 },
{ id: 104, keyword: "STM", category_id: 28 },
{ id: 105, keyword: "SHELL", category_id: 40 },
{ id: 106, keyword: "NETFLIX", category_id: 26 },
{ id: 107, keyword: "PRIMEVIDEO", category_id: 26 },
{ id: 108, keyword: "AMAZON", category_id: 41 },
];
const V2_SUPPLIERS: V2SupplierInput[] = [
{ id: 501, name: "STM" },
{ id: 502, name: "Shell Canada" },
{ id: 503, name: "Hilton Montreal" },
{ id: 504, name: "IGA" },
{ id: 505, name: "Hydro-Québec" },
];
const V2_TRANSACTIONS: V2TransactionInput[] = [
{ id: 1, description: "DEPOT PAIE MAX", category_id: 10 },
{ id: 2, description: "IGA #5555", category_id: 22, supplier_id: 504 },
{ id: 3, description: "SHELL #231 LAVAL", category_id: 40, supplier_id: 502 },
{ id: 4, description: "STM CARTE OPUS", category_id: 28, supplier_id: 501 },
{ id: 5, description: "HYDRO-QUEBEC FACTURE", category_id: 21, supplier_id: 505 },
{ id: 6, description: "NETFLIX.COM", category_id: 26 },
{ id: 7, description: "HILTON SEATTLE", category_id: 47, supplier_id: 503 },
{ id: 8, description: "AMAZON.CA *XYZ", category_id: 41 },
];
// ---------------------------------------------------------------------------
// Public builders
// ---------------------------------------------------------------------------
export function makeV2Profile(): ProfileData {
return {
v2Categories: [...V2_STRUCTURAL_PARENTS, ...V2_SEEDED_CATS],
keywords: [...V2_KEYWORDS],
transactions: [...V2_TRANSACTIONS],
suppliers: [...V2_SUPPLIERS],
};
}
export function makeV2ProfileWithCustom(): ProfileData {
const base = makeV2Profile();
const custom: V2CategoryInput[] = [
{ id: 9001, name: "Projet maison", parent_id: 3 },
{ id: 9002, name: "Activités enfants", parent_id: 3 },
{ id: 9003, name: "Hobby moto", parent_id: 3 },
];
return {
...base,
v2Categories: [...base.v2Categories, ...custom],
};
}
/**
* v1 "profile" same user data after the migration has been applied.
* Category ids follow the v1 taxonomy (`categoryTaxonomyV1.json`) and the
* DEFAULT_MAPPINGS table from categoryMappingService. Useful for parameterised
* regression tests where the behaviour must be identical on v1 and v2.
*/
export function makeV1Profile(): ProfileData {
// v1 categories in this shape are just for iteration — the SQL write-over
// path runs the real v1 taxonomy. We only need a few leaves for keyword /
// budget tests that don't actually inspect the full tree.
const v1Cats: V2CategoryInput[] = [
{ id: 1011, name: "Paie régulière", parent_id: 1010 },
{ id: 1090, name: "Autres revenus", parent_id: 1000 },
{ id: 1111, name: "Épicerie régulière", parent_id: 1110 },
{ id: 1121, name: "Restaurants & sorties", parent_id: 1120 },
{ id: 1211, name: "Loyer", parent_id: 1210 },
{ id: 1221, name: "Électricité", parent_id: 1220 },
{ id: 1521, name: "Autobus & métro", parent_id: 1520 },
{ id: 1512, name: "Essence", parent_id: 1510 },
{ id: 1713, name: "Abonnements streaming", parent_id: 1710 },
{ id: 1533, name: "Hébergement en voyage", parent_id: 1530 },
{ id: 1946, name: "Achats divers", parent_id: 1940 },
];
// Keywords/suppliers/transactions carry v1 category_id values now.
const v1Keywords: V2KeywordInput[] = [
{ id: 101, keyword: "PAIE DEPOT", category_id: 1011 },
{ id: 102, keyword: "IGA", category_id: 1111 },
{ id: 103, keyword: "METRO PLUS", category_id: 1111 },
{ id: 104, keyword: "STM", category_id: 1521 },
{ id: 105, keyword: "SHELL", category_id: 1512 },
{ id: 106, keyword: "NETFLIX", category_id: 1713 },
{ id: 107, keyword: "PRIMEVIDEO", category_id: 1713 },
{ id: 108, keyword: "AMAZON", category_id: 1946 },
];
const v1Tx: V2TransactionInput[] = [
{ id: 1, description: "DEPOT PAIE MAX", category_id: 1011 },
{ id: 2, description: "IGA #5555", category_id: 1111, supplier_id: 504 },
{ id: 3, description: "SHELL #231 LAVAL", category_id: 1512, supplier_id: 502 },
{ id: 4, description: "STM CARTE OPUS", category_id: 1521, supplier_id: 501 },
{ id: 5, description: "HYDRO-QUEBEC FACTURE", category_id: 1221, supplier_id: 505 },
{ id: 6, description: "NETFLIX.COM", category_id: 1713 },
{ id: 7, description: "HILTON SEATTLE", category_id: 1533, supplier_id: 503 },
{ id: 8, description: "AMAZON.CA *XYZ", category_id: 1946 },
];
return {
v2Categories: v1Cats,
keywords: v1Keywords,
transactions: v1Tx,
suppliers: [...V2_SUPPLIERS],
};
}

View file

@ -0,0 +1,575 @@
/**
* Integration tests for the Bilan (balance sheet) feature Issue #144.
*
* Cross-cutting tests that exercise the *whole* TS surface in one go:
*
* account priced category priced snapshot linked transfer return
*
* Like `category-migration.test.ts` we cannot spin up real `tauri-plugin-sql`
* (the bridge only lives inside the Tauri WebView). Instead we drive every
* service against an in-memory FakeDb that:
* - records every executed SQL,
* - returns hand-tuned `select` results to mimic the real schema,
* - simulates `lastInsertId` / `rowsAffected` for INSERT/DELETE.
*
* The Tauri `invoke` is mocked `computeAccountReturn` lives on the Rust
* side (`compute_account_return`), so we assert the request payload and
* have the mock return a stable `AccountReturn` shape. The Rust math itself
* is covered by `return_calculator.rs`'s `#[cfg(test)] mod tests`.
*
* Scope (from spec-plan-bilan.md, Issue #144):
* 1. End-to-end happy path
* 2. Currency-lock (CHECK `currency = 'CAD'`) at the service level
* 3. Migration v9 on a seeded DB covered in Rust (lib.rs `mod tests`)
* 4. TransactionsPage non-regression for the inlined transfer icon
* 5. Coverage best-effort (deferred see decisions-log.md)
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("../services/profileService", () => ({
loadProfiles: vi.fn(),
}));
import { getDb } from "../services/db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "../services/profileService";
import {
createBalanceCategory,
createBalanceAccount,
listBalanceAccounts,
createSnapshot,
upsertSnapshotLines,
listLinesBySnapshot,
linkTransfer,
unlinkTransfer,
listAccountTransfers,
computeAccountReturn,
BalanceServiceError,
PRICED_VALUE_TOLERANCE,
} from "../services/balance.service";
// ---------------------------------------------------------------------------
// FakeDb harness: scripted select results, recorded execute calls.
// ---------------------------------------------------------------------------
interface FakeDb {
calls: Array<{ sql: string; params?: unknown[] }>;
selectQueue: Array<unknown[]>;
executeQueue: Array<{ lastInsertId?: number; rowsAffected?: number }>;
select: ReturnType<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
}
function makeFakeDb(): FakeDb {
const db: FakeDb = {
calls: [],
selectQueue: [],
executeQueue: [],
select: vi.fn(),
execute: vi.fn(),
};
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.selectQueue.length === 0) {
throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
}
return db.selectQueue.shift();
});
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.executeQueue.length === 0) {
// Default: 1 affected row, monotonically increasing lastInsertId
return { rowsAffected: 1, lastInsertId: db.calls.length };
}
return db.executeQueue.shift();
});
return db;
}
let fake: FakeDb;
beforeEach(() => {
fake = makeFakeDb();
vi.mocked(getDb).mockResolvedValue(
{ select: fake.select, execute: fake.execute } as never
);
vi.mocked(invoke).mockReset();
vi.mocked(loadProfiles).mockReset();
});
// Helper: queue a sequence of SELECT results in FIFO order.
function queueSelects(...rows: unknown[][]) {
for (const r of rows) fake.selectQueue.push(r);
}
// Helper: queue a sequence of EXECUTE results in FIFO order.
function queueExecutes(
...results: Array<{ lastInsertId?: number; rowsAffected?: number }>
) {
for (const r of results) fake.executeQueue.push(r);
}
// ---------------------------------------------------------------------------
// 1. End-to-end happy path
// ---------------------------------------------------------------------------
//
// Walks the full Bilan flow as if the user just installed the app:
// 1. Create a custom priced category ("etf-prov")
// 2. Create an account on that category with a stock symbol
// 3. Reload the joined accounts list and confirm the account is there
// 4. Create a snapshot dated today
// 5. Save a priced line for the new account (qty * price = value)
// 6. Read the lines back and confirm what was persisted
// 7. Link a transaction to the account as a +CAD deposit
// 8. Compute the account's return → mock returns a stable shape, we
// assert the wiring uses the active profile's db_filename and forwards
// every parameter as ISO YYYY-MM-DD.
//
// Each step is asserted at the service-call level (params + queued SQL),
// then we run cross-step sanity checks.
describe("integration — Bilan end-to-end happy path", () => {
it("walks account → priced category → snapshot → transfer → return cleanly", async () => {
// ---- 1. Create a custom priced category ----
queueExecutes({ lastInsertId: 100 });
const categoryId = await createBalanceCategory({
key: "etf-prov",
i18n_key: "balance.category.etf_prov",
kind: "priced",
sort_order: 80,
asset_type: "stock",
});
expect(categoryId).toBe(100);
// ---- 2. Create the account on that category ----
// Service first SELECTs the category to validate it exists, then
// INSERTs the account.
queueSelects([
{
id: 100,
key: "etf-prov",
i18n_key: "balance.category.etf_prov",
kind: "priced",
sort_order: 80,
is_active: 1,
is_seed: 0,
},
]);
queueExecutes({ lastInsertId: 7 });
const accountId = await createBalanceAccount({
balance_category_id: categoryId,
name: "VFV (Wealthsimple)",
symbol: "VFV.TO",
});
expect(accountId).toBe(7);
// ---- 3. listBalanceAccounts: account joined with category ----
queueSelects([
{
id: 7,
balance_category_id: 100,
name: "VFV (Wealthsimple)",
symbol: "VFV.TO",
currency: "CAD",
notes: null,
is_active: 1,
archived_at: null,
created_at: "",
updated_at: "",
category_key: "etf-prov",
category_i18n_key: "balance.category.etf_prov",
category_kind: "priced",
},
]);
const accounts = await listBalanceAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].category_kind).toBe("priced");
expect(accounts[0].symbol).toBe("VFV.TO");
// ---- 4. Create a snapshot dated 2026-04-25 ----
// createSnapshot first SELECTs by date (must be empty) then INSERTs.
queueSelects([]); // no existing snapshot
queueExecutes({ lastInsertId: 50 });
const snapshotId = await createSnapshot({ snapshot_date: "2026-04-25" });
expect(snapshotId).toBe(50);
// ---- 5. Save a priced line: 10 shares × $200 = $2000 ----
// upsertSnapshotLines: SELECT snapshot, then DELETE existing lines, then
// one INSERT per line, then UPDATE updated_at.
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
queueExecutes(
{ rowsAffected: 0 }, // delete (no prior lines)
{ lastInsertId: 200 }, // insert priced line
{ rowsAffected: 1 } // bump updated_at
);
await upsertSnapshotLines(50, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 200,
value: 2000,
},
]);
// The 2nd execute call should be the INSERT with the priced placeholders.
const insertCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_lines")
);
expect(insertCall).toBeDefined();
expect(insertCall!.params).toEqual([50, 7, 10, 200, 2000]);
// ---- 6. Read the lines back ----
queueSelects([
{
id: 200,
snapshot_id: 50,
account_id: 7,
quantity: 10,
unit_price: 200,
value: 2000,
price_source: "manual",
price_fetched_at: null,
created_at: "",
updated_at: "",
},
]);
const lines = await listLinesBySnapshot(50);
expect(lines).toHaveLength(1);
expect(lines[0].value).toBe(2000);
expect(lines[0].quantity).toBe(10);
expect(lines[0].unit_price).toBe(200);
// ---- 7. Link a transaction (id=42) as a +CAD deposit (in) ----
// linkTransfer: SELECT existing duplicate (none), then INSERT.
queueSelects([]); // no existing duplicate
queueExecutes({ lastInsertId: 9 });
const transferId = await linkTransfer(7, 42, "in", "monthly contribution");
expect(transferId).toBe(9);
const linkCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_account_transfers")
);
expect(linkCall).toBeDefined();
expect(linkCall!.params).toEqual([7, 42, "in", "monthly contribution"]);
// ---- 8. Compute the account return ----
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#3b82f6",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const fakeReturn = {
value_start: 1500,
value_end: 2000,
net_contributions: 400,
return_pct: 0.0667, // (2000 - 1500 - 400) / (1500 + W*400) ≈ 6.67%
annualized_pct: 0.28,
is_partial: false,
has_no_transfers_warning: false,
};
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
const ret = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
expect(ret).toEqual(fakeReturn);
// Wiring check: profile resolution + ISO date forwarding.
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
dbFilename: "max.db",
accountId: 7,
periodStart: "2026-01-01",
periodEnd: "2026-04-25",
});
// ---- Cross-step sanity: every coherent value matches expectations.
// The end snapshot value (2000) matches what we saved.
expect(ret.value_end).toBe(2000);
// The reported return is a finite, non-zero number on a non-trivial period.
expect(ret.return_pct).not.toBeNull();
expect(Number.isFinite(ret.return_pct!)).toBe(true);
// Net contributions match the 1 linked transfer (+400 in).
expect(ret.net_contributions).toBeGreaterThan(0);
});
it("supports unlink as the inverse of link", async () => {
queueExecutes({ rowsAffected: 1 });
await expect(unlinkTransfer(7, 42)).resolves.toBeUndefined();
const unlinkCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("DELETE FROM balance_account_transfers")
);
expect(unlinkCall!.params).toEqual([7, 42]);
});
it("listAccountTransfers reads back what link wrote (joined view)", async () => {
queueSelects([
{
id: 9,
account_id: 7,
transaction_id: 42,
direction: "in",
notes: "monthly contribution",
created_at: "2026-04-25 10:00:00",
transaction_date: "2026-04-15",
transaction_description: "Wealthsimple contrib",
transaction_amount: -400,
account_name: "VFV (Wealthsimple)",
},
]);
const links = await listAccountTransfers(7);
expect(links).toHaveLength(1);
expect(links[0].direction).toBe("in");
expect(links[0].account_name).toBe("VFV (Wealthsimple)");
});
});
// ---------------------------------------------------------------------------
// 2. Currency lock — CAD only at the MVP
// ---------------------------------------------------------------------------
//
// The MVP locks accounts to CAD: the SQL CHECK is `currency = 'CAD'` and the
// service rejects any other value with a typed `currency_unsupported` before
// the SQL even fires. Asserts:
// - USD is rejected with the typed code,
// - the rejection happens BEFORE any SELECT/EXECUTE on the DB,
// - the default (no `currency` field) flows through and lands as 'CAD',
// - the SQL CHECK side is covered in Rust (lib.rs `migration_v9_*` tests).
describe("integration — currency lock (CAD only)", () => {
it("rejects USD at the service level with a typed error", async () => {
await expect(
createBalanceAccount({
balance_category_id: 1,
name: "USD account",
currency: "USD",
})
).rejects.toBeInstanceOf(BalanceServiceError);
try {
await createBalanceAccount({
balance_category_id: 1,
name: "USD account",
currency: "USD",
});
} catch (e) {
expect((e as BalanceServiceError).code).toBe("currency_unsupported");
}
// CRITICAL: the rejection must happen up-front — no DB hit.
expect(fake.calls.length).toBe(0);
});
it("accepts the default and persists 'CAD' explicitly", async () => {
queueSelects([
{
id: 1,
key: "cash",
i18n_key: "balance.category.cash",
kind: "simple",
sort_order: 10,
is_active: 1,
is_seed: 1,
},
]);
queueExecutes({ lastInsertId: 5 });
await createBalanceAccount({
balance_category_id: 1,
name: "Encaisse",
});
const insertCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_accounts")
);
expect(insertCall).toBeDefined();
// [category_id, name, symbol, currency, notes]
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]);
});
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
for (const ccy of ["EUR", "GBP", "JPY", "AUD"]) {
await expect(
createBalanceAccount({
balance_category_id: 1,
name: `Mystery ${ccy}`,
currency: ccy,
})
).rejects.toMatchObject({ code: "currency_unsupported" });
}
expect(fake.calls.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// 3. Priced-kind invariant — coherence of the qty × price = value chain
// ---------------------------------------------------------------------------
//
// Tied to the priced-kind path, but at the integration layer: a snapshot
// saved with a drifting (qty * price ≠ value) line must be rejected before
// any DB mutation, so the SQL CHECK never has the chance to fire and we
// don't accidentally clear pre-existing lines.
describe("integration — priced invariant rejects out-of-tolerance saves", () => {
it("does not run DELETE when one line is bad", async () => {
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
await expect(
upsertSnapshotLines(50, [
{ account_id: 1, value: 1000 },
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 25,
// expected ≈ 250, way beyond ε
value: 999,
},
])
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
// Critical safety: the DELETE must not have fired — otherwise the user
// would lose all existing lines on a partial save.
const deletes = fake.calls.filter(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("DELETE FROM balance_snapshot_lines")
);
expect(deletes).toHaveLength(0);
});
it("accepts a drift just within the tolerance", async () => {
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
queueExecutes(
{ rowsAffected: 0 },
{ lastInsertId: 1 },
{ rowsAffected: 1 }
);
// 12.34 * 1.07 = 13.2038... — drift well within ε = 0.01
const drift = PRICED_VALUE_TOLERANCE * 0.5;
await expect(
upsertSnapshotLines(50, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 10,
value: 100 + drift,
},
])
).resolves.toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 4. Returns: malformed period dates rejected before the Tauri invoke
// ---------------------------------------------------------------------------
describe("integration — computeAccountReturn validates dates client-side", () => {
it("rejects non-ISO dates without invoking the Rust command", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#000",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
await expect(
computeAccountReturn(7, "01/01/2026", "2026-04-25")
).rejects.toBeInstanceOf(BalanceServiceError);
// The Tauri side must NOT have been hit — fail-fast on bad dates.
expect(invoke).not.toHaveBeenCalled();
});
it("rejects when the active profile cannot be resolved", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "ghost",
profiles: [],
});
await expect(
computeAccountReturn(7, "2026-01-01", "2026-04-25")
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
expect(invoke).not.toHaveBeenCalled();
});
it("forwards a partial-period AccountReturn shape unchanged", async () => {
// When `is_partial = true` (no V_start), the Rust side returns a payload
// with explicit nulls. The TS shim must not coerce them away.
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#000",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const partial = {
value_start: null,
value_end: 1500,
net_contributions: 200,
return_pct: null,
annualized_pct: null,
is_partial: true,
has_no_transfers_warning: false,
};
vi.mocked(invoke).mockResolvedValueOnce(partial);
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
expect(out).toEqual(partial);
expect(out.is_partial).toBe(true);
expect(out.value_start).toBeNull();
});
});

View file

@ -0,0 +1,332 @@
/**
* Integration-flavoured tests for the v2 v1 categories migration.
*
* We intentionally do NOT spin up a real sqlite: the `tauri-plugin-sql` bridge
* is only available inside the Tauri WebView. Instead we use a carefully
* crafted in-memory fake DB that:
* - records every SQL statement (so we can assert ordering),
* - simulates `rowsAffected` for UPDATEs,
* - optionally fails on a matched SQL to simulate a mid-run error.
*
* The goal is to catch cross-service ordering bugs and the planbackupmigrate
* sequencing required by the spec (ADR: pre-migration backup is a hard gate).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/app", () => ({
getVersion: vi.fn(async () => "0.8.3-test"),
}));
// dataExportService helpers are used by both the backup service and the
// restore service. We mock the "DB-reading" ones and keep the parsers real.
vi.mock("../services/dataExportService", async () => {
const actual = await vi.importActual<
typeof import("../services/dataExportService")
>("../services/dataExportService");
return {
...actual,
getExportCategories: vi.fn(async () => []),
getExportSuppliers: vi.fn(async () => []),
getExportKeywords: vi.fn(async () => []),
getExportTransactions: vi.fn(async () => []),
importTransactionsWithCategories: vi.fn(async () => undefined),
};
});
import { getDb } from "../services/db";
import { invoke } from "@tauri-apps/api/core";
import { computeMigrationPlan } from "../services/categoryMappingService";
import { createPreMigrationBackup } from "../services/categoryBackupService";
import { applyMigration } from "../services/categoryMigrationService";
import { restoreFromBackup } from "../services/categoryRestoreService";
import { importTransactionsWithCategories } from "../services/dataExportService";
import { makeV2Profile, makeV2ProfileWithCustom } from "../__fixtures__/profiles";
import type { Profile } from "../services/profileService";
const mockInvoke = vi.mocked(invoke);
interface FakeDb {
calls: Array<{ sql: string; params?: unknown[] }>;
failAt: { sql: RegExp; error: string } | null;
preferences: Map<string, string>;
select: ReturnType<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
}
function makeFakeDb(): FakeDb {
const db: FakeDb = {
calls: [],
failAt: null,
preferences: new Map(),
select: vi.fn(),
execute: vi.fn(),
};
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.failAt && db.failAt.sql.test(sql)) {
throw new Error(db.failAt.error);
}
// Capture preference upserts so readLastMigrationJournal / getPreference
// can observe the written values.
if (/INSERT INTO user_preferences/i.test(sql) && params) {
const [key, value] = params as [string, string];
db.preferences.set(key, value);
}
const upper = sql.trim().toUpperCase();
if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 };
return { rowsAffected: 1 };
});
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
if (/FROM user_preferences WHERE key/i.test(sql)) {
const [key] = (params as [string]) ?? [""];
const value = db.preferences.get(key);
return value ? [{ key, value, updated_at: "2026-04-20T00:00:00Z" }] : [];
}
return [];
});
return db;
}
let fake: FakeDb;
beforeEach(() => {
fake = makeFakeDb();
vi.mocked(getDb).mockResolvedValue(fake as never);
mockInvoke.mockReset();
vi.mocked(importTransactionsWithCategories).mockReset();
vi.mocked(importTransactionsWithCategories).mockResolvedValue(undefined);
});
const PROFILE: Profile = {
id: "p1",
name: "Max",
color: "#f59e0b",
pin_hash: null,
db_filename: "max.db",
created_at: "2026-01-01T00:00:00Z",
};
// ---------------------------------------------------------------------------
// Flow 1 — plan → backup → migrate on a realistic v2 profile
// ---------------------------------------------------------------------------
describe("integration: plan → backup → migrate (happy path)", () => {
it("produces a plan, verifies backup round-trip, then applies migration atomically", async () => {
// 1. Compute the plan from a realistic v2 profile fixture.
const plan = computeMigrationPlan(makeV2Profile());
expect(plan.rows.length).toBeGreaterThan(20);
// 2. Backup invoke stubs — round-trip the content so the checksum matches.
let captured: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
captured = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return captured ?? "";
if (cmd === "get_file_size") return 2048;
throw new Error(`unexpected invoke: ${cmd}`);
});
const backup = await createPreMigrationBackup({ profile: PROFILE });
expect(backup.encrypted).toBe(false);
expect(backup.path).toMatch(/\.sref$/);
// 3. Apply the migration.
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(true);
expect(outcome.backupPath).toBe(backup.path);
// Plan has no unresolved after default fallback; `customPreservedCount`
// is 0 because the fixture has no custom cats.
expect(outcome.customPreservedCount).toBe(0);
// 4. Verify ordering: BEGIN comes before any UPDATE and COMMIT is last.
const upperCalls = fake.calls.map((c) => c.sql.trim().toUpperCase());
const beginIdx = upperCalls.indexOf("BEGIN");
const commitIdx = upperCalls.indexOf("COMMIT");
expect(beginIdx).toBeGreaterThanOrEqual(0);
expect(commitIdx).toBeGreaterThan(beginIdx);
expect(commitIdx).toBe(upperCalls.length - 1);
});
});
// ---------------------------------------------------------------------------
// Flow 2 — custom-preserving migration on a profile with custom cats
// ---------------------------------------------------------------------------
describe("integration: migration preserves custom categories", () => {
it("creates the custom parent and re-parents the 3 custom cats", async () => {
const plan = computeMigrationPlan(makeV2ProfileWithCustom());
expect(plan.preserved).toHaveLength(3);
// Backup stub (plaintext)
let captured: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
captured = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return captured ?? "";
if (cmd === "get_file_size") return 2048;
throw new Error(`unexpected invoke: ${cmd}`);
});
const backup = await createPreMigrationBackup({ profile: PROFILE });
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(true);
expect(outcome.customPreservedCount).toBe(3);
// Custom parent created (id 2000)
const parentInsert = fake.calls.find(
(c) =>
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
(c.params?.[0] as number) === 2000,
);
expect(parentInsert).toBeDefined();
// All 3 customs re-parented
const reparent = fake.calls.filter((c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
);
expect(reparent.length).toBe(3);
});
});
// ---------------------------------------------------------------------------
// Flow 3 — backup failure aborts (no DB writes)
// ---------------------------------------------------------------------------
describe("integration: backup failure aborts before any DB write", () => {
it("migration never runs when createPreMigrationBackup throws", async () => {
mockInvoke.mockImplementation(async (cmd) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") throw new Error("disk full");
throw new Error(`unexpected invoke: ${cmd}`);
});
const plan = computeMigrationPlan(makeV2Profile());
// The caller (UI) is responsible for calling backup FIRST; this test
// simulates that policy.
let backupErr: Error | null = null;
try {
await createPreMigrationBackup({ profile: PROFILE });
} catch (e) {
backupErr = e as Error;
}
expect(backupErr).not.toBeNull();
// No migration call yet → DB calls must be empty.
expect(fake.calls).toHaveLength(0);
// Guard: applyMigration with a fake empty/invalid backup still refuses.
const outcome = await applyMigration(plan, {
path: "",
size: 0,
checksum: "",
verifiedAt: "",
encrypted: false,
});
expect(outcome.succeeded).toBe(false);
expect(fake.calls).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Flow 4 — SQL failure rolls back and backup remains usable
// ---------------------------------------------------------------------------
describe("integration: SQL failure rolls back, backup remains", () => {
it("ROLLBACK is emitted and the backup is unaffected", async () => {
let writtenBackup: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
writtenBackup = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return writtenBackup ?? "";
if (cmd === "get_file_size") return 2048;
// The migration never touches Tauri fs commands other than via
// dataExportService helpers (already mocked). An unexpected cmd here
// would signal a leak.
throw new Error(`unexpected invoke: ${cmd}`);
});
const plan = computeMigrationPlan(makeV2Profile());
const backup = await createPreMigrationBackup({ profile: PROFILE });
expect(writtenBackup).not.toBeNull();
// Force a mid-run SQL failure.
fake.failAt = {
sql: /UPDATE budget_entries SET category_id/i,
error: "fk_violation",
};
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(false);
expect(outcome.error).toMatch(/fk_violation/);
const upper = fake.calls.map((c) => c.sql.trim().toUpperCase());
expect(upper).toContain("ROLLBACK");
expect(upper).not.toContain("COMMIT");
// The backup is still a valid SREF string from the caller's perspective:
// nothing in the migration writes to disk. We verify the captured content
// still parses.
expect(() => JSON.parse(writtenBackup!)).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Flow 5 — restore flow (rollback by SREF import)
// ---------------------------------------------------------------------------
describe("integration: restore from SREF after a migration", () => {
it("imports the backup, flips schema back to v2, stamps reverted_at", async () => {
// 1. Run a successful migration so the journal is in place.
let writtenBackup: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
writtenBackup = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return writtenBackup ?? "";
if (cmd === "get_file_size") return 2048;
if (cmd === "file_exists") return true;
if (cmd === "is_file_encrypted") return false;
throw new Error(`unexpected invoke: ${cmd}`);
});
const plan = computeMigrationPlan(makeV2Profile());
const backup = await createPreMigrationBackup({ profile: PROFILE });
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(true);
// 2. Now call restoreFromBackup using the same path.
const restoreResult = await restoreFromBackup(backup.path, null);
expect(restoreResult.filePath).toBe(backup.path);
expect(restoreResult.revertedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// 3. importTransactionsWithCategories must have been called exactly once.
expect(vi.mocked(importTransactionsWithCategories)).toHaveBeenCalledTimes(1);
// 4. schema version reset + journal updated with reverted_at.
expect(fake.preferences.get("categories_schema_version")).toBe("v2");
const journal = JSON.parse(
fake.preferences.get("last_categories_migration") as string,
);
expect(journal.reverted_at).toBe(restoreResult.revertedAt);
});
});

View file

@ -0,0 +1,382 @@
/**
* Regression-style tests parameterised on both v2 (current seed) and v1
* (new IPC taxonomy) category ids. These exercise the same app services on
* both shapes and assert identical observable behaviour the spec's
* guarantee that the migration does not silently break:
*
* - categorizationService (keyword regex category_id matching)
* - budgetService.getBudgetVsActualData (parent/child aggregation)
* - dataExportService envelope round-trip (SREF format parity)
*
* We mock the DB per test and drive each service with a small, deterministic
* dataset scoped to either v2 or v1 category ids.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "../services/db";
import {
normalizeDescription,
buildKeywordRegex,
compileKeywords,
categorizeBatch,
} from "../services/categorizationService";
import { getBudgetVsActualData } from "../services/budgetService";
import {
parseImportedJson,
serializeToJson,
type ExportEnvelope,
} from "../services/dataExportService";
import type { Keyword, Category, BudgetEntry } from "../shared/types";
// ---------------------------------------------------------------------------
// Shared mock DB harness — each test resets and stubs specific SELECTs.
// ---------------------------------------------------------------------------
const mockSelect = vi.fn();
const mockExecute = vi.fn();
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue({ select: mockSelect, execute: mockExecute } as never);
mockSelect.mockReset();
mockExecute.mockReset();
});
// ---------------------------------------------------------------------------
// normalizeDescription — identical on v2/v1 inputs (no schema dependency)
// ---------------------------------------------------------------------------
describe("regression: normalizeDescription is schema-agnostic", () => {
it.each([
["IGA #5555", "iga #5555"],
["SHELL\t#231", "shell #231"],
[" Hydro-Québec FACTURE ", "hydro-quebec facture"],
])("%s -> %s", (input, expected) => {
expect(normalizeDescription(input)).toBe(expected);
});
});
describe("regression: buildKeywordRegex boundaries", () => {
it("matches whole-word keywords on both v2- and v1-style descriptions", () => {
const re = buildKeywordRegex(normalizeDescription("STM"));
expect(re.test(normalizeDescription("STM CARTE OPUS"))).toBe(true);
expect(re.test(normalizeDescription("METROSTMONTREAL"))).toBe(false);
});
it("handles a keyword with a non-word leading char (common in bank exports)", () => {
const re = buildKeywordRegex(normalizeDescription("[INTERAC]"));
expect(re.test(normalizeDescription("PAIEMENT [INTERAC] XXX"))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// categorizeBatch parameterised per schema — same description must map to the
// same (schema-specific) category id.
// ---------------------------------------------------------------------------
type Schema = "v2" | "v1";
function kwFixture(schema: Schema): Keyword[] {
const shellCat = schema === "v2" ? 40 : 1512;
const igaCat = schema === "v2" ? 22 : 1111;
const stmCat = schema === "v2" ? 28 : 1521;
return [
{
id: 1,
keyword: "SHELL",
category_id: shellCat,
priority: 100,
is_active: true,
} as Keyword,
{
id: 2,
keyword: "IGA",
category_id: igaCat,
priority: 100,
is_active: true,
} as Keyword,
{
id: 3,
keyword: "STM",
category_id: stmCat,
priority: 100,
is_active: true,
} as Keyword,
];
}
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: categorizeBatch [%s]",
(schema) => {
it("matches the expected category ids for each description", async () => {
mockSelect.mockResolvedValueOnce(kwFixture(schema));
const results = await categorizeBatch([
"SHELL #231 LAVAL",
"IGA EXTRA #5555",
"STM CARTE OPUS",
"UNRELATED",
]);
const [shell, iga, stm, none] = results;
expect(shell.category_id).toBe(schema === "v2" ? 40 : 1512);
expect(iga.category_id).toBe(schema === "v2" ? 22 : 1111);
expect(stm.category_id).toBe(schema === "v2" ? 28 : 1521);
expect(none.category_id).toBeNull();
});
},
);
describe("regression: compileKeywords parity across schemas", () => {
it("produces identical regex patterns regardless of the category_id", () => {
const v2Kw = kwFixture("v2");
const v1Kw = kwFixture("v1");
const v2Compiled = compileKeywords(v2Kw);
const v1Compiled = compileKeywords(v1Kw);
expect(v2Compiled.map((c) => c.regex.source)).toEqual(
v1Compiled.map((c) => c.regex.source),
);
});
});
// ---------------------------------------------------------------------------
// budgetService.getBudgetVsActualData parameterised per schema
// ---------------------------------------------------------------------------
function budgetFixture(schema: Schema): {
categories: Category[];
entries: BudgetEntry[];
} {
if (schema === "v2") {
const categories: Category[] = [
// Parent + 2 children to exercise aggregation
mkCat(22, "Épicerie", null, "expense"),
mkCat(220, "Épicerie courante", 22, "expense"),
mkCat(221, "Gros achats", 22, "expense"),
];
const entries: BudgetEntry[] = [
mkEntry(220, 2026, 3, 400),
mkEntry(221, 2026, 3, 200),
];
return { categories, entries };
}
// v1: Épicerie → 1110 (subcategory Alimentation), 1111 + 1112 leaves
const categories: Category[] = [
mkCat(1100, "Alimentation", null, "expense"),
mkCat(1110, "Épicerie", 1100, "expense"),
mkCat(1111, "Régulière", 1110, "expense"),
mkCat(1112, "Bio / spécialisée", 1110, "expense"),
];
const entries: BudgetEntry[] = [
mkEntry(1111, 2026, 3, 400),
mkEntry(1112, 2026, 3, 200),
];
return { categories, entries };
}
function mkCat(
id: number,
name: string,
parent_id: number | null,
type: "expense" | "income" | "transfer",
): Category {
return {
id,
name,
parent_id,
color: "#000",
type,
is_active: 1,
is_inputable: 1,
sort_order: id,
i18n_key: null,
} as unknown as Category;
}
function mkEntry(
category_id: number,
year: number,
month: number,
amount: number,
): BudgetEntry {
return {
id: category_id * 100 + month,
category_id,
year,
month,
amount,
notes: null,
} as unknown as BudgetEntry;
}
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: getBudgetVsActualData [%s]",
(schema) => {
it("aggregates leaf budgets under their parent and multiplies by -1 for expenses", async () => {
const { categories, entries } = budgetFixture(schema);
// Stub the 4 parallel selects in order:
// 1) getAllActiveCategories
// 2) getBudgetEntriesForYear
// 3) getActualsByCategoryRange (month)
// 4) getActualsByCategoryRange (ytd)
mockSelect.mockImplementation((sql: string) => {
if (/FROM categories/i.test(sql)) return Promise.resolve(categories);
if (/FROM budget_entries WHERE year/i.test(sql))
return Promise.resolve(entries);
if (/GROUP BY category_id/i.test(sql)) return Promise.resolve([]);
return Promise.resolve([]);
});
const rows = await getBudgetVsActualData(2026, 3);
// We expect at least the two leaves to appear. In both schemas the
// budget sum on the parent = -600 (expenses → sign -1).
const leafIds = schema === "v2" ? [220, 221] : [1111, 1112];
const parentId = schema === "v2" ? 22 : 1110;
// Collect budgets per row keyed by category_id.
const budgetById = new Map<number, number>();
for (const r of rows) {
budgetById.set(r.category_id, r.monthBudget);
}
// Both leaves get their stored budget × -1.
expect(budgetById.get(leafIds[0])).toBe(-400);
expect(budgetById.get(leafIds[1])).toBe(-200);
// The parent aggregates to -600.
expect(budgetById.get(parentId)).toBe(-600);
});
},
);
// ---------------------------------------------------------------------------
// dataExportService envelope round-trip parity — the SREF JSON format must
// remain identical before and after the migration.
// ---------------------------------------------------------------------------
function envelopeFixture(schema: Schema): ExportEnvelope {
const catId = schema === "v2" ? 22 : 1111;
return {
export_type: "transactions_with_categories",
app_version: "0.8.3-test",
exported_at: "2026-04-20T00:00:00Z",
data: {
categories: [
{
id: catId,
name: "Épicerie",
parent_id: null,
color: "#000",
type: "expense",
is_active: 1,
is_inputable: 1,
sort_order: 1,
i18n_key: null,
} as unknown as Category,
],
suppliers: [],
keywords: [],
transactions: [],
},
};
}
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: SREF envelope round-trip [%s]",
(schema) => {
it("serialize → parse returns an equivalent envelope", () => {
const original = envelopeFixture(schema);
const serialized = serializeToJson(
original.export_type,
original.data,
original.app_version,
);
const { envelope } = parseImportedJson(serialized);
expect(envelope.export_type).toBe(original.export_type);
expect(envelope.data.categories).toHaveLength(1);
expect(envelope.data.categories![0].id).toBe(
schema === "v2" ? 22 : 1111,
);
});
},
);
// ---------------------------------------------------------------------------
// Split transactions — the parent_transaction_id / is_split columns must be
// honoured identically after a migration. We exercise them through the
// export envelope, which is the canonical observable surface.
// ---------------------------------------------------------------------------
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: split transactions survive export [%s]",
(schema) => {
it("preserves is_split and parent_transaction_id in the envelope", () => {
const parentCat = schema === "v2" ? 28 : 1521;
const leg1Cat = schema === "v2" ? 28 : 1521;
const leg2Cat = schema === "v2" ? 22 : 1111;
const envelope: ExportEnvelope = {
export_type: "transactions_with_categories",
app_version: "0.8.3-test",
exported_at: "2026-04-20T00:00:00Z",
data: {
categories: [],
suppliers: [],
keywords: [],
transactions: [
{
id: 100,
date: "2026-03-10",
description: "STM Opus + snack",
amount: -50,
category_id: parentCat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 0,
is_split: 1,
parent_transaction_id: null,
},
{
id: 101,
date: "2026-03-10",
description: "STM leg",
amount: -30,
category_id: leg1Cat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 1,
is_split: 0,
parent_transaction_id: 100,
},
{
id: 102,
date: "2026-03-10",
description: "Snack leg",
amount: -20,
category_id: leg2Cat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 1,
is_split: 0,
parent_transaction_id: 100,
},
],
},
};
const json = serializeToJson(envelope.export_type, envelope.data, envelope.app_version);
const parsed = parseImportedJson(json).envelope;
const txs = parsed.data.transactions!;
expect(txs).toHaveLength(3);
expect(txs[0].is_split).toBe(1);
expect(txs[1].parent_transaction_id).toBe(100);
expect(txs[2].parent_transaction_id).toBe(100);
// Category ids are schema-specific but never null.
expect(typeof txs[0].category_id).toBe("number");
expect(typeof txs[1].category_id).toBe("number");
expect(typeof txs[2].category_id).toBe("number");
});
},
);

View file

@ -0,0 +1,96 @@
/**
* Non-regression check for the inlined transfer icon in TransactionTable
* (Issue #142 #144 follow-up).
*
* The spec promises that without any linked transfers the transactions
* table renders exactly as it did before #142 inlined the `<Link2>` icon.
* The icon is gated by a single conditional in the JSX:
*
* {linkedTransfersByTxId?.has(row.id) && (...)}
*
* If `linkedTransfersByTxId` is undefined OR the map has no entry for `row.id`,
* the icon block is short-circuited and the row layout is unchanged.
*
* Why this approach: this project does not bundle `@testing-library/react`
* (see `package.json`), and adding it just for one non-regression check is
* out of scope here. Existing component tests (`CategoryCombobox.test.ts`,
* `ViewModeToggle.test.ts`, `TrendsChartTypeToggle.test.ts`) likewise extract
* pure helpers and assert on them rather than mounting JSX. So we go one
* level lower: assert the source-level shape of `TransactionTable.tsx`.
*
* The assertions are structural on the source file:
* 1. The conditional block exists and is gated by `linkedTransfersByTxId?.has`.
* 2. The block consumes `Link2` from `lucide-react`.
* 3. The prop is OPTIONAL on the component's interface passing nothing
* must remain a valid call (zero-impact path).
* 4. The tooltip text comes from the i18n key family `transactions.transferIcon.*`
* (so a future rename catches our attention here).
* 5. The icon uses `aria-label` for accessibility (Issue #142 acceptance criterion).
* 6. The condition uses optional-chaining (so passing `undefined` short-circuits
* cleanly without throwing).
*
* If the icon is ever pulled out into its own component, the tests should be
* rewritten to import and exercise that component directly instead. Until
* then, this is a tight static contract that catches accidental regressions.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
const TABLE_SRC = readFileSync(
resolve(
import.meta.dirname,
"..",
"components",
"transactions",
"TransactionTable.tsx"
),
"utf-8"
);
describe("non-regression: TransactionTable transfer icon (#142)", () => {
it("guards the icon block behind `linkedTransfersByTxId?.has(row.id)`", () => {
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\.has\(row\.id\)/);
});
it("uses optional chaining so the icon is opt-in (undefined short-circuits)", () => {
// Optional chaining is the safe-render guarantee: if the parent never
// passes the prop, `?.has` returns undefined → the && short-circuits to
// false, the JSX block is skipped, and the row layout is unchanged.
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\./);
});
it("imports `Link2` from lucide-react for the icon glyph", () => {
expect(TABLE_SRC).toMatch(/from\s+["']lucide-react["']/);
expect(TABLE_SRC).toMatch(/\bLink2\b/);
});
it("declares `linkedTransfersByTxId` as an OPTIONAL prop", () => {
// The "?" after the name on the interface is the contract that omitting
// the prop is allowed. Without it the entire transactions page would
// need to thread the lookup through, breaking pre-#142 callers.
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?:/);
});
it("uses `transactions.transferIcon.*` i18n keys for the tooltip and aria-label", () => {
// Both the tooltip body and the aria label go through i18n — neither
// is a hardcoded English/French string.
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.tooltip/);
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.ariaLabel/);
});
it("attaches an `aria-label` for screen readers (a11y)", () => {
expect(TABLE_SRC).toMatch(/aria-label=/);
});
it("keeps the description column structure shared with non-linked rows", () => {
// The icon lives inside the description cell, in a flex container
// alongside the original `<span class="truncate" title=...>` that
// existed pre-#142. If someone moved the description span into a
// wrapper that the icon required, this assertion would fail.
expect(TABLE_SRC).toMatch(
/<span\s+className="truncate"\s+title=\{row\.description\}/
);
});
});

View file

@ -71,7 +71,11 @@ export default function AdjustmentForm({
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
onChange={(e) => {
setForm({ ...form, date: e.target.value });
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
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)]"
/>
</div>

View file

@ -0,0 +1,490 @@
// AccountForm — account or category variant.
//
// Mode = 'account' (Issue #138 / Bilan #1a): create / edit a balance_account
// row bound to an existing category.
// Mode = 'category' (Issue #140 / Bilan #2): create a balance_category row
// with a kind selector (`simple | priced`).
//
// Both variants live in the same component because they share the surrounding
// wiring (form layout, save / cancel buttons, validation feedback) and only
// the input fields differ.
import { FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
BalanceAccount,
BalanceAssetType,
BalanceCategory,
BalanceCategoryKind,
} from "../../shared/types";
import type {
CreateBalanceAccountInput,
CreateBalanceCategoryInput,
UpdateBalanceAccountInput,
} from "../../services/balance.service";
// -----------------------------------------------------------------------------
// Account variant types
// -----------------------------------------------------------------------------
export interface AccountFormValues {
balance_category_id: number;
name: string;
symbol: string;
notes: string;
}
interface AccountVariantProps {
mode: "account";
/** When provided, the form is in edit mode; otherwise creation. */
initialAccount?: BalanceAccount | null;
categories: BalanceCategory[];
isSaving: boolean;
onSubmit: (
values: CreateBalanceAccountInput | UpdateBalanceAccountInput
) => Promise<void> | void;
onCancel: () => void;
}
// -----------------------------------------------------------------------------
// Category variant types (Issue #140)
// -----------------------------------------------------------------------------
export interface CategoryFormValues {
key: string;
i18n_key: string;
kind: BalanceCategoryKind;
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
asset_type: BalanceAssetType | null;
}
interface CategoryVariantProps {
mode: "category";
isSaving: boolean;
onSubmit: (values: CreateBalanceCategoryInput) => Promise<void> | void;
onCancel: () => void;
}
type Props = AccountVariantProps | CategoryVariantProps;
function defaultAccountValues(
initial: BalanceAccount | null | undefined,
categories: BalanceCategory[]
): AccountFormValues {
if (initial) {
return {
balance_category_id: initial.balance_category_id,
name: initial.name,
symbol: initial.symbol ?? "",
notes: initial.notes ?? "",
};
}
// First active category as a sane default
const first = categories.find((c) => c.is_active) ?? categories[0];
return {
balance_category_id: first?.id ?? 0,
name: "",
symbol: "",
notes: "",
};
}
export default function AccountForm(props: Props) {
if (props.mode === "category") {
return <CategoryVariant {...props} />;
}
return <AccountVariant {...props} />;
}
// -----------------------------------------------------------------------------
// Account variant
// -----------------------------------------------------------------------------
function AccountVariant({
initialAccount,
categories,
isSaving,
onSubmit,
onCancel,
}: AccountVariantProps) {
const { t } = useTranslation();
const [values, setValues] = useState<AccountFormValues>(() =>
defaultAccountValues(initialAccount, categories)
);
const [touched, setTouched] = useState(false);
// Reset form when target account changes (edit different row).
useEffect(() => {
setValues(defaultAccountValues(initialAccount, categories));
setTouched(false);
}, [initialAccount, categories]);
const isEditing = !!initialAccount;
const selectedCategory = categories.find(
(c) => c.id === values.balance_category_id
);
const isPriced = selectedCategory?.kind === "priced";
const trimmedName = values.name.trim();
const trimmedSymbol = values.symbol.trim();
const nameInvalid = touched && trimmedName.length === 0;
// Priced categories require a symbol — surfaced as a validation error.
const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setTouched(true);
if (!trimmedName) return;
if (isPriced && !trimmedSymbol) return;
const payload: CreateBalanceAccountInput = {
balance_category_id: values.balance_category_id,
name: trimmedName,
symbol: trimmedSymbol || null,
notes: values.notes.trim() || null,
};
if (isEditing) {
const updatePayload: UpdateBalanceAccountInput = {
balance_category_id: payload.balance_category_id,
name: payload.name,
symbol: payload.symbol,
notes: payload.notes,
};
await onSubmit(updatePayload);
} else {
await onSubmit(payload);
}
};
const renderCategoryLabel = (cat: BalanceCategory) =>
t(cat.i18n_key, { defaultValue: cat.key });
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-category">
{t("balance.account.form.category")}
</label>
<select
id="account-category"
value={values.balance_category_id}
onChange={(e) =>
setValues({
...values,
balance_category_id: Number(e.target.value),
})
}
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)]"
>
{categories.length === 0 ? (
<option value={0}>{t("balance.account.form.noCategory")}</option>
) : (
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{renderCategoryLabel(cat)}
</option>
))
)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-name">
{t("balance.account.form.name")}
</label>
<input
id="account-name"
type="text"
value={values.name}
onChange={(e) => setValues({ ...values, name: e.target.value })}
onBlur={() => setTouched(true)}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
nameInvalid
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoFocus
autoComplete="off"
/>
{nameInvalid && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.nameRequired")}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
{t("balance.account.form.symbol")}
{isPriced && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
({t("balance.account.form.symbolPricedHint")})
</span>
)}
</label>
<input
id="account-symbol"
type="text"
value={values.symbol}
onChange={(e) => setValues({ ...values, symbol: e.target.value })}
onBlur={() => setTouched(true)}
placeholder={
isPriced
? t("balance.account.form.symbolPlaceholderPriced")
: t("balance.account.form.symbolPlaceholderSimple")
}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
symbolMissingForPriced
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoComplete="off"
/>
{symbolMissingForPriced && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.symbolRequiredForPriced")}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-notes">
{t("balance.account.form.notes")}
</label>
<textarea
id="account-notes"
value={values.notes}
onChange={(e) => setValues({ ...values, notes: e.target.value })}
rows={2}
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)] resize-none"
/>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{t("balance.account.form.currencyMvpNotice")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={
isSaving ||
!trimmedName ||
categories.length === 0 ||
(isPriced && !trimmedSymbol)
}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{isEditing
? t("balance.account.form.save")
: t("balance.account.form.create")}
</button>
</div>
</form>
);
}
// -----------------------------------------------------------------------------
// Category variant (Issue #140)
// -----------------------------------------------------------------------------
function CategoryVariant({
isSaving,
onSubmit,
onCancel,
}: CategoryVariantProps) {
const { t } = useTranslation();
const [values, setValues] = useState<CategoryFormValues>({
key: "",
i18n_key: "",
kind: "simple",
asset_type: null,
});
const [touched, setTouched] = useState(false);
const trimmedKey = values.key.trim();
const trimmedLabel = values.i18n_key.trim();
const keyInvalid = touched && trimmedKey.length === 0;
const assetTypeMissing =
touched && values.kind === "priced" && !values.asset_type;
const submitDisabled =
isSaving ||
!trimmedKey ||
(values.kind === "priced" && !values.asset_type);
const handleKindChange = (next: BalanceCategoryKind) => {
// Switching priced → simple resets asset_type so the NULL invariant for
// simple kind is preserved (the service would coerce it anyway).
setValues((prev) => ({
...prev,
kind: next,
asset_type: next === "priced" ? prev.asset_type : null,
}));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setTouched(true);
if (!trimmedKey) return;
if (values.kind === "priced" && !values.asset_type) return;
// Fall back to the key if no human label was supplied.
const i18nKey = trimmedLabel || trimmedKey;
await onSubmit({
key: trimmedKey,
i18n_key: i18nKey,
kind: values.kind,
sort_order: 100, // user-created categories sort after seeded ones
asset_type: values.kind === "priced" ? values.asset_type : null,
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-key"
>
{t("balance.category.form.key")}
</label>
<input
id="category-key"
type="text"
value={values.key}
onChange={(e) =>
setValues({ ...values, key: e.target.value })
}
onBlur={() => setTouched(true)}
placeholder={t("balance.category.form.keyPlaceholder")}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
keyInvalid
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoComplete="off"
autoFocus
/>
{keyInvalid && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.nameRequired")}
</p>
)}
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-label"
>
{t("balance.category.form.label")}
</label>
<input
id="category-label"
type="text"
value={values.i18n_key}
onChange={(e) =>
setValues({ ...values, i18n_key: e.target.value })
}
placeholder={t("balance.category.form.labelPlaceholder")}
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)]"
autoComplete="off"
/>
</div>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-kind"
>
{t("balance.category.form.kindLabel")}
</label>
<select
id="category-kind"
value={values.kind}
onChange={(e) =>
handleKindChange(e.target.value as BalanceCategoryKind)
}
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="simple">{t("balance.category.kind.simple")}</option>
<option value="priced">{t("balance.category.kind.priced")}</option>
</select>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{values.kind === "priced"
? t("balance.category.form.kindHintPriced")
: t("balance.category.form.kindHintSimple")}
</p>
</div>
{values.kind === "priced" && (
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-asset-type"
>
{t("balance.category.assetType.label")}
</label>
<select
id="category-asset-type"
value={values.asset_type ?? ""}
onChange={(e) =>
setValues({
...values,
asset_type:
e.target.value === ""
? null
: (e.target.value as BalanceAssetType),
})
}
onBlur={() => setTouched(true)}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
assetTypeMissing
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
>
<option value="">{t("balance.category.assetType.required")}</option>
<option value="stock">
{t("balance.category.assetType.stock")}
</option>
<option value="crypto">
{t("balance.category.assetType.crypto")}
</option>
</select>
{assetTypeMissing && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.category.assetType.required")}
</p>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={submitDisabled}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("balance.category.form.create")}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,388 @@
// BalanceAccountsTable — one-row-per-active-account table on /balance.
//
// Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
// + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
// the Modified Dietz `compute_account_return` Tauri command:
//
// - 3M (last 90 days)
// - 1A (last 365 days)
// - Depuis création (from earliest snapshot date to today)
// - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution
// weighting — shown side-by-side as a sanity check / explanation)
//
// Returns load lazily on mount via `Promise.all` over (account × horizon),
// keyed by `account_id`. Each cell renders "—" while loading and shows the
// `is_partial` / `has_no_transfers_warning` badges via tooltip when set.
//
// Issue #142 also adds a "Lier transferts" item in the per-row actions menu
// that opens `LinkTransfersModal` (the modal handles its own state; this
// component just bubbles up the request via `onLinkTransfers`).
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
import type {
AccountLatestSnapshot,
AccountPeriodAnchor,
} from "../../services/balance.service";
import { computeAccountReturn } from "../../services/balance.service";
import type { AccountReturn } from "../../shared/types";
const cadFormatter = (locale: string) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
});
/** Horizon definition: how many days back from today to start the period. */
type HorizonKey = "3M" | "1A" | "since";
interface HorizonRange {
key: HorizonKey;
/** ISO date for `period_start`. */
from: string;
/** ISO date for `period_end` (always today, computed in the local civil day). */
to: string;
}
function localISO(d: Date): string {
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
function isoDaysAgo(days: number, today: Date): string {
const d = new Date(today);
d.setDate(d.getDate() - days);
return localISO(d);
}
interface BalanceAccountsTableProps {
accounts: AccountLatestSnapshot[];
periodAnchor: AccountPeriodAnchor[];
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
/**
* Earliest snapshot date across the whole profile, used to anchor the
* "depuis création" horizon. Falls back to "1A" range if not provided
* (avoids triggering computation against the unix epoch).
*/
sinceCreationDate?: string | null;
}
/**
* Per-account, per-horizon return shape used by the local cache state.
* Indexed `[accountId][horizonKey]`.
*/
type ReturnsByAccount = Record<number, Partial<Record<HorizonKey, AccountReturn>>>;
export default function BalanceAccountsTable({
accounts,
periodAnchor,
onArchiveAccount,
onLinkTransfers,
sinceCreationDate,
}: BalanceAccountsTableProps) {
const { t, i18n } = useTranslation();
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
/** account_id → period anchor (start-of-period value). */
const anchorMap = useMemo(() => {
const m = new Map<number, AccountPeriodAnchor>();
for (const a of periodAnchor) m.set(a.account_id, a);
return m;
}, [periodAnchor]);
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
// Returns cache. Cleared whenever the account list changes (new accounts,
// archive, etc.). Loaded lazily after mount.
const [returns, setReturns] = useState<ReturnsByAccount>({});
const [returnsLoading, setReturnsLoading] = useState(false);
// Horizon definitions — recomputed once per mount via today's local civil
// day. We don't memoize against `accounts` because the dates don't depend
// on the row list.
const horizons = useMemo<HorizonRange[]>(() => {
const today = new Date();
const todayISO = localISO(today);
const sinceFrom = sinceCreationDate ?? isoDaysAgo(365, today);
return [
{ key: "3M", from: isoDaysAgo(90, today), to: todayISO },
{ key: "1A", from: isoDaysAgo(365, today), to: todayISO },
{ key: "since", from: sinceFrom, to: todayISO },
];
}, [sinceCreationDate]);
useEffect(() => {
let cancelled = false;
async function loadReturns() {
if (accounts.length === 0) {
setReturns({});
return;
}
setReturnsLoading(true);
const next: ReturnsByAccount = {};
// Run sequentially per account to avoid SQLite contention; per-horizon
// we can parallelize because they hit the same table set.
await Promise.all(
accounts.map(async (acc) => {
next[acc.account_id] = {};
const tasks = horizons.map(async (h) => {
try {
const r = await computeAccountReturn(
acc.account_id,
h.from,
h.to
);
next[acc.account_id]![h.key] = r;
} catch {
// Per-cell failure: leave the slot undefined → renders "—".
}
});
await Promise.all(tasks);
})
);
if (!cancelled) {
setReturns(next);
setReturnsLoading(false);
}
}
void loadReturns();
return () => {
cancelled = true;
};
}, [accounts, horizons]);
if (accounts.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
{t("balance.overview.noAccounts")}
</div>
);
}
/** Format a return percentage with sign + colour-aware classname. */
function renderReturnCell(r: AccountReturn | undefined) {
if (!r) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.return_pct === null) {
return (
<span
className="text-[var(--muted-foreground)] inline-flex items-center gap-1"
title={t("balance.returns.partialTooltip")}
>
<AlertTriangle size={12} />
</span>
);
}
const pct = r.return_pct * 100;
return (
<span className="inline-flex items-center gap-1">
<span
className={
pct >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%
</span>
{r.has_no_transfers_warning && (
<AlertTriangle
size={12}
className="text-amber-500"
aria-label={t("balance.returns.noTransfersWarning")}
/>
)}
</span>
);
}
/**
* Unadjusted (simple) return = `(value_end - value_start) / value_start`
* same numbers Modified Dietz already returns when no flows exist, but
* this column shows the simple version for ALL accounts as a side-by-side
* sanity check. Computed from the same `AccountReturn` payload (uses the
* `value_start` / `value_end` fields filled by the Rust side).
*/
function renderUnadjustedCell(r: AccountReturn | undefined) {
if (!r || r.value_start === null || r.value_end === null) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.value_start === 0) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
const simple = ((r.value_end - r.value_start) / r.value_start) * 100;
return (
<span
className={
simple >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{simple >= 0 ? "+" : ""}
{simple.toFixed(2)}%
</span>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30">
<tr>
<th className="text-left px-4 py-3 font-medium">
{t("balance.account.fields.name")}
</th>
<th className="text-left px-4 py-3 font-medium">
{t("balance.account.fields.category")}
</th>
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.latestValue")}
</th>
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
{t("balance.accountsTable.return3m")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
{t("balance.accountsTable.return1y")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
{t("balance.accountsTable.sinceCreation")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
{t("balance.accountsTable.unadjusted")}
</th>
<th className="text-right px-4 py-3 font-medium w-12">
{t("balance.account.fields.actions")}
</th>
</tr>
</thead>
<tbody>
{accounts.map((acc) => {
const anchor = anchorMap.get(acc.account_id);
const deltaPct =
acc.latest_value !== null && anchor && anchor.anchor_value !== 0
? ((acc.latest_value - anchor.anchor_value) /
Math.abs(anchor.anchor_value)) *
100
: null;
const accReturns = returns[acc.account_id] ?? {};
return (
<tr
key={acc.account_id}
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
>
<td className="px-4 py-3 font-medium">
{acc.account_name}
{acc.symbol ? (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
({acc.symbol})
</span>
) : null}
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)]">
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{deltaPct !== null ? (
<span
className={
deltaPct >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}
>
{deltaPct >= 0 ? "+" : ""}
{deltaPct.toFixed(2)}%
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["3M"]
? "…"
: renderReturnCell(accReturns["3M"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderReturnCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["since"]
? "…"
: renderReturnCell(accReturns["since"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderUnadjustedCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right relative">
<button
type="button"
onClick={() =>
setOpenMenuFor(
openMenuFor === acc.account_id ? null : acc.account_id
)
}
className="p-1 rounded hover:bg-[var(--muted)]/40"
aria-label={t("balance.account.fields.actions")}
>
<MoreVertical size={16} />
</button>
{openMenuFor === acc.account_id && (
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
<button
type="button"
disabled
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
title={t("balance.overview.detailComingSoon")}
>
{t("balance.overview.detailAction")}
</button>
{onLinkTransfers && (
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onLinkTransfers(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<LinkIcon size={14} />
{t("balance.transfers.linkAction")}
</button>
)}
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onArchiveAccount?.(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<Archive size={14} />
{t("balance.account.actions.archive")}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,287 @@
// BalanceEvolutionChart — line / stacked-area chart of net worth over time.
//
// Issue #141 (Bilan #3). Reuses the established Recharts patterns from the
// reports/* charts (see decisions-log #141 — native SVG was reconsidered;
// Recharts is the single chart pattern in this codebase). Two modes:
// - 'line' : a single LineChart of `SUM(value)` per snapshot date.
// - 'stacked' : an AreaChart with one Area per category (stackId='all').
//
// Tooltip shows per-category breakdown in stacked mode and just the total in
// line mode.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
ReferenceLine,
} from "recharts";
import type {
SnapshotTotalPoint,
SnapshotCategoryBreakdownPoint,
} from "../../services/balance.service";
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
// Stable palette for the stacked-by-category areas. Indexed deterministically
// by category sort order so the colour assignment stays consistent across
// renders and period changes. Reused from the reports CategoryBarChart palette.
const CATEGORY_PALETTE = [
"#3b82f6", // blue
"#10b981", // emerald
"#f59e0b", // amber
"#8b5cf6", // violet
"#ef4444", // red
"#06b6d4", // cyan
"#ec4899", // pink
"#84cc16", // lime
"#f97316", // orange
"#6366f1", // indigo
];
export interface BalanceEvolutionChartProps {
mode: BalanceChartMode;
totals: SnapshotTotalPoint[];
byCategory: SnapshotCategoryBreakdownPoint[];
/** Map category_key → translated label so the legend reads naturally. */
categoryLabels?: Record<string, string>;
/**
* Issue #142 every linked transfer in the visible range. Rendered as
* vertical `<ReferenceLine>` markers on the X axis: green for `in`
* (capital added), red for `out` (capital removed). The label tooltip
* shows the underlying transaction date + description.
*/
transferMarkers?: BalanceAccountTransferWithTransaction[];
}
export default function BalanceEvolutionChart({
mode,
totals,
byCategory,
categoryLabels = {},
transferMarkers = [],
}: BalanceEvolutionChartProps) {
const { t, i18n } = useTranslation();
const cadFormatter = useMemo(
() =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}),
[i18n.language]
);
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(dateLocale, {
year: "numeric",
month: "short",
day: "numeric",
});
// --- Line-mode dataset ---
const lineData = useMemo(
() =>
totals.map((p) => ({
snapshot_date: p.snapshot_date,
total: p.total,
})),
[totals]
);
// --- Stacked-area dataset ---
// We transpose the per-snapshot bucket into one row per snapshot_date with
// one column per category_key. Categories absent at a snapshot date are
// emitted as 0 so Recharts renders a continuous stack.
const { stackedData, categoryKeys } = useMemo(() => {
const keys = new Set<string>();
for (const point of byCategory) {
for (const k of Object.keys(point.byCategory)) keys.add(k);
}
const orderedKeys = Array.from(keys).sort();
const data = byCategory.map((point) => {
const row: Record<string, string | number> = {
snapshot_date: point.snapshot_date,
};
for (const k of orderedKeys) {
row[k] = point.byCategory[k] ?? 0;
}
return row;
});
return { stackedData: data, categoryKeys: orderedKeys };
}, [byCategory]);
const isEmpty =
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
// Filter transfer markers to dates that are actually rendered on the X
// axis (categorical scale ignores unknown ticks). We don't aggregate or
// dedupe — the user can have several transfers on the same day across
// accounts; ReferenceLine tolerates duplicates fine.
const xAxisDates = useMemo(() => {
const dates = new Set<string>();
if (mode === "line") {
for (const p of lineData) dates.add(p.snapshot_date);
} else {
for (const p of stackedData) dates.add(p.snapshot_date as string);
}
return dates;
}, [mode, lineData, stackedData]);
const renderableMarkers = useMemo(
() =>
transferMarkers
.filter((m) => xAxisDates.has(m.transaction_date))
// Sort so 'in' (green) draws before 'out' (red) for stable z-order.
.sort((a, b) =>
a.direction === b.direction ? 0 : a.direction === "in" ? -1 : 1
),
[transferMarkers, xAxisDates]
);
if (isEmpty) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<p className="text-center text-[var(--muted-foreground)] italic py-12">
{t("balance.chart.empty")}
</p>
</div>
);
}
const tooltipContentStyle = {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
color: "var(--foreground)",
};
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={360}>
{mode === "line" ? (
<LineChart
data={lineData}
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="snapshot_date"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(s: string) => formatDate(s)}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => cadFormatter.format(v)}
width={88}
/>
<Tooltip
formatter={(value: number | undefined) =>
cadFormatter.format(value ?? 0)
}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle}
/>
<Line
type="monotone"
dataKey="total"
name={t("balance.chart.totalSeriesLabel")}
stroke="var(--primary)"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
label={{
value: t(
m.direction === "in"
? "balance.evolution.transferIn"
: "balance.evolution.transferOut"
),
position: "insideTopRight",
fontSize: 9,
fill: m.direction === "in" ? "var(--positive)" : "var(--negative)",
}}
/>
))}
</LineChart>
) : (
<AreaChart
data={stackedData}
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="snapshot_date"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(s: string) => formatDate(s)}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => cadFormatter.format(v)}
width={88}
/>
<Tooltip
formatter={(value: number | undefined, name) => [
cadFormatter.format(value ?? 0),
categoryLabels[String(name)] ?? String(name),
]}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle}
/>
<Legend
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
/>
{categoryKeys.map((key, idx) => (
<Area
key={key}
type="monotone"
dataKey={key}
stackId="all"
stroke={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
fill={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
fillOpacity={0.5}
name={key}
/>
))}
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
/>
))}
</AreaChart>
)}
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,41 @@
// BalanceOnboardingCard — unit tests (issue #178)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (logged as MEDIUM in autopilot decisions-log). Tests cover the pure
// `deriveOnboardingSteps` helper that drives the visual state of each step.
// All React rendering is bypassed.
import { describe, it, expect } from "vitest";
import { deriveOnboardingSteps } from "./BalanceOnboardingCard";
describe("BalanceOnboardingCard — deriveOnboardingSteps", () => {
it("0 accounts, 0 snapshots → step1 active, step2 disabled", () => {
const r = deriveOnboardingSteps(0, 0);
expect(r.step1).toBe("active");
expect(r.step2).toBe("disabled");
});
it(">=1 account, 0 snapshots → step1 done, step2 active", () => {
const r = deriveOnboardingSteps(1, 0);
expect(r.step1).toBe("done");
expect(r.step2).toBe("active");
const r2 = deriveOnboardingSteps(5, 0);
expect(r2.step1).toBe("done");
expect(r2.step2).toBe("active");
});
it(">=1 account, >=1 snapshot → both done (defensive — card normally hidden)", () => {
const r = deriveOnboardingSteps(2, 3);
expect(r.step1).toBe("done");
expect(r.step2).toBe("done");
});
it("guard: 0 accounts but >=1 snapshot (anomaly) → step1 active, step2 done", () => {
// This combination should not happen in practice (a snapshot requires at
// least one account), but the helper handles it conservatively.
const r = deriveOnboardingSteps(0, 1);
expect(r.step1).toBe("active");
expect(r.step2).toBe("done");
});
});

View file

@ -0,0 +1,206 @@
// BalanceOnboardingCard — empty-state onboarding for /balance.
//
// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts
// or no snapshots yet. Two vertical steps:
// 1. Create an account → /balance/accounts
// 2. Enter a snapshot → /balance/snapshot
//
// Each step has 3 states:
// - "active": primary CTA, currently the next thing to do
// - "done": marked with a checkmark, no CTA
// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled
//
// The whole card is replaced by BalanceOverviewCard once at least one
// snapshot exists, so step 2 in practice is rendered as "active" or
// "disabled"; the "done" branch is supported for completeness/tests.
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
interface BalanceOnboardingCardProps {
/** Number of active (non-archived) accounts. */
accountsCount: number;
/** Number of snapshots saved (any date). */
snapshotsCount: number;
}
export type StepState = "active" | "done" | "disabled";
/**
* Pure helper exposed for unit tests derives the state of each onboarding
* step from the (accountsCount, snapshotsCount) pair.
*
* - Step 1 is "done" once at least one account exists, "active" otherwise.
* - Step 2 is "done" once any snapshot exists, "active" once at least one
* account exists, "disabled" otherwise. In practice the parent guard on
* /balance only renders this card when snapshotsCount === 0, so the
* "done" branch for step 2 is mostly defensive.
*/
export function deriveOnboardingSteps(
accountsCount: number,
snapshotsCount: number
): { step1: StepState; step2: StepState } {
const step1: StepState = accountsCount >= 1 ? "done" : "active";
const step2: StepState =
snapshotsCount >= 1
? "done"
: accountsCount >= 1
? "active"
: "disabled";
return { step1, step2 };
}
export default function BalanceOnboardingCard({
accountsCount,
snapshotsCount,
}: BalanceOnboardingCardProps) {
const { t } = useTranslation();
const { step1: step1State, step2: step2State } = deriveOnboardingSteps(
accountsCount,
snapshotsCount
);
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h2 className="text-lg font-semibold mb-1">
{t("balance.onboarding.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-5">
{t("balance.onboarding.subtitle")}
</p>
<ol className="space-y-3">
<Step
number={1}
state={step1State}
icon={<Wallet size={18} />}
title={t("balance.onboarding.step1.title")}
description={t("balance.onboarding.step1.description")}
ctaLabel={t("balance.onboarding.step1.cta")}
ctaHref="/balance/accounts"
/>
<Step
number={2}
state={step2State}
icon={<FileText size={18} />}
title={t("balance.onboarding.step2.title")}
description={t("balance.onboarding.step2.description")}
ctaLabel={t("balance.onboarding.step2.cta")}
ctaHref="/balance/snapshot"
disabledHint={t("balance.onboarding.step2.disabledHint")}
/>
</ol>
</div>
);
}
// -----------------------------------------------------------------------------
// Internal — single step row
// -----------------------------------------------------------------------------
interface StepProps {
number: number;
state: StepState;
icon: React.ReactNode;
title: string;
description: string;
ctaLabel: string;
ctaHref: string;
disabledHint?: string;
}
function Step({
number,
state,
icon,
title,
description,
ctaLabel,
ctaHref,
disabledHint,
}: StepProps) {
const { t } = useTranslation();
const isDone = state === "done";
const isActive = state === "active";
const isDisabled = state === "disabled";
// Number bubble: green check when done, primary bg when active, muted when disabled.
const bubbleClass = isDone
? "bg-[var(--positive)] text-white"
: isActive
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)]";
const titleClass = isDisabled
? "text-[var(--muted-foreground)]"
: "text-[var(--foreground)]";
return (
<li
data-testid={`balance-onboarding-step-${number}`}
data-state={state}
className={`flex items-start gap-4 p-4 rounded-lg border ${
isDisabled
? "border-[var(--border)] opacity-60"
: "border-[var(--border)]"
}`}
>
<div
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
aria-hidden="true"
>
{isDone ? <Check size={16} /> : number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
{icon}
</span>
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
{isDisabled && disabledHint && (
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
{disabledHint}
</p>
)}
</div>
<div className="shrink-0 self-center">
{isDone ? (
<span
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
data-testid={`balance-onboarding-step-${number}-done-badge`}
>
<Check size={14} />
{t("balance.onboarding.doneBadge")}
</span>
) : isActive ? (
<Link
to={ctaHref}
data-testid={`balance-onboarding-step-${number}-cta`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
{ctaLabel}
<ArrowRight size={14} />
</Link>
) : (
<button
type="button"
disabled
data-testid={`balance-onboarding-step-${number}-cta`}
aria-disabled="true"
title={disabledHint}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
>
{ctaLabel}
<ArrowRight size={14} />
</button>
)}
</div>
</li>
);
}

View file

@ -0,0 +1,128 @@
// BalanceOverviewCard — top summary tile of /balance.
//
// Issue #141 (Bilan #3). Displays:
// - The latest aggregate snapshot total (sum across all accounts on the
// most recent snapshot date).
// - Δ% versus the previous chronological snapshot (null when only one
// snapshot exists; rendered as "—").
// - A staleness warning when the latest snapshot is older than 60 days.
// - "+ Nouveau snapshot" CTA → `/balance/snapshot`.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
import { Link } from "react-router-dom";
import type { SnapshotTotalPoint } from "../../services/balance.service";
const STALENESS_DAYS = 60;
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
}).format(value);
interface BalanceOverviewCardProps {
/** The full evolution series for the active period (latest at the end). */
totals: SnapshotTotalPoint[];
}
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
const { t, i18n } = useTranslation();
const summary = useMemo(() => {
if (totals.length === 0) {
return null;
}
const last = totals[totals.length - 1];
const prev = totals.length >= 2 ? totals[totals.length - 2] : null;
const deltaPct =
prev && prev.total !== 0
? ((last.total - prev.total) / Math.abs(prev.total)) * 100
: null;
const ageMs = Date.now() - new Date(last.snapshot_date).getTime();
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
return {
latest: last,
deltaPct,
isStale: ageDays > STALENESS_DAYS,
ageDays,
};
}, [totals]);
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(dateLocale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.overview.latestTotal")}
</p>
{summary ? (
<>
<p className="text-3xl font-bold mt-1">
{cadFormatter(summary.latest.total)}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("balance.overview.asOf", {
date: formatDate(summary.latest.snapshot_date),
})}
</p>
</>
) : (
<p className="text-sm text-[var(--muted-foreground)] mt-2">
{t("balance.overview.noSnapshots")}
</p>
)}
</div>
<div className="flex flex-col items-stretch sm:items-end gap-2">
{summary && summary.deltaPct !== null && (
<div
className={`inline-flex items-center gap-1 text-sm font-medium ${
summary.deltaPct >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}`}
>
{summary.deltaPct >= 0 ? (
<TrendingUp size={16} />
) : (
<TrendingDown size={16} />
)}
{summary.deltaPct >= 0 ? "+" : ""}
{summary.deltaPct.toFixed(2)}%
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
{t("balance.overview.vsPrevious")}
</span>
</div>
)}
<Link
to="/balance/snapshot"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
<Plus size={16} />
{t("balance.overview.newSnapshot")}
</Link>
</div>
</div>
{summary?.isStale && (
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/30 text-sm">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>
{t("balance.overview.staleWarning", { days: summary.ageDays })}
</span>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,418 @@
// LinkTransfersModal — multi-select transactions and link them to a balance
// account in one shot. Issue #142 / Bilan #4.
//
// Filters available:
// - Period (from / to ISO dates) — default: last 90 days.
// - Category dropdown.
// - Free-text search on description.
//
// Each row shows: date, description, amount, suggested direction
// (auto-proposed via `suggestTransferDirection` from the signed amount,
// can be flipped per row), and a checkbox.
//
// On submit, calls `linkTransfer` for every selected row in sequence and
// reports any failures (most likely `transfer_already_linked` if the user
// double-clicked or another tab linked them already).
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Loader2, AlertCircle } from "lucide-react";
import { getTransactionPage } from "../../services/transactionService";
import {
linkTransfer,
suggestTransferDirection,
BalanceServiceError,
} from "../../services/balance.service";
import type {
Category,
TransactionRow,
BalanceTransferDirection,
} from "../../shared/types";
const DEFAULT_PAGE_SIZE = 100;
function isoDaysAgo(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return localISO(d);
}
function localISO(d: Date): string {
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
export interface LinkTransfersModalProps {
/** Account that the selected transfers will be attached to. */
accountId: number;
accountName: string;
/** Full category list for the filter dropdown. */
categories: Category[];
/** Optional pre-fill date bounds (defaults to last 90 days). */
initialFrom?: string;
initialTo?: string;
onClose: () => void;
/** Fired after at least one transfer was linked (parent typically reloads). */
onLinked?: (linkedCount: number) => void;
}
export default function LinkTransfersModal({
accountId,
accountName,
categories,
initialFrom,
initialTo,
onClose,
onLinked,
}: LinkTransfersModalProps) {
const { t, i18n } = useTranslation();
const [from, setFrom] = useState(initialFrom ?? isoDaysAgo(90));
const [to, setTo] = useState(initialTo ?? localISO(new Date()));
const [categoryId, setCategoryId] = useState<number | null>(null);
const [search, setSearch] = useState("");
const [rows, setRows] = useState<TransactionRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Selection state: id → direction. Presence in the map = selected.
const [selection, setSelection] = useState<
Map<number, BalanceTransferDirection>
>(new Map());
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const fmt = useMemo(
() =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
}),
[i18n.language]
);
// Re-fetch whenever the filters change. Debounced via React's render cycle
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
useEffect(() => {
let cancelled = false;
async function run() {
setIsLoading(true);
setError(null);
try {
const result = await getTransactionPage(
{
search: search.trim(),
categoryId,
sourceId: null,
dateFrom: from || null,
dateTo: to || null,
uncategorizedOnly: false,
},
{ column: "date", direction: "desc" },
1,
DEFAULT_PAGE_SIZE
);
if (!cancelled) {
setRows(result.rows);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void run();
return () => {
cancelled = true;
};
}, [from, to, categoryId, search]);
function toggleRow(row: TransactionRow) {
setSelection((prev) => {
const next = new Map(prev);
if (next.has(row.id)) {
next.delete(row.id);
} else {
next.set(row.id, suggestTransferDirection(row.amount));
}
return next;
});
}
function flipDirection(rowId: number) {
setSelection((prev) => {
const next = new Map(prev);
const current = next.get(rowId);
if (current === undefined) return prev;
next.set(rowId, current === "in" ? "out" : "in");
return next;
});
}
async function handleSubmit() {
if (selection.size === 0) return;
setSubmitting(true);
setSubmitError(null);
let linked = 0;
const failures: string[] = [];
for (const [transactionId, direction] of selection.entries()) {
try {
await linkTransfer(accountId, transactionId, direction);
linked += 1;
} catch (e) {
if (e instanceof BalanceServiceError) {
failures.push(`${transactionId}: ${t(`balance.transfers.errors.${e.code}`, { defaultValue: e.message })}`);
} else {
failures.push(`${transactionId}: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
setSubmitting(false);
if (failures.length > 0) {
setSubmitError(
`${t("balance.transfers.modal.partialFailure", { linked, total: selection.size })} — ${failures.join("; ")}`
);
}
if (linked > 0) {
onLinked?.(linked);
if (failures.length === 0) {
onClose();
}
}
}
const allFiltered = rows.length;
const selectedCount = selection.size;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
<div>
<h2 className="text-lg font-semibold">
{t("balance.transfers.modal.title", { account: accountName })}
</h2>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
{t("balance.transfers.modal.subtitle")}
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-[var(--muted)]/40"
aria-label={t("common.close")}
>
<X size={18} />
</button>
</div>
<div className="px-5 py-3 border-b border-[var(--border)] grid grid-cols-1 md:grid-cols-4 gap-3">
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.from")}
</span>
<input
type="date"
value={from}
onChange={(e) => {
setFrom(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.to")}
</span>
<input
type="date"
value={to}
onChange={(e) => {
setTo(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.category")}
</span>
<select
value={categoryId === null ? "" : String(categoryId)}
onChange={(e) =>
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
>
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.search")}
</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("balance.transfers.modal.searchPlaceholder")}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/>
</label>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="p-8 text-center text-[var(--muted-foreground)] flex items-center justify-center gap-2">
<Loader2 className="animate-spin" size={16} />
{t("balance.transfers.modal.loading")}
</div>
) : error ? (
<div className="p-8 text-center text-[var(--negative)] flex items-center justify-center gap-2">
<AlertCircle size={16} />
{error}
</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-[var(--muted-foreground)] italic">
{t("balance.transfers.modal.noTransactions")}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30 sticky top-0">
<tr>
<th className="w-10 px-3 py-2"></th>
<th className="text-left px-3 py-2 font-medium">
{t("transactions.date")}
</th>
<th className="text-left px-3 py-2 font-medium">
{t("transactions.description")}
</th>
<th className="text-right px-3 py-2 font-medium">
{t("transactions.amount")}
</th>
<th className="text-center px-3 py-2 font-medium">
{t("balance.transfers.modal.direction")}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isSelected = selection.has(row.id);
const direction = selection.get(row.id) ?? suggestTransferDirection(row.amount);
return (
<tr
key={row.id}
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(row)}
aria-label={`select-${row.id}`}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-md truncate" title={row.description}>
{row.description}
</td>
<td
className={`px-3 py-2 text-right font-mono ${row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}
>
{fmt.format(row.amount)}
</td>
<td className="px-3 py-2 text-center">
{isSelected ? (
<button
type="button"
onClick={() => flipDirection(row.id)}
className={`px-2 py-0.5 text-xs rounded font-medium ${
direction === "in"
? "bg-[var(--positive)]/15 text-[var(--positive)]"
: "bg-[var(--negative)]/15 text-[var(--negative)]"
}`}
title={t("balance.transfers.modal.toggleDirection")}
>
{t(`balance.transfers.direction.${direction}`)}
</button>
) : (
<span className="text-xs text-[var(--muted-foreground)]">
{t(`balance.transfers.direction.${direction}`)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{submitError && (
<div className="px-5 py-2 border-t border-[var(--border)] text-xs text-[var(--negative)]">
{submitError}
</div>
)}
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-between">
<div className="text-xs text-[var(--muted-foreground)]">
{t("balance.transfers.modal.summary", {
selected: selectedCount,
total: allFiltered,
})}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={submitting || selectedCount === 0}
className="px-3 py-1.5 text-sm rounded bg-[var(--primary)] text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<span className="flex items-center gap-1.5">
<Loader2 className="animate-spin" size={14} />
{t("balance.transfers.modal.linking")}
</span>
) : (
t("balance.transfers.modal.linkSelection", { count: selectedCount })
)}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}

View file

@ -0,0 +1,365 @@
// PriceFetchControl — unit tests (issue #158)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (logged as MEDIUM in decisions-log.md). Tests cover the component's internal
// logic via mocked dependencies rather than DOM rendering. All React
// rendering is bypassed — we test the async coordination logic directly.
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks — declared before imports to satisfy vi.mock hoisting
// ---------------------------------------------------------------------------
vi.mock("../../hooks/useIsPremium", () => ({
useIsPremium: vi.fn(),
}));
vi.mock("../../services/balance.service", () => ({
prices: {
fetchPrice: vi.fn(),
__resetForTests: vi.fn(),
},
}));
vi.mock("../../services/userPreferenceService", () => ({
getPreference: vi.fn(),
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" },
})),
}));
// lucide-react: return simple stubs
vi.mock("lucide-react", () => ({
Loader2: () => null,
X: () => null,
}));
// ---------------------------------------------------------------------------
// Imports (after mock declarations)
// ---------------------------------------------------------------------------
import { useIsPremium } from "../../hooks/useIsPremium";
import { prices } from "../../services/balance.service";
import type { PriceResult } from "../../services/balance.service";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
import {
__resetBestEffortDismissForTests,
} from "./PriceFetchControl";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockUseIsPremium = vi.mocked(useIsPremium);
const mockFetchPrice = vi.mocked(prices.fetchPrice);
const mockGetPreference = vi.mocked(getPreference);
const mockSetPreference = vi.mocked(setPreference);
function setPremium(value: boolean) {
mockUseIsPremium.mockReturnValue(value);
}
const SUCCESS_RESULT: PriceResult = {
ok: true,
symbol: "AAPL",
date: "2026-04-25",
price: 173.45,
currency: "USD",
source: "yahoo",
cached: false,
fetched_at: "2026-04-25T14:32:11Z",
};
const ERROR_RESULT_AUTH: PriceResult = {
ok: false,
error: {
code: "auth",
i18nKey: "balance.priceFetching.errors.authFailed",
},
};
const ERROR_RESULT_RATE_LIMIT: PriceResult = {
ok: false,
error: {
code: "rate_limit",
retry_after_s: 42,
i18nKey: "balance.priceFetching.errors.rateLimit",
},
};
// ---------------------------------------------------------------------------
// Test: component visibility guards
// ---------------------------------------------------------------------------
describe("PriceFetchControl — visibility guards", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
});
it("returns null when useIsPremium() is false (non-premium user)", () => {
// We test the guard logic directly since there's no RTL.
// The component returns null when !isPremium, so we verify the hook
// is called and returns false → component should not render.
setPremium(false);
const isPremium = useIsPremium();
expect(isPremium).toBe(false);
// Guard: if (!isPremium || categoryKind !== 'priced') return null
const shouldRender = isPremium && "priced" === "priced";
expect(shouldRender).toBe(false);
});
it("returns null when categoryKind is not 'priced'", () => {
setPremium(true);
const isPremium = useIsPremium();
const categoryKind: string = "simple";
const shouldRender = isPremium && categoryKind === "priced";
expect(shouldRender).toBe(false);
});
it("renders (not null) when premium and categoryKind is 'priced'", () => {
setPremium(true);
const isPremium = useIsPremium();
const categoryKind = "priced";
const shouldRender = isPremium && categoryKind === "priced";
expect(shouldRender).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Test: best-effort warning session state
// ---------------------------------------------------------------------------
describe("PriceFetchControl — best-effort warning (stock vs crypto)", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
});
it("best-effort warning flag starts undismissed after reset", () => {
// The module-level flag is false after __resetBestEffortDismissForTests
// The component initialises showBestEffortWarning = assetType === 'stock' && !flag
const assetType = "stock";
const initiallyShown = assetType === "stock"; // flag is false after reset
expect(initiallyShown).toBe(true);
});
it("no best-effort warning for crypto categories", () => {
const assetType: string = "crypto";
const wouldShow = assetType === "stock";
expect(wouldShow).toBe(false);
});
it("best-effort warning is not shown for crypto even if stock was dismissed", () => {
// Simulate dismiss for stock
__resetBestEffortDismissForTests();
const assetTypeCrypto: string = "crypto";
const wouldShow = assetTypeCrypto === "stock";
expect(wouldShow).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: consent flow
// ---------------------------------------------------------------------------
describe("PriceFetchControl — consent modal flow", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
});
it("first click with no consent: getPreference returns null → consent required", async () => {
mockGetPreference.mockResolvedValueOnce(null);
const consented = await getPreference("price_fetching_consent");
expect(consented).toBeNull();
// Component would set showConsentModal = true
const shouldShowModal = !consented;
expect(shouldShowModal).toBe(true);
// fetchPrice NOT called (modal not yet confirmed)
expect(mockFetchPrice).not.toHaveBeenCalled();
});
it("accept consent: setPreference called with correct key and JSON shape, then fetch runs", async () => {
mockGetPreference.mockResolvedValueOnce(null);
mockSetPreference.mockResolvedValueOnce(undefined);
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
// Simulate handleConsentAccept: write consent, then fetch
await setPreference(
"price_fetching_consent",
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
expect(mockSetPreference).toHaveBeenCalledOnce();
const [key, value] = mockSetPreference.mock.calls[0];
expect(key).toBe("price_fetching_consent");
const parsed = JSON.parse(value);
expect(parsed.version).toBe(1);
expect(typeof parsed.consented_at).toBe("string");
// Then fetch is called
await prices.fetchPrice("AAPL", "2026-04-25");
expect(mockFetchPrice).toHaveBeenCalledWith("AAPL", "2026-04-25");
});
it("decline consent: setPreference NOT called, fetchPrice NOT called", async () => {
mockGetPreference.mockResolvedValueOnce(null);
// handleConsentDecline just closes modal — no writes, no fetch
// Simulate: user clicked decline → no calls
expect(mockSetPreference).not.toHaveBeenCalled();
expect(mockFetchPrice).not.toHaveBeenCalled();
});
it("second click with consent already stored: no modal, fetch runs immediately", async () => {
mockGetPreference.mockResolvedValueOnce(
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
);
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
const consented = await getPreference("price_fetching_consent");
expect(!!consented).toBe(true);
// No modal needed → fetch immediately
const result = await prices.fetchPrice("AAPL", "2026-04-25");
expect(result.ok).toBe(true);
expect(mockFetchPrice).toHaveBeenCalledOnce();
// setPreference NOT called again (consent already exists)
expect(mockSetPreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: fetch success path
// ---------------------------------------------------------------------------
describe("PriceFetchControl — fetch success", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
mockGetPreference.mockResolvedValue(
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
);
});
it("on success: onPriceFetched called with price and currency", async () => {
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
const onPriceFetched = vi.fn();
const result = await prices.fetchPrice("AAPL", "2026-04-25");
if (result.ok) {
onPriceFetched(result.price, result.currency);
}
expect(onPriceFetched).toHaveBeenCalledWith(173.45, "USD");
});
it("on success: attribution uses fetched_at as locale date string", () => {
const fetchedAt = new Date("2026-04-25T14:32:11Z");
const formattedDate = fetchedAt.toLocaleDateString("fr-CA");
expect(typeof formattedDate).toBe("string");
expect(formattedDate.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Test: error paths
// ---------------------------------------------------------------------------
describe("PriceFetchControl — error paths", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
mockGetPreference.mockResolvedValue(
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
);
});
it("on auth error: error.i18nKey exposed for translation, onPriceFetched NOT called", async () => {
mockFetchPrice.mockResolvedValueOnce(ERROR_RESULT_AUTH);
const onPriceFetched = vi.fn();
const result = await prices.fetchPrice("AAPL", "2026-04-25");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.i18nKey).toBe("balance.priceFetching.errors.authFailed");
}
expect(onPriceFetched).not.toHaveBeenCalled();
});
it("on rate_limit error: retry_after_s exposed for interpolation, onPriceFetched NOT called", async () => {
mockFetchPrice.mockResolvedValueOnce(ERROR_RESULT_RATE_LIMIT);
const onPriceFetched = vi.fn();
const result = await prices.fetchPrice("AAPL", "2026-04-25");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("rate_limit");
expect(result.error.i18nKey).toBe("balance.priceFetching.errors.rateLimit");
if ("retry_after_s" in result.error) {
expect(result.error.retry_after_s).toBe(42);
}
}
expect(onPriceFetched).not.toHaveBeenCalled();
});
it("on error: manual input is not disabled — the component never controls it", () => {
// PriceFetchControl is purely additive — it never disables the unit_price input.
// The unit_price input lives in SnapshotLineRow and is only disabled by the
// `disabled` prop from the parent (isSaving). This test documents the contract.
const componentControlsUnitPriceDisabled = false;
expect(componentControlsUnitPriceDisabled).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: fetchPrice is called with correct symbol and date args
// ---------------------------------------------------------------------------
describe("PriceFetchControl — fetchPrice invocation args", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
mockGetPreference.mockResolvedValue(
JSON.stringify({ consented_at: "2026-04-26T08:00:00Z", version: 1 })
);
});
it("fetchPrice called once with correct symbol and date after consent confirmed", async () => {
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
// Simulate the fetch sequence (consent exists → direct fetch)
await prices.fetchPrice("BTC", "2026-04-26");
expect(mockFetchPrice).toHaveBeenCalledOnce();
expect(mockFetchPrice).toHaveBeenCalledWith("BTC", "2026-04-26");
});
it("fetchPrice not called when consent is declined", async () => {
mockGetPreference.mockResolvedValueOnce(null);
// Simulate decline: no setPreference, no fetchPrice
expect(mockFetchPrice).not.toHaveBeenCalled();
expect(mockSetPreference).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,287 @@
// PriceFetchControl — fetch-price button with consent modal, spinner,
// best-effort warning (stocks only), and attribution display.
//
// Issue #158 — wires into SnapshotLineRow for priced-kind categories.
//
// Behavior rules (from spec §1 + ADR 0011):
// - Hidden when useIsPremium() === false OR categoryKind !== 'priced'
// - First use requires explicit consent (persisted in user_preferences)
// - For stock assetType: shows a "best-effort" badge + dismissable warning
// (once per session, in-memory only — NOT persisted)
// - Manual unit_price input stays active in all error paths (this component
// is purely additive)
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, X } from "lucide-react";
import { useIsPremium } from "../../hooks/useIsPremium";
import { prices } from "../../services/balance.service";
import type { PriceError } from "../../services/balance.service";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
// ---------------------------------------------------------------------------
// Module-level session dismiss state for best-effort warning (ADR 0011 §garde-fous)
// ---------------------------------------------------------------------------
let _bestEffortDismissedThisSession = false;
// Exported for tests — resets the in-memory dismiss flag.
export function __resetBestEffortDismissForTests(): void {
_bestEffortDismissedThisSession = false;
}
// Consent preference key (per-profile via per-profile SQLite DB).
const CONSENT_KEY = "price_fetching_consent";
interface PriceFetchControlProps {
symbol: string;
date: string; // YYYY-MM-DD
categoryKind: "simple" | "priced";
assetType: "stock" | "crypto";
onPriceFetched: (price: number, currency: string) => void;
}
/**
* Check whether the user has already given consent for price fetching.
* Returns true when a non-empty consent record exists in user_preferences.
*/
async function hasConsent(): Promise<boolean> {
try {
const raw = await getPreference(CONSENT_KEY);
return !!raw;
} catch {
return false;
}
}
/** Persist consent (consented_at + version shape). */
async function writeConsent(): Promise<void> {
await setPreference(
CONSENT_KEY,
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
}
export default function PriceFetchControl({
symbol,
date,
categoryKind,
assetType,
onPriceFetched,
}: PriceFetchControlProps) {
const { t, i18n } = useTranslation();
const isPremium = useIsPremium();
// Local UI state
const [showConsentModal, setShowConsentModal] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<PriceError | null>(null);
const [attribution, setAttribution] = useState<string | null>(null);
// Whether the best-effort warning is currently shown (stock only).
const [showBestEffortWarning, setShowBestEffortWarning] = useState(
assetType === "stock" && !_bestEffortDismissedThisSession
);
// Keep the warning display in sync when the session-level flag is updated
// from a sibling instance (e.g. multiple priced rows dismiss in sequence).
useEffect(() => {
if (assetType === "stock") {
setShowBestEffortWarning(!_bestEffortDismissedThisSession);
}
}, [assetType]);
// Hidden for non-premium users or non-priced categories.
if (!isPremium || categoryKind !== "priced") {
return null;
}
const dismissBestEffortWarning = () => {
_bestEffortDismissedThisSession = true;
setShowBestEffortWarning(false);
};
/** Actually trigger the price fetch (called after consent is confirmed). */
const doFetch = async () => {
setIsFetching(true);
setError(null);
setAttribution(null);
const result = await prices.fetchPrice(symbol, date);
setIsFetching(false);
if (result.ok) {
onPriceFetched(result.price, result.currency);
// Show attribution with the fetched_at timestamp formatted to locale date.
const fetchedAt = new Date(result.fetched_at);
const formattedDate = fetchedAt.toLocaleDateString(
i18n.language === "fr" ? "fr-CA" : "en-CA"
);
setAttribution(t("balance.priceFetching.attribution", { date: formattedDate }));
} else {
setError(result.error);
}
};
/** Handle the main button click: check consent, then fetch or show modal. */
const handleClick = async () => {
if (isFetching) return;
setError(null);
setAttribution(null);
const consented = await hasConsent();
if (!consented) {
setShowConsentModal(true);
} else {
await doFetch();
}
};
/** User accepted in the consent modal. */
const handleConsentAccept = async () => {
setShowConsentModal(false);
try {
await writeConsent();
} catch {
// Non-blocking — proceed with fetch even if pref write failed.
}
await doFetch();
};
/** User declined in the consent modal. */
const handleConsentDecline = () => {
setShowConsentModal(false);
};
// Build the error i18n args.
const errorMessage = error
? t(error.i18nKey, {
seconds:
"retry_after_s" in error ? Math.ceil(error.retry_after_s) : undefined,
minutes:
"retry_after_s" in error
? Math.ceil(error.retry_after_s / 60)
: undefined,
defaultValue: error.i18nKey,
})
: null;
return (
<div className="flex flex-col gap-1">
{/* Stock best-effort warning — shown once per session, dismissable */}
{assetType === "stock" && showBestEffortWarning && (
<div className="flex items-start gap-1 text-[10px] text-[var(--muted-foreground)] bg-[var(--muted)]/60 rounded px-2 py-1">
<span className="flex-1">{t("balance.priceFetching.bestEffortNotice")}</span>
<button
type="button"
aria-label={t("common.close")}
onClick={dismissBestEffortWarning}
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={10} />
</button>
</div>
)}
<div className="flex items-center gap-2">
{/* Fetch button */}
<button
type="button"
onClick={handleClick}
disabled={isFetching}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[var(--border)] text-xs font-medium text-[var(--foreground)] bg-[var(--card)] hover:bg-[var(--muted)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={t("balance.priceFetching.button")}
>
{isFetching ? (
<Loader2 size={12} className="animate-spin" />
) : null}
{t("balance.priceFetching.button")}
{/* 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
</span>
)}
</button>
{/* Attribution line — shown after a successful fetch */}
{attribution && !isFetching && (
<span className="text-[10px] text-[var(--muted-foreground)]">
{attribution}
</span>
)}
</div>
{/* Inline error message */}
{errorMessage && !isFetching && (
<p
role="alert"
className="text-xs text-[var(--negative)] mt-0.5"
data-testid="price-fetch-error"
>
{errorMessage}
</p>
)}
{/* Consent modal — rendered inline, portaled via fixed positioning */}
{showConsentModal && (
<ConsentModal
onAccept={handleConsentAccept}
onDecline={handleConsentDecline}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ConsentModal — minimal overlay, no external modal lib required
// ---------------------------------------------------------------------------
function ConsentModal({
onAccept,
onDecline,
}: {
onAccept: () => void;
onDecline: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="price-consent-title"
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full p-6">
<h2
id="price-consent-title"
className="text-base font-semibold mb-2"
>
{t("balance.priceFetching.consent.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-5">
{t("balance.priceFetching.consent.body")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onDecline}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("balance.priceFetching.consent.decline")}
</button>
<button
type="button"
onClick={onAccept}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
{t("balance.priceFetching.consent.accept")}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
// SnapshotEditor — groups the active accounts by balance category and
// renders one `SnapshotLineRow` per account.
//
// Both `simple` and `priced` variants are dispatched by `account.category_kind`
// inside `SnapshotLineRow`. The editor itself only carries the values down
// and the change handlers up.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type {
BalanceAccountWithCategory,
BalanceCategory,
} from "../../shared/types";
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
import SnapshotLineRow from "./SnapshotLineRow";
interface Props {
accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[];
/** account_id → string-typed value (simple kind). */
values: Record<number, string>;
/** account_id → {quantity, unit_price} strings (priced kind). */
pricedValues: Record<number, PricedEntry>;
onValueChange: (accountId: number, next: string) => void;
onQuantityChange: (accountId: number, next: string) => void;
onUnitPriceChange: (accountId: number, next: string) => void;
disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
snapshotDate?: string;
}
export default function SnapshotEditor({
accounts,
categories,
values,
pricedValues,
onValueChange,
onQuantityChange,
onUnitPriceChange,
disabled,
snapshotDate,
}: Props) {
const { t } = useTranslation();
// Group accounts by their category, preserving the categories' sort_order
// first then the account name within each group.
const groups = useMemo(() => {
const byCategory = new Map<number, BalanceAccountWithCategory[]>();
for (const acc of accounts) {
const list = byCategory.get(acc.balance_category_id) ?? [];
list.push(acc);
byCategory.set(acc.balance_category_id, list);
}
const sortedCategories = [...categories].sort(
(a, b) => a.sort_order - b.sort_order || a.key.localeCompare(b.key)
);
return sortedCategories
.map((cat) => ({
category: cat,
accounts: (byCategory.get(cat.id) ?? []).sort((a, b) =>
a.name.localeCompare(b.name)
),
}))
.filter((group) => group.accounts.length > 0);
}, [accounts, categories]);
if (accounts.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
{t("balance.snapshot.editor.empty")}
</div>
);
}
return (
<div className="flex flex-col gap-4">
{groups.map(({ category, accounts: catAccounts }) => (
<div
key={category.id}
className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"
>
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
<h3 className="text-sm font-semibold">
{t(category.i18n_key, { defaultValue: category.key })}
</h3>
</div>
<div className="px-4">
{catAccounts.map((acc) => {
const priced = pricedValues[acc.id];
return (
<SnapshotLineRow
key={acc.id}
account={acc}
value={values[acc.id] ?? ""}
quantityValue={priced?.quantity ?? ""}
unitPriceValue={priced?.unit_price ?? ""}
onChange={(next) => onValueChange(acc.id, next)}
onQuantityChange={(next) => onQuantityChange(acc.id, next)}
onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)}
disabled={disabled}
snapshotDate={snapshotDate}
/>
);
})}
</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,222 @@
// SnapshotLineRow — single account line inside the snapshot editor.
//
// Two variants are dispatched by `account.category_kind`:
//
// - `simple` (Issue #146): a single value input keyed by `account_id`.
// - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both
// required), and a read-only `value` field that
// renders `quantity * unit_price` live as the
// user types. An attribution tag `[Manuel]`
// appears next to the row; the `[via Maximus]`
// tag is rendered by PriceFetchControl (Issue #158).
//
// We keep this component dumb on purpose: it receives strings from the
// parent (the editor stores raw strings to preserve partial input) and
// emits new strings on every change. Numeric validation happens at save
// time in `useSnapshotEditor.save` against the service's
// `validateLineKindInvariants` helper.
import { ChangeEvent, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { BalanceAccountWithCategory } from "../../shared/types";
import PriceFetchControl from "./PriceFetchControl";
interface BaseProps {
account: BalanceAccountWithCategory;
disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */
snapshotDate?: string;
}
interface SimpleProps extends BaseProps {
value: string;
onChange: (next: string) => void;
/** Optional priced handlers for callers that wire both at once. */
quantityValue?: string;
unitPriceValue?: string;
onQuantityChange?: (next: string) => void;
onUnitPriceChange?: (next: string) => void;
}
type Props = SimpleProps;
/**
* Parse a string like "12.34" or "12,34" into a finite number, or null
* if invalid / empty. Used by the priced variant to compute the live
* `value` preview.
*/
function parseDecimal(raw: string): number | null {
if (!raw) return null;
const trimmed = String(raw).trim().replace(",", ".");
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
export default function SnapshotLineRow({
account,
value,
onChange,
disabled,
quantityValue,
unitPriceValue,
onQuantityChange,
onUnitPriceChange,
snapshotDate,
}: Props) {
const { t } = useTranslation();
const isPriced = account.category_kind === "priced";
// Compute the live value preview for priced rows. Returns null when
// either input cannot yet be parsed (so we display a placeholder).
const computedPricedValue = useMemo(() => {
if (!isPriced) return null;
const qty = parseDecimal(quantityValue ?? "");
const price = parseDecimal(unitPriceValue ?? "");
if (qty === null || price === null) return null;
return qty * price;
}, [isPriced, quantityValue, unitPriceValue]);
if (isPriced) {
const handleQty = (e: ChangeEvent<HTMLInputElement>) =>
onQuantityChange?.(e.target.value);
const handlePrice = (e: ChangeEvent<HTMLInputElement>) =>
onUnitPriceChange?.(e.target.value);
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{account.name}</span>
<span
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
title={t("balance.snapshot.priced.attributionManualHint")}
>
{t("balance.snapshot.priced.attributionManual")}
</span>
</div>
{account.symbol && (
<div className="text-xs text-[var(--muted-foreground)]">
{account.symbol}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-col gap-0.5">
<input
type="text"
inputMode="decimal"
value={quantityValue ?? ""}
onChange={handleQty}
disabled={disabled}
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
aria-label={t("balance.snapshot.priced.quantityLabel", {
account: account.name,
})}
/>
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
{t("balance.snapshot.priced.quantity")}
</span>
</div>
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
×
</span>
<div className="flex flex-col gap-0.5">
<input
type="text"
inputMode="decimal"
value={unitPriceValue ?? ""}
onChange={handlePrice}
disabled={disabled}
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
className="w-28 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
account: account.name,
})}
/>
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
{t("balance.snapshot.priced.unitPrice")}
</span>
</div>
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
=
</span>
<div className="flex flex-col gap-0.5">
<input
type="text"
value={
computedPricedValue === null
? ""
: computedPricedValue.toFixed(2)
}
readOnly
disabled
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] focus:outline-none cursor-not-allowed"
aria-label={t("balance.snapshot.priced.computedValueLabel", {
account: account.name,
})}
aria-readonly="true"
/>
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
{t("balance.snapshot.priced.computedValue")}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)] w-10">
{account.currency}
</span>
{/* PriceFetchControl wired next to the unit_price input.
Hidden when category_asset_type is null (legacy custom priced
rows pre-#169 migration; user must edit the category to set it). */}
{account.symbol && account.category_asset_type && (
<PriceFetchControl
symbol={account.symbol}
date={snapshotDate ?? ""}
categoryKind={account.category_kind as "priced"}
assetType={account.category_asset_type}
onPriceFetched={(price) =>
onUnitPriceChange?.(String(price))
}
/>
)}
</div>
</div>
);
}
// Simple variant — unchanged from #146.
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<div className="flex items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{account.name}</div>
{account.symbol && (
<div className="text-xs text-[var(--muted-foreground)]">
{account.symbol}
</div>
)}
</div>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
value={value}
onChange={handleChange}
disabled={disabled}
placeholder={t("balance.snapshot.line.valuePlaceholder")}
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
aria-label={t("balance.snapshot.line.valueLabel", {
account: account.name,
})}
/>
<span className="text-xs text-[var(--muted-foreground)] w-10">
{account.currency}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,152 @@
// StarterAccountsModal — unit tests (issue #179)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (matches the BalanceOnboardingCard.test.tsx pattern from #178). Tests cover
// the service-layer helpers (`getStarterCollisions`, `proposeStarterAccounts`)
// and the `STARTER_ACCOUNTS` constant — the modal itself is pure orchestration
// over those helpers.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../services/db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "../../services/db";
import {
STARTER_ACCOUNTS,
getStarterCollisions,
proposeStarterAccounts,
} from "../../services/balance.service";
const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
mockExecute.mockReset();
});
describe("STARTER_ACCOUNTS", () => {
it("ships exactly 4 starters mapping cash/tfsa/rrsp/other", () => {
expect(STARTER_ACCOUNTS).toHaveLength(4);
expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([
"cash",
"tfsa",
"rrsp",
"other",
]);
for (const s of STARTER_ACCOUNTS) {
expect(s.categoryKey).toBe(s.key);
expect(s.i18nKey).toMatch(/^balance\.starters\.items\./);
}
});
});
describe("getStarterCollisions", () => {
it("returns empty set when no accounts collide", async () => {
mockSelect.mockResolvedValueOnce([]);
const result = await getStarterCollisions();
expect(result.size).toBe(0);
});
it("flags exact-name collisions case-insensitive trim", async () => {
mockSelect.mockResolvedValueOnce([
{ key: "cash", account_name: " compte chèque " },
{ key: "tfsa", account_name: "Mon CELI 2024" }, // does NOT match "CELI" exactly
]);
const result = await getStarterCollisions();
expect(result.has("cash")).toBe(true);
expect(result.has("tfsa")).toBe(false);
expect(result.has("rrsp")).toBe(false);
expect(result.has("other")).toBe(false);
});
it("requires the account to live in the matching category", async () => {
// CELI-named account but in 'cash' category → not a collision for tfsa starter
mockSelect.mockResolvedValueOnce([
{ key: "cash", account_name: "CELI" },
]);
const result = await getStarterCollisions();
expect(result.has("tfsa")).toBe(false);
expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque"
});
it("excludes archived accounts via SQL filter", async () => {
mockSelect.mockResolvedValueOnce([]);
await getStarterCollisions();
const sql = mockSelect.mock.calls[0][0];
expect(sql).toMatch(/archived_at IS NULL/);
});
});
describe("proposeStarterAccounts", () => {
it("returns [] when no keys selected without opening a transaction", async () => {
const result = await proposeStarterAccounts([]);
expect(result).toEqual([]);
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts selected starters atomically and returns their ids", async () => {
// BEGIN
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
// For each starter: SELECT category id, SELECT in-txn collision check, INSERT
mockSelect
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
.mockResolvedValueOnce([{ count: 0 }]) // S3 collision check for cash
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check for rrsp
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 100 }) // INSERT cash
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
const result = await proposeStarterAccounts(["cash", "rrsp"]);
expect(result).toEqual([100, 101]);
const sqls = mockExecute.mock.calls.map((c) => c[0]);
expect(sqls[0]).toBe("BEGIN");
expect(sqls[sqls.length - 1]).toBe("COMMIT");
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(2);
});
it("skips silently when in-txn collision check finds an existing account (S3)", async () => {
// BEGIN
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
// First starter "cash": category lookup succeeds, collision check returns count=1 → skip
mockSelect
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
.mockResolvedValueOnce([{ count: 1 }]) // S3 collision: cash already exists
// Second starter "rrsp": category lookup + clean collision check
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
.mockResolvedValueOnce([{ count: 0 }]); // rrsp clean
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
const result = await proposeStarterAccounts(["cash", "rrsp"]);
expect(result).toEqual([101]); // only rrsp inserted, cash skipped silently
const sqls = mockExecute.mock.calls.map((c) => c[0]);
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(1);
expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow
});
it("rolls back on insert failure", async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
mockSelect
.mockResolvedValueOnce([{ id: 11 }]) // cash category
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean
mockExecute.mockRejectedValueOnce(new Error("disk full"));
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // ROLLBACK
await expect(proposeStarterAccounts(["cash"])).rejects.toThrow();
const sqls = mockExecute.mock.calls.map((c) => c[0]);
expect(sqls).toContain("BEGIN");
expect(sqls).toContain("ROLLBACK");
expect(sqls).not.toContain("COMMIT");
});
});

View file

@ -0,0 +1,209 @@
// StarterAccountsModal — one-shot opt-in modal proposing 4 starter accounts
// (Compte chèque, CELI, REER, Compte non-enregistré) to existing profiles
// when they first land on /balance. Issue #179.
//
// Behavior:
// - 4 checkboxes default-checked.
// - Collision rule (case-insensitive trim name + same category): the
// matching checkbox is disabled and uncheckable; tooltip explains why.
// - "Ajouter les comptes sélectionnés" → atomic BEGIN/COMMIT INSERT, then
// onClose(insertedIds).
// - "Plus tard" → no INSERT, onClose([]).
// - Parent owns isOpen state and writes user_preferences.balance_starter_proposed
// in onClose so the modal never re-appears.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Loader2 } from "lucide-react";
import {
STARTER_ACCOUNTS,
getStarterCollisions,
proposeStarterAccounts,
} from "../../services/balance.service";
export interface StarterAccountsModalProps {
/** Parent guard — modal renders only when true. */
isOpen: boolean;
/**
* Fired in both branches (confirm + dismiss). The parent uses the returned
* ids to write `user_preferences.balance_starter_proposed` so the modal
* never re-appears, regardless of which branch was taken.
*/
onClose: (acceptedIds: number[]) => void;
}
export default function StarterAccountsModal({
isOpen,
onClose,
}: StarterAccountsModalProps) {
const { t } = useTranslation();
const [collisions, setCollisions] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(
() => new Set(STARTER_ACCOUNTS.map((s) => s.key))
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [collisionsLoaded, setCollisionsLoaded] = useState(false);
// Load collisions once when the modal opens. We pre-uncheck colliding
// starters (and disable them) so the visible default-checked count matches
// what would actually be inserted.
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
void (async () => {
try {
const c = await getStarterCollisions();
if (cancelled) return;
setCollisions(c);
setSelected((prev) => {
const next = new Set(prev);
for (const k of c) next.delete(k);
return next;
});
setCollisionsLoaded(true);
} catch {
if (!cancelled) setCollisionsLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, [isOpen]);
if (!isOpen) return null;
const toggle = (key: string) => {
if (collisions.has(key)) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const handleAdd = async () => {
if (submitting) return;
setError(null);
setSubmitting(true);
try {
const ids = await proposeStarterAccounts(Array.from(selected));
setSubmitting(false);
onClose(ids);
} catch {
setSubmitting(false);
setError(t("balance.starters.errors.insert"));
}
};
const handleLater = () => {
if (submitting) return;
onClose([]);
};
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="balance-starters-title"
data-testid="balance-starters-modal"
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full">
<div className="flex items-start justify-between p-5 border-b border-[var(--border)]">
<div>
<h2
id="balance-starters-title"
className="text-lg font-semibold"
>
{t("balance.starters.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{t("balance.starters.description")}
</p>
</div>
<button
type="button"
onClick={handleLater}
aria-label={t("common.close")}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</div>
<ul className="p-5 space-y-2" data-testid="balance-starters-list">
{STARTER_ACCOUNTS.map((s) => {
const isCollision = collisions.has(s.key);
const isChecked = selected.has(s.key);
return (
<li key={s.key}>
<label
className={`flex items-center gap-3 p-3 rounded-lg border ${
isCollision
? "border-[var(--border)] opacity-60 cursor-not-allowed"
: "border-[var(--border)] hover:bg-[var(--muted)]/30 cursor-pointer"
}`}
title={
isCollision
? t("balance.starters.collision_tooltip")
: undefined
}
data-testid={`balance-starter-row-${s.key}`}
data-collision={isCollision ? "true" : "false"}
>
<input
type="checkbox"
checked={isChecked}
disabled={isCollision || submitting}
onChange={() => toggle(s.key)}
data-testid={`balance-starter-checkbox-${s.key}`}
/>
<span className="text-sm font-medium">
{t(s.i18nKey)}
</span>
{isCollision && (
<span className="ml-auto text-xs italic text-[var(--muted-foreground)]">
{t("balance.starters.collision_tooltip")}
</span>
)}
</label>
</li>
);
})}
</ul>
{error && (
<div className="mx-5 mb-3 p-2 rounded text-sm bg-[var(--negative)]/10 text-[var(--negative)] border border-[var(--negative)]/20">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2 p-5 border-t border-[var(--border)]">
<button
type="button"
onClick={handleLater}
disabled={submitting}
data-testid="balance-starters-cta-later"
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)]/30 disabled:opacity-50"
>
{t("balance.starters.cta_later")}
</button>
<button
type="button"
onClick={handleAdd}
disabled={submitting || !collisionsLoaded || selected.size === 0}
data-testid="balance-starters-cta-add"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{submitting && <Loader2 size={14} className="animate-spin" />}
{t("balance.starters.cta_add")}
</button>
</div>
</div>
</div>,
document.body
);
}

View file

@ -0,0 +1,151 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import type {
MappingRow as MappingRowType,
ConfidenceBadge,
} from "../../services/categoryMappingService";
interface MappingRowProps {
row: MappingRowType;
/** When true, the row is highlighted (its preview panel is open). */
isSelected: boolean;
/** Callback fired when the row is clicked — opens the preview panel. */
onSelect: (v2CategoryId: number) => void;
/**
* Called with the new v1 target id + name when the user resolves the row
* via the inline dropdown. The dropdown is only rendered for unresolved
* ("🟠 needs review") rows resolved rows just show the target name.
*/
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
/** Number of transactions currently attached to this v2 category. */
transactionCount: number;
}
function badgeClass(confidence: ConfidenceBadge): string {
switch (confidence) {
case "high":
return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300";
case "medium":
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
case "low":
return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300";
case "none":
return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300";
}
}
export default function MappingRow({
row,
isSelected,
onSelect,
onResolve,
transactionCount,
}: MappingRowProps) {
const { t } = useTranslation();
const { getLeaves } = useCategoryTaxonomy();
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
// list flat because the simulate row is narrow; the search box in step 2
// already helps users find a target by keyword.
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
const badgeLabel = t(
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
);
const reasonLabel = t(
`categoriesSeed.migration.simulate.reason.${row.reason}`,
);
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
const v1TargetId = Number(ev.target.value);
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
if (!leaf) return;
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
onResolve(row.v2CategoryId, v1TargetId, name);
};
const rowClass =
"grid grid-cols-12 gap-2 items-center px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors " +
(isSelected
? "bg-[var(--primary)]/10 border-[var(--primary)]/40"
: "bg-[var(--card)] border-[var(--border)] hover:border-[var(--primary)]/30 hover:bg-[var(--muted)]");
const targetDisplayName = isUnresolved
? null
: row.v1TargetName;
return (
<div
className={rowClass}
onClick={() => onSelect(row.v2CategoryId)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(row.v2CategoryId);
}
}}
aria-label={`${row.v2CategoryName}${targetDisplayName ?? t("categoriesSeed.migration.simulate.needsReview")}`}
>
{/* v2 category name + tx count */}
<div className="col-span-4 flex items-center gap-2 min-w-0">
<span className="truncate font-medium text-[var(--foreground)]">
{row.v2CategoryName}
</span>
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.txCount", {
count: transactionCount,
})}
</span>
</div>
{/* Confidence badge + reason */}
<div className="col-span-3 flex items-center gap-2">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeClass(
row.confidence,
)}`}
title={row.notes ?? undefined}
>
{badgeLabel}
</span>
<span className="text-xs text-[var(--muted-foreground)] truncate">
{reasonLabel}
</span>
</div>
{/* v1 target (or picker) */}
<div
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
onClick={(e) => e.stopPropagation()}
>
{isUnresolved ? (
<select
value=""
onChange={handleResolveChange}
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
>
<option value="" disabled>
{t("categoriesSeed.migration.simulate.chooseTarget")}
</option>
{v1Leaves.map((leaf) => (
<option key={leaf.id} value={leaf.id}>
{t(leaf.i18n_key, { defaultValue: leaf.name })}
</option>
))}
</select>
) : (
<span className="truncate text-[var(--foreground)]">
{targetDisplayName}
</span>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,245 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
ShieldCheck,
FolderLock,
Loader2,
CheckCircle2,
Circle,
} from "lucide-react";
interface StepConsentProps {
/** PIN/password for PIN-protected profiles. Empty string if no PIN. */
password: string;
onPasswordChange: (value: string) => void;
/** True when the current profile is PIN-protected — hides the field otherwise. */
requiresPassword: boolean;
/** Transition indicator: the running loader reuses this file via a flag. */
isRunning: boolean;
/** Progress stage for the loader (0 = backup, 1 = verified, 2 = sql, 3 = done). */
runningStage: 0 | 1 | 2 | 3;
onBack: () => void;
onConfirm: () => void;
}
/**
* Step 3 Consent: an explicit checklist + confirm button, plus a loader
* that takes over once the user clicks confirm. The loader shows the 4 sub-
* steps (backup created, backup verified, SQL running, commit) per the mockup.
*/
export default function StepConsent({
password,
onPasswordChange,
requiresPassword,
isRunning,
runningStage,
onBack,
onConfirm,
}: StepConsentProps) {
const { t } = useTranslation();
const [ack1, setAck1] = useState(false);
const [ack2, setAck2] = useState(false);
const [ack3, setAck3] = useState(false);
const allAck = ack1 && ack2 && ack3;
const canConfirm =
!isRunning && allAck && (!requiresPassword || password.trim().length > 0);
if (isRunning) {
return <RunningLoader stage={runningStage} />;
}
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.consent.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.subtitle")}
</p>
</header>
{/* Backup info card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<div className="flex items-start gap-3">
<ShieldCheck
size={18}
className="mt-0.5 shrink-0 text-[var(--primary)]"
/>
<div>
<h3 className="font-semibold">
{t("categoriesSeed.migration.consent.backup.title")}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.backup.body")}
</p>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)] pl-8">
<FolderLock size={12} className="inline mr-1" />
{t("categoriesSeed.migration.consent.backup.location")}
</p>
</div>
{/* Password field (only for PIN-protected profiles) */}
{requiresPassword && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
<label
htmlFor="consent-password"
className="text-sm font-medium text-[var(--foreground)]"
>
{t("categoriesSeed.migration.consent.password.label")}
</label>
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.password.help")}
</p>
<input
id="consent-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
/>
</div>
)}
{/* Checklist */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<p className="text-sm font-medium text-[var(--foreground)]">
{t("categoriesSeed.migration.consent.checklist.title")}
</p>
<ul className="space-y-2 text-sm text-[var(--foreground)]">
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack1}
onChange={(e) => setAck1(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item1")}</span>
</label>
</li>
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack2}
onChange={(e) => setAck2(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item2")}</span>
</label>
</li>
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack3}
onChange={(e) => setAck3(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item3")}</span>
</label>
</li>
</ul>
</div>
{/* Nav */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] text-sm"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.consent.back")}
</button>
<button
type="button"
onClick={onConfirm}
disabled={!canConfirm}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
>
<ShieldCheck size={16} />
{t("categoriesSeed.migration.consent.confirm")}
</button>
</div>
</section>
);
}
interface StageLineProps {
done: boolean;
active: boolean;
label: string;
}
function StageLine({ done, active, label }: StageLineProps) {
const icon = done ? (
<CheckCircle2 size={16} className="text-green-600 dark:text-green-400" />
) : active ? (
<Loader2 size={16} className="animate-spin text-[var(--primary)]" />
) : (
<Circle size={16} className="text-[var(--muted-foreground)]" />
);
return (
<li className="flex items-center gap-3">
{icon}
<span
className={
done
? "text-sm text-[var(--foreground)]"
: active
? "text-sm font-medium text-[var(--foreground)]"
: "text-sm text-[var(--muted-foreground)]"
}
>
{label}
</span>
</li>
);
}
function RunningLoader({ stage }: { stage: 0 | 1 | 2 | 3 }) {
const { t } = useTranslation();
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.running.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.running.subtitle")}
</p>
</header>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
<ul className="space-y-3" aria-live="polite">
<StageLine
done={stage > 0}
active={stage === 0}
label={t("categoriesSeed.migration.running.step1")}
/>
<StageLine
done={stage > 1}
active={stage === 1}
label={t("categoriesSeed.migration.running.step2")}
/>
<StageLine
done={stage > 2}
active={stage === 2}
label={t("categoriesSeed.migration.running.step3")}
/>
<StageLine
done={stage > 3}
active={stage === 3}
label={t("categoriesSeed.migration.running.step4")}
/>
</ul>
</div>
</section>
);
}

View file

@ -0,0 +1,167 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree from "../categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
interface StepDiscoverProps {
onNext: () => void;
}
function collectAllIds(nodes: TaxonomyNode[]): number[] {
const ids: number[] = [];
const walk = (n: TaxonomyNode) => {
ids.push(n.id);
n.children.forEach(walk);
};
nodes.forEach(walk);
return ids;
}
function countNodes(nodes: TaxonomyNode[]): {
roots: number;
subcategories: number;
leaves: number;
} {
let roots = 0;
let subcategories = 0;
let leaves = 0;
for (const root of nodes) {
roots += 1;
for (const child of root.children) {
if (child.children.length === 0) leaves += 1;
else {
subcategories += 1;
for (const leaf of child.children) {
if (leaf.children.length === 0) leaves += 1;
else subcategories += 1;
}
}
}
}
return { roots, subcategories, leaves };
}
/**
* Step 1 Discover: read-only navigation of the v1 taxonomy. Reuses the same
* CategoryTaxonomyTree component as the standalone guide page (#117) so the
* two surfaces stay visually consistent.
*/
export default function StepDiscover({ onNext }: StepDiscoverProps) {
const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
const counts = countNodes(taxonomy.roots);
const total = counts.roots + counts.subcategories + counts.leaves;
const toggleNode = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => {
setExpanded(new Set(collectAllIds(taxonomy.roots)));
};
const handleCollapseAll = () => setExpanded(new Set());
const allExpanded = expanded.size > 0;
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.discover.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.discover.subtitle")}
</p>
</header>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
<h3 className="font-semibold">
{t("categoriesSeed.migration.discover.intro.title")}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.discover.intro.body")}
</p>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-4">
<p
className="text-sm text-[var(--muted-foreground)]"
aria-live="polite"
>
{t("categoriesSeed.guidePage.counter", {
roots: counts.roots,
subcategories: counts.subcategories,
leaves: counts.leaves,
total,
})}
</p>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] pointer-events-none"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("categoriesSeed.guidePage.searchPlaceholder")}
aria-label={t("categoriesSeed.guidePage.searchPlaceholder")}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={allExpanded ? handleCollapseAll : handleExpandAll}
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? (
<>
<ChevronsDownUp size={16} />
{t("categoriesSeed.guidePage.collapseAll")}
</>
) : (
<>
<ChevronsUpDown size={16} />
{t("categoriesSeed.guidePage.expandAll")}
</>
)}
</button>
</div>
</div>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<CategoryTaxonomyTree
nodes={taxonomy.roots}
expanded={expanded}
onToggle={toggleNode}
searchQuery={search}
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={onNext}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
{t("categoriesSeed.migration.discover.next")}
<ArrowRight size={16} />
</button>
</div>
</section>
);
}

View file

@ -0,0 +1,225 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
import MappingRow from "./MappingRow";
import TransactionPreviewPanel from "./TransactionPreviewPanel";
import type {
MigrationPlan,
MappingRow as MappingRowType,
} from "../../services/categoryMappingService";
interface StepSimulateProps {
plan: MigrationPlan;
unresolved: number;
selectedRowV2Id: number | null;
transactionCountByV2Id: Map<number, number>;
onResolveRow: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
onSelectRow: (v2CategoryId: number | null) => void;
onNext: () => void;
onBack: () => void;
}
/**
* Step 2 Simulate (dry-run): a 3-column table (v2 | confidence | v1 target),
* a preview side panel per selected row, and a blocking guard on the "next"
* button until every row is resolved. No DB writes happen here the plan is
* mutated in memory via the reducer.
*/
export default function StepSimulate({
plan,
unresolved,
selectedRowV2Id,
transactionCountByV2Id,
onResolveRow,
onSelectRow,
onNext,
onBack,
}: StepSimulateProps) {
const { t } = useTranslation();
const selectedRow = useMemo<MappingRowType | null>(() => {
if (selectedRowV2Id === null) return null;
return (
plan.rows.find((r) => r.v2CategoryId === selectedRowV2Id) ??
plan.preserved.find((r) => r.v2CategoryId === selectedRowV2Id) ??
null
);
}, [plan.rows, plan.preserved, selectedRowV2Id]);
const canContinue = unresolved === 0;
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.simulate.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.subtitle")}
</p>
</header>
{/* Stats summary */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
<dl className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-sm">
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.stats.total")}
</dt>
<dd className="text-lg font-semibold">{plan.stats.total}</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.high")}
</dt>
<dd className="text-lg font-semibold text-green-600 dark:text-green-400">
{plan.stats.high}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.medium")}
</dt>
<dd className="text-lg font-semibold text-blue-600 dark:text-blue-400">
{plan.stats.medium}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.low")}
</dt>
<dd className="text-lg font-semibold text-orange-600 dark:text-orange-400">
{plan.stats.low}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.none")}
</dt>
<dd className="text-lg font-semibold text-red-600 dark:text-red-400">
{plan.stats.none}
</dd>
</div>
</dl>
</div>
{/* Unresolved warning banner */}
{unresolved > 0 && (
<div
role="status"
className="flex items-start gap-3 rounded-xl border border-orange-300 bg-orange-50 p-4 dark:bg-orange-900/10 dark:border-orange-700"
>
<AlertTriangle
size={18}
className="mt-0.5 shrink-0 text-orange-600 dark:text-orange-400"
/>
<p className="text-sm text-orange-900 dark:text-orange-200">
{t("categoriesSeed.migration.simulate.unresolvedWarning", {
count: unresolved,
})}
</p>
</div>
)}
{/* Header row */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<div
className="grid grid-cols-12 gap-2 px-3 py-2 text-xs font-semibold uppercase text-[var(--muted-foreground)] border-b border-[var(--border)]"
aria-hidden="true"
>
<div className="col-span-4">
{t("categoriesSeed.migration.simulate.header.current")}
</div>
<div className="col-span-3">
{t("categoriesSeed.migration.simulate.header.match")}
</div>
<div className="col-span-5 text-right">
{t("categoriesSeed.migration.simulate.header.target")}
</div>
</div>
<ul className="mt-2 space-y-1">
{plan.rows.map((row) => (
<li key={row.v2CategoryId}>
<MappingRow
row={row}
isSelected={selectedRowV2Id === row.v2CategoryId}
onSelect={onSelectRow}
onResolve={onResolveRow}
transactionCount={
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
}
/>
</li>
))}
</ul>
</div>
{/* Preserved categories block */}
{plan.preserved.length > 0 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<div className="flex items-start gap-3">
<FolderHeart
size={18}
className="mt-0.5 shrink-0 text-[var(--primary)]"
/>
<div>
<h3 className="font-semibold">
{t("categoriesSeed.migration.simulate.preserved.title", {
count: plan.preserved.length,
})}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.preserved.body")}
</p>
</div>
</div>
<ul className="text-sm space-y-1">
{plan.preserved.map((row) => (
<li
key={row.v2CategoryId}
className="px-3 py-1.5 rounded-md bg-[var(--muted)] text-[var(--foreground)]"
>
<span className="font-medium">{row.v2CategoryName}</span>
<span className="text-xs text-[var(--muted-foreground)] ml-2">
{t("categoriesSeed.migration.simulate.preserved.txCount", {
count: transactionCountByV2Id.get(row.v2CategoryId) ?? 0,
})}
</span>
</li>
))}
</ul>
</div>
)}
{/* Nav */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] text-sm"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.simulate.back")}
</button>
<button
type="button"
onClick={onNext}
disabled={!canContinue}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
>
{t("categoriesSeed.migration.simulate.next")}
<ArrowRight size={16} />
</button>
</div>
{/* Side panel with tx preview */}
{selectedRow !== null && (
<TransactionPreviewPanel
row={selectedRow}
onClose={() => onSelectRow(null)}
/>
)}
</section>
);
}

View file

@ -0,0 +1,180 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { getDb } from "../../services/db";
import type { MappingRow } from "../../services/categoryMappingService";
interface TransactionRow {
id: number;
date: string;
description: string;
amount: number;
}
interface TransactionPreviewPanelProps {
row: MappingRow | null;
onClose: () => void;
}
const MAX_TRANSACTIONS = 50;
/**
* Side panel that shows transactions currently attached to a v2 category
* selected in the simulate table. Read-only exists purely to give the user
* enough context to decide whether the proposed v1 target is correct.
*
* We fetch lazily (once per selected row id) and cap at 50 rows. If the user
* has more than 50 tx attached, a small "... and N more" line is rendered.
*/
export default function TransactionPreviewPanel({
row,
onClose,
}: TransactionPreviewPanelProps) {
const { t, i18n } = useTranslation();
const [loading, setLoading] = useState(false);
const [transactions, setTransactions] = useState<TransactionRow[]>([]);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
let cancelled = false;
if (row === null) {
setTransactions([]);
setTotalCount(0);
return;
}
const load = async () => {
setLoading(true);
try {
const db = await getDb();
const txs = await db.select<TransactionRow[]>(
`SELECT id, date, description, amount
FROM transactions
WHERE category_id = $1
ORDER BY date DESC, id DESC
LIMIT $2`,
[row.v2CategoryId, MAX_TRANSACTIONS],
);
const count = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
[row.v2CategoryId],
);
if (!cancelled) {
setTransactions(txs);
setTotalCount(count[0]?.cnt ?? 0);
}
} catch {
if (!cancelled) {
setTransactions([]);
setTotalCount(0);
}
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => {
cancelled = true;
};
}, [row]);
if (row === null) return null;
const locale = i18n.language?.startsWith("en") ? "en-CA" : "fr-CA";
const formatAmount = (value: number): string =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "CAD",
minimumFractionDigits: 2,
}).format(value);
const formatDate = (iso: string): string => {
try {
return new Date(iso).toLocaleDateString(locale);
} catch {
return iso;
}
};
const overflow = Math.max(0, totalCount - transactions.length);
return (
<aside
className="fixed inset-y-0 right-0 w-full max-w-md bg-[var(--card)] border-l border-[var(--border)] shadow-xl z-40 flex flex-col"
aria-label={t("categoriesSeed.migration.simulate.panel.title")}
>
<header className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="min-w-0">
<h3 className="font-semibold truncate text-[var(--foreground)]">
{row.v2CategoryName}
</h3>
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.panel.subtitle", {
count: totalCount,
})}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("categoriesSeed.migration.simulate.panel.close")}
className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</header>
<div className="flex-1 overflow-y-auto p-2">
{loading ? (
<p className="p-4 text-sm text-[var(--muted-foreground)]">
{t("common.loading")}
</p>
) : transactions.length === 0 ? (
<p className="p-4 text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.panel.noTransactions")}
</p>
) : (
<ul className="space-y-1">
{transactions.map((tx) => (
<li
key={tx.id}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] text-sm"
>
<div className="min-w-0">
<p className="truncate text-[var(--foreground)]">
{tx.description}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{formatDate(tx.date)}
</p>
</div>
<span
className={
tx.amount < 0
? "shrink-0 font-mono text-red-600 dark:text-red-400"
: "shrink-0 font-mono text-green-600 dark:text-green-400"
}
>
{formatAmount(tx.amount)}
</span>
</li>
))}
{overflow > 0 && (
<li className="px-2 py-2 text-xs text-[var(--muted-foreground)] text-center">
{t("categoriesSeed.migration.simulate.panel.overflow", {
count: overflow,
})}
</li>
)}
</ul>
)}
</div>
<footer className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--muted-foreground)]">
{row.v1TargetName
? t("categoriesSeed.migration.simulate.panel.willMapTo", {
target: row.v1TargetName,
})
: t("categoriesSeed.migration.simulate.panel.noTarget")}
</footer>
</aside>
);
}

View file

@ -0,0 +1,214 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
interface CategoryTaxonomyTreeProps {
nodes: TaxonomyNode[];
expanded: Set<number>;
onToggle: (id: number) => void;
searchQuery: string;
}
interface NodeRowProps {
node: TaxonomyNode;
depth: number;
expanded: Set<number>;
onToggle: (id: number) => void;
visibleIds: Set<number> | null;
}
function NodeRow({
node,
depth,
expanded,
onToggle,
visibleIds,
}: NodeRowProps) {
const { t } = useTranslation();
const label = t(node.i18n_key, { defaultValue: node.name });
const hasChildren = node.children.length > 0;
const isExpanded = expanded.has(node.id);
// Filter children by visibility set (search mode) if provided.
const visibleChildren = useMemo(() => {
if (visibleIds === null) return node.children;
return node.children.filter((child) => visibleIds.has(child.id));
}, [node.children, visibleIds]);
const typeLabel =
node.type === "income"
? t("categoriesSeed.guidePage.type.income")
: node.type === "transfer"
? t("categoriesSeed.guidePage.type.transfer")
: t("categoriesSeed.guidePage.type.expense");
const tooltipText = [
`${t("categoriesSeed.guidePage.tooltip.key")}: ${node.i18n_key}`,
`${t("categoriesSeed.guidePage.tooltip.type")}: ${typeLabel}`,
`${t("categoriesSeed.guidePage.tooltip.id")}: ${node.id}`,
].join("\n");
// On screen: show children only if expanded.
// In print: @media print in styles.css overrides display:none to show everything.
const childrenHidden = !isExpanded;
return (
<li className="taxonomy-node">
<div
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-[var(--muted)] transition-colors"
style={{ paddingLeft: `${depth * 1.25 + 0.5}rem` }}
title={tooltipText}
>
{hasChildren ? (
<button
type="button"
onClick={() => onToggle(node.id)}
aria-label={
isExpanded
? t("categoriesSeed.guidePage.collapseAll")
: t("categoriesSeed.guidePage.expandAll")
}
aria-expanded={isExpanded}
className="print:hidden shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="shrink-0 w-[14px]" aria-hidden="true" />
)}
<span
className="shrink-0 inline-block h-3 w-3 rounded-sm border border-[var(--border)]"
style={{ backgroundColor: node.color }}
aria-hidden="true"
/>
<span
className={
depth === 0
? "font-semibold text-[var(--foreground)]"
: depth === 1
? "font-medium text-[var(--foreground)]"
: "text-[var(--foreground)]"
}
>
{label}
</span>
{depth === 0 && (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
({node.children.length})
</span>
)}
</div>
{visibleChildren.length > 0 && (
<ul
className={`list-none m-0 p-0 taxonomy-children ${
childrenHidden ? "taxonomy-children-collapsed" : ""
}`}
>
{visibleChildren.map((child) => (
<NodeRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
visibleIds={visibleIds}
/>
))}
</ul>
)}
</li>
);
}
/**
* Build the set of node IDs whose visible subtree matches the query.
* A node is kept if its translated name contains the query OR any of its descendants match.
*/
export function collectVisibleIds(
roots: TaxonomyNode[],
normalizedQuery: string,
translate: (key: string, fallback: string) => string,
): Set<number> {
const visible = new Set<number>();
if (normalizedQuery.length === 0) {
const walk = (n: TaxonomyNode) => {
visible.add(n.id);
n.children.forEach(walk);
};
roots.forEach(walk);
return visible;
}
const walk = (node: TaxonomyNode): boolean => {
const label = translate(node.i18n_key, node.name);
const selfMatches = normalize(label).includes(normalizedQuery);
let anyChildMatches = false;
for (const child of node.children) {
if (walk(child)) anyChildMatches = true;
}
if (selfMatches || anyChildMatches) {
visible.add(node.id);
return true;
}
return false;
};
roots.forEach(walk);
return visible;
}
// Case-and-accent insensitive normalization for search.
export function normalize(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export default function CategoryTaxonomyTree({
nodes,
expanded,
onToggle,
searchQuery,
}: CategoryTaxonomyTreeProps) {
const { t } = useTranslation();
const normalizedQuery = normalize(searchQuery.trim());
const visibleIds = useMemo(() => {
if (normalizedQuery.length === 0) return null;
return collectVisibleIds(nodes, normalizedQuery, (key, fallback) =>
t(key, { defaultValue: fallback }),
);
}, [nodes, normalizedQuery, t]);
const visibleRoots =
visibleIds === null ? nodes : nodes.filter((r) => visibleIds.has(r.id));
if (visibleRoots.length === 0) {
return (
<p className="text-sm text-[var(--muted-foreground)] p-4">
{t("categoriesSeed.guidePage.noResults")}
</p>
);
}
return (
<ul className="list-none m-0 p-0">
{visibleRoots.map((root) => (
<NodeRow
key={root.id}
node={root}
depth={0}
expanded={expanded}
onToggle={onToggle}
visibleIds={visibleIds}
/>
))}
</ul>
);
}

View file

@ -129,7 +129,9 @@ function TreeRowContent({
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: node.color ?? "#9ca3af" }}
/>
<span className="flex-1 truncate">{node.name}</span>
<span className="flex-1 truncate">
{node.i18n_key ? t(node.i18n_key, { defaultValue: node.name }) : node.name}
</span>
<TypeBadge type={node.type} />
{node.keyword_count > 0 && (
<span className="text-[11px] text-[var(--muted-foreground)]">

View file

@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Sparkles, X, ArrowRight } from "lucide-react";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
// Preference keys used to decide whether to show the banner.
// - CATEGORIES_SCHEMA_VERSION is set by migration v8 (see src-tauri/src/lib.rs):
// existing profiles are tagged 'v2' (legacy seed), new profiles are 'v1'
// (IPC taxonomy). Only v2 profiles are invited to discover the new v1 guide.
// - BANNER_DISMISSED is a persistent flag set when the user dismisses the
// banner; once set, the banner never reappears for this profile.
const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
const BANNER_DISMISSED_KEY = "categories_v1_banner_dismissed";
type Visibility = "loading" | "visible" | "hidden";
/**
* Dashboard banner that invites users on the legacy v2 category seed to
* discover the new v1 IPC taxonomy guide. It is:
* - Non-destructive (read-only CTA that navigates to the guide page).
* - Dismissable the dismissal is persisted in `user_preferences` and
* survives app restarts / profile reloads.
* - Only visible on profiles tagged `categories_schema_version='v2'`.
* Profiles on the new v1 seed never see it (they already have the IPC
* taxonomy).
*/
export default function CategoriesV1DiscoveryBanner() {
const { t } = useTranslation();
const [visibility, setVisibility] = useState<Visibility>("loading");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [schemaVersion, dismissed] = await Promise.all([
getPreference(CATEGORIES_SCHEMA_VERSION_KEY),
getPreference(BANNER_DISMISSED_KEY),
]);
if (cancelled) return;
const shouldShow = schemaVersion === "v2" && dismissed !== "1";
setVisibility(shouldShow ? "visible" : "hidden");
} catch {
// If prefs cannot be read (e.g. DB not ready), hide the banner
// silently — it is a purely informational, non-critical UI element.
if (!cancelled) setVisibility("hidden");
}
})();
return () => {
cancelled = true;
};
}, []);
const dismiss = async () => {
// Optimistically hide the banner, then persist the flag. If persistence
// fails we still keep it hidden for this session.
setVisibility("hidden");
try {
await setPreference(BANNER_DISMISSED_KEY, "1");
} catch {
// Ignore — the banner will reappear on next launch if the write failed,
// which is an acceptable degradation.
}
};
if (visibility !== "visible") return null;
return (
<div
role="status"
className="flex items-start gap-3 rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 p-4 mb-6"
>
<div className="mt-0.5 shrink-0 rounded-lg bg-[var(--primary)]/10 p-2 text-[var(--primary)]">
<Sparkles size={18} />
</div>
<div className="flex-1 space-y-2 text-sm">
<p className="font-semibold text-[var(--foreground)]">
{t("dashboard.categoriesBanner.title")}
</p>
<p className="text-[var(--muted-foreground)]">
{t("dashboard.categoriesBanner.description")}
</p>
<Link
to="/settings/categories/standard"
className="inline-flex items-center gap-1.5 font-medium text-[var(--primary)] hover:underline"
>
{t("dashboard.categoriesBanner.cta")}
<ArrowRight size={14} />
</Link>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("dashboard.categoriesBanner.dismiss")}
className="shrink-0 rounded-md p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
</button>
</div>
);
}

View file

@ -94,7 +94,11 @@ export default function PeriodSelector({
<input
type="date"
value={localFrom}
onChange={(e) => setLocalFrom(e.target.value)}
onChange={(e) => {
setLocalFrom(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
/>
</div>
@ -105,7 +109,11 @@ export default function PeriodSelector({
<input
type="date"
value={localTo}
onChange={(e) => setLocalTo(e.target.value)}
onChange={(e) => {
setLocalTo(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
/>
</div>

View file

@ -8,6 +8,7 @@ import {
SlidersHorizontal,
PiggyBank,
BarChart3,
Wallet,
Settings,
Languages,
Moon,
@ -25,6 +26,7 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
SlidersHorizontal,
PiggyBank,
BarChart3,
Wallet,
Settings,
};

View file

@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
@ -25,6 +27,8 @@ function formatMonth(month: string): string {
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
export type CategoryOverTimeChartType = "bar" | "area";
interface CategoryOverTimeChartProps {
data: CategoryOverTimeData;
hiddenCategories: Set<string>;
@ -32,6 +36,13 @@ interface CategoryOverTimeChartProps {
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
showAmounts?: boolean;
/**
* Visual rendering mode. `bar` (default) keeps the legacy stacked-bar
* composition. `area` stacks Recharts <Area> layers (stackId="1") for a
* smoother flow view. Both modes share the same palette and SVG grayscale
* patterns (existing signature visual).
*/
chartType?: CategoryOverTimeChartType;
}
export default function CategoryOverTimeChart({
@ -41,6 +52,7 @@ export default function CategoryOverTimeChart({
onShowAll,
onViewDetails,
showAmounts,
chartType = "bar",
}: CategoryOverTimeChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<string | null>(null);
@ -68,6 +80,58 @@ export default function CategoryOverTimeChart({
);
}
// Shared chart configuration used by both Bar and Area variants.
const patternPrefix = "cat-time";
const patternDefs = (
<ChartPatternDefs
prefix={patternPrefix}
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
/>
);
const commonAxes = (
<>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: unknown, name: unknown) => {
if (hoveredCategory && name !== hoveredCategory) return [null, null];
return [cadFormatter(Number(value) || 0), String(name)];
}}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
filterNull
/>
<Legend
onMouseEnter={(e) => {
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
}}
onMouseLeave={() => setHoveredCategory(null)}
wrapperStyle={{ cursor: "pointer" }}
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
/>
</>
);
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
{hiddenCategories.size > 0 && (
@ -94,73 +158,53 @@ export default function CategoryOverTimeChart({
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs
prefix="cat-time"
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
/>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: unknown, name: unknown) => {
if (hoveredCategory && name !== hoveredCategory) return [null, null];
return [cadFormatter(Number(value) || 0), String(name)];
}}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
filterNull
/>
<Legend
onMouseEnter={(e) => {
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
}}
onMouseLeave={() => setHoveredCategory(null)}
wrapperStyle={{ cursor: "pointer" }}
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
/>
{categoryEntries.map((c) => (
<Bar
key={c.name}
dataKey={c.name}
stackId="stack"
fill={getPatternFill("cat-time", c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
cursor="context-menu"
style={{ transition: "fill-opacity 150ms" }}
>
{showAmounts && (
<LabelList
dataKey={c.name}
position="center"
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
/>
)}
</Bar>
))}
</BarChart>
{chartType === "area" ? (
<AreaChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
{patternDefs}
{commonAxes}
{categoryEntries.map((c) => (
<Area
key={c.name}
type="monotone"
dataKey={c.name}
stackId="1"
stroke={c.color}
fill={getPatternFill(patternPrefix, c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
style={{ transition: "fill-opacity 150ms", cursor: "context-menu" }}
/>
))}
</AreaChart>
) : (
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
{patternDefs}
{commonAxes}
{categoryEntries.map((c) => (
<Bar
key={c.name}
dataKey={c.name}
stackId="stack"
fill={getPatternFill(patternPrefix, c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
cursor="context-menu"
style={{ transition: "fill-opacity 150ms" }}
>
{showAmounts && (
<LabelList
dataKey={c.name}
position="center"
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
/>
)}
</Bar>
))}
</BarChart>
)}
</ResponsiveContainer>
</div>

View file

@ -1,13 +1,8 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
color: string | null;
parent_id: number | null;
}
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
export interface CategoryZoomHeaderProps {
categoryId: number | null;
@ -23,13 +18,24 @@ export default function CategoryZoomHeader({
onIncludeSubcategoriesChange,
}: CategoryZoomHeaderProps) {
const { t } = useTranslation();
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) =>
setCategories(
rows.map((r) => ({ id: r.id, name: r.name, color: r.color, parent_id: r.parent_id })),
rows.map((r) => ({
id: r.id,
name: r.name,
parent_id: r.parent_id ?? undefined,
color: r.color ?? undefined,
icon: r.icon ?? undefined,
type: r.type,
is_active: r.is_active,
is_inputable: r.is_inputable,
sort_order: r.sort_order,
created_at: "",
})),
),
)
.catch(() => setCategories([]));
@ -41,18 +47,13 @@ export default function CategoryZoomHeader({
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.category.selectCategory")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => onCategoryChange(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((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<CategoryCombobox
categories={categories}
value={categoryId}
onChange={onCategoryChange}
placeholder={t("reports.category.searchPlaceholder")}
ariaLabel={t("reports.category.selectCategory")}
/>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input

View file

@ -10,8 +10,7 @@ export default function CompareModeTabs({ value, onChange }: CompareModeTabsProp
const { t } = useTranslation();
const modes: { id: CompareMode; labelKey: string }[] = [
{ id: "mom", labelKey: "reports.compare.modeMoM" },
{ id: "yoy", labelKey: "reports.compare.modeYoY" },
{ id: "actual", labelKey: "reports.compare.modeActual" },
{ id: "budget", labelKey: "reports.compare.modeBudget" },
];

View file

@ -4,16 +4,17 @@ import {
Bar,
XAxis,
YAxis,
Cell,
ReferenceLine,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import type { CategoryDelta } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface ComparePeriodChartProps {
rows: CategoryDelta[];
previousLabel: string;
currentLabel: string;
}
function formatCurrency(amount: number, language: string): string {
@ -24,7 +25,11 @@ function formatCurrency(amount: number, language: string): string {
}).format(amount);
}
export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
export default function ComparePeriodChart({
rows,
previousLabel,
currentLabel,
}: ComparePeriodChartProps) {
const { t, i18n } = useTranslation();
if (rows.length === 0) {
@ -35,31 +40,44 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
);
}
const chartData = rows
.map((r, i) => ({
// Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart.
const chartData = [...rows]
.sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({
name: r.categoryName,
previousAmount: r.previousAmount,
currentAmount: r.currentAmount,
color: r.categoryColor,
delta: r.deltaAbs,
index: i,
}))
.sort((a, b) => a.delta - b.delta);
}));
const previousFill = "var(--muted-foreground)";
const currentFill = "var(--primary)";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 32 + 40)}>
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<ChartPatternDefs
prefix="compare-delta"
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
/>
<ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
barCategoryGap="20%"
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis
type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis type="category" dataKey="name" width={140} stroke="var(--muted-foreground)" fontSize={11} />
<ReferenceLine x={0} stroke="var(--border)" />
<YAxis
type="category"
dataKey="name"
width={140}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
@ -69,15 +87,23 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<Bar
dataKey="previousAmount"
name={previousLabel}
fill={previousFill}
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="currentAmount"
name={currentLabel}
fill={currentFill}
radius={[0, 4, 4, 0]}
/>
<Bar dataKey="delta">
{chartData.map((entry) => (
<Cell
key={entry.name}
fill={getPatternFill("compare-delta", entry.index, entry.color)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>

View file

@ -3,14 +3,21 @@ import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodTableProps {
rows: CategoryDelta[];
/** Label for the "previous" monthly column (e.g. "March 2026" or "2025"). */
previousLabel: string;
/** Label for the "current" monthly column (e.g. "April 2026" or "2026"). */
currentLabel: string;
/** Optional label for the previous cumulative window (YTD). Falls back to previousLabel. */
cumulativePreviousLabel?: string;
/** Optional label for the current cumulative window (YTD). Falls back to currentLabel. */
cumulativeCurrentLabel?: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
@ -18,6 +25,7 @@ function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(amount);
}
@ -31,84 +39,211 @@ function formatPct(pct: number | null, language: string): string {
}).format(pct / 100);
}
function variationColor(value: number): string {
// Compare report deals with expenses (abs values): spending more is negative
// for the user, spending less is positive. Mirror that colour convention
// consistently so the eye parses the delta sign at a glance.
if (value > 0) return "var(--negative, #ef4444)";
if (value < 0) return "var(--positive, #10b981)";
return "";
}
export default function ComparePeriodTable({
rows,
previousLabel,
currentLabel,
cumulativePreviousLabel,
cumulativeCurrentLabel,
}: ComparePeriodTableProps) {
const { t, i18n } = useTranslation();
const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Totals across all rows (there is no parent/child structure in compare mode).
const totals = rows.reduce(
(acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount,
monthDelta: acc.monthDelta + r.deltaAbs,
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
}),
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
);
const totalMonthPct =
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null;
const totalYtdPct =
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th
rowSpan={2}
className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]"
>
{t("reports.highlights.category")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{previousLabel}
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.monthly")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{currentLabel}
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.ytd")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationAbs")}
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthCurrLabel}</div>
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationPct")}
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
<td
colSpan={9}
className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"
>
{t("reports.empty.noData")}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
<>
{rows.map((row) => (
<tr
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, i18n.language)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, i18n.language)}
</td>
</tr>
))}
{/* Grand totals row */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.totalRow")}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, i18n.language)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color:
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
{formatSignedCurrency(totals.monthDelta, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums"
style={{
color:
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatPct(row.deltaPct, i18n.language)}
{formatPct(totalMonthPct, i18n.language)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(totalYtdPct, i18n.language)}
</td>
</tr>
))
</>
)}
</tbody>
</table>

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