Commit graph

139 commits

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

Refs #395

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

Refs #408

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

Resolves #395

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

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

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

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

Refs #376

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

Review of #396 (PR #402).

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

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

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

Review of #396 (PR #402).

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:46:26 -04:00
le king fu
b0206ae56b fix(profiles): catch secondary profiles up on missed migrations (#390)
tauri-plugin-sql migrates only the database registered with add_migrations,
the default profile's. A secondary profile kept the consolidated schema of
the version that created it and never received a migration afterwards.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:00 -04:00
le king fu
ed35570121 fix(license): drop an activation token that no longer stands for this machine
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m21s
A data directory written by v0.15.0 or earlier can hold a valid key next
to the activation token of another license, typically a refunded hence
revoked one. The License card read "Activated", market prices were
refused, and "Activate" was never offered: the folder could not recover
without a manual file removal.

- Prices: a 403 license_revoked drops the refused token, and only while
  the file still holds it, so an activation that lands while the refused
  request is in flight keeps its new token (CWE-367). The key's revoked
  state is never written from this route. The token is read through
  ACTIVATION_FILE by the directory-based read_stored_activation_token_in,
  in fetch_price_in, testable without AppHandle.
- Machine list: list_activated_machines returns { machines,
  token_dropped }. A complete list that names this machine under neither
  identifier drops a token that activates this machine, when the OS id is
  readable and the token is the one on disk when the list was asked for.
  An error, a revoked key or a rate limit drops nothing.
  machines_from_verify counts the rows it skips, which the list still
  tolerates. This also gives "Activate" back to a machine deactivated from
  another computer.
- Front: the activation status is read again after every list load; a
  fixed log line (ACTIVATION_TOKEN_LOG) is written on token_dropped and on
  a price refused as revoked; the revoked-license price message invites to
  activate the machine again.
- Docs: architecture, ADR 0017 (#365 amendment), ADR 0020 (rule 4), user
  guide and in-app tips, changelogs.

Resolves #367

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:58:25 -04:00
le king fu
e2f0fb86da feat(license): remove the stored license key from the License card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m26s
"Remove the key" on the License card removes license.key, then the
activation token, and keeps license.check, so a revoked key removed then
entered again stays Free. Removing the key stops the revocation check,
which until now only storing another key could stop.

When this machine is activated with a valid key, its slot is freed on
the license server first (remove_license with release_slot). A release
that fails removes nothing; on network, server, rate_limit or
invalid_response the card asks whether to remove the key anyway, the
slot then staying occupied. no_active_activation, license_not_found and
license_revoked (recorded for the key sent) mean the slot is free.

- Rust: remove_license / remove_license_in, release_this_machine_slot,
  this_machine_token_id, and the shared remove_activation_token_in
  helper, now also used by store_license_in and the deactivation.
- Front: removeLicense service; performRemoveLicense in
  useLicenseMachines (confirmations by state, error table, in-app log
  lines); removing and removeError in the machines reducer, which lock
  key entry, Activate and Deactivate; the button for any stored key,
  valid or revoked.
- Docs: user guide, in-app tips, architecture, ADR 0017 amendment,
  changelogs.

Resolves #370

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

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

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

Resolves #351

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:22:18 -04:00
le king fu
b107cf42ce fix(license): apply the review of #368
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m45s
PR Check — Rust / rust (pull_request) Successful in 8m54s
- A state file that cannot be written now has a test on both of its paths:
  the attempt (nothing is sent, or a read-only data directory would send a
  request at every start) and the verdict (the key is not reported revoked
  when nothing on disk says so). The first path was correct but unguarded:
  the review's mutation left all 185 tests green.
- The storage outcome no longer claims that nothing was sent: it also covers
  a verdict that could not be written. Reworded in licenseService.ts, the
  in-app log line and docs/architecture.md.
- The Updates card names a revoked key in its Free branch, between the
  message and the CTAs: a refunded Base buyer first meets the loss of
  automatic updates there (Max, 2026-10-03). The error page stays as it is.
- A revoked key keeps being checked once per 24 h while it stays stored, so
  that a correction made on the license server is picked up (Max,
  2026-10-03). Declared in the user guide, the in-app docs, the changelogs
  and the ADR 0017 amendment.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:29:47 -04:00
le king fu
4225dadebe test(license): guard what the first mutation pass left green (#365)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m3s
Five mutations survived the first pass, for four gaps:
- the revoked flag exposed by the provider: the value is now derived by
  licenseContextValue, a plain function, and tested;
- the price message for a revoked license missing from either locale;
- the attempt written after the request instead of before: a test now reads
  the state file from the server's side, while the request is in flight;
- the 24 h interval, which the boundary rows followed because they were
  built from the constant: both sides of the boundary are written out.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:10:24 -04:00
le king fu
e24c30fe6a feat(license): online revocation check — a revoked key falls back to Free (#365)
A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.

Rust
- license.check, next to the key, holds what the license server last said
  about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
  missing, unreadable or invalid file, or a state about another key, reads
  as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
  locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
  the key only. Nothing is sent without a stored key that validates, nor
  within 24 h of the last attempt, which is written before the request.
  Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
  under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
  premium_required. It records nothing: the price route judges the license
  of the activation token, which the client cannot tie to the stored key,
  and 0.15.0 left the token of a previous key next to a new one (#367).

Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
  session flag and markRevoked are gone. The check starts once per app
  start, writes its outcome to the in-app log, and reloads the context only
  when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.

Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:45:34 -04:00
le king fu
0a9c9f71dd fix(license): apply the review of #366
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m47s
PR Check — Rust / rust (pull_request) Successful in 8m46s
The review approved the previous head with five suggestions; this commit
applies the four that touch the repository (the fifth is the manual check
in the pull request description).

- Tests: a valid token issued for another machine goes with any row of
  this machine. Two rows added to the deactivation table: the reviewer's
  mutation that kept such a token stayed green before them.
- ADR 0020, removal condition: counted from the last raw-claim token the
  server issued, not from a client version. A Free 0.15.0 no longer
  updates and can still activate with the raw id months from now.
- ADR 0020: names what the transition leaves behind when a machine is
  activated again while its raw row is still active (a second row, one
  more slot), and what a public key does not cover (whoever already holds
  the OS id can recompute the derived one).
- "No command hands the OS id to the webview" was wider than the code: the
  server's list still carries it for a row from before the derivation.
  Bounded in the ADR, architecture.md and the licenseService.ts comment.

No behaviour change. Rust 168, vitest 1314.

Refs #350

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:12:32 -04:00
le king fu
e5662ad018 fix(license): send a derived machine identifier, never the OS machine id (#350)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 9m19s
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.

- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
  characters, pinned by two reference vectors. An empty OS id is refused,
  never hashed.
- Activation sends the derived identifier and requires the issued token to
  carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
  whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
  identifier (D3). `ActivationStatus` no longer carries an identifier and
  the unused `get_machine_id` command is removed: no command hands the OS
  id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
  freed slot: any row of this machine takes it, except the old raw row
  while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
  removing the raw-id compatibility.

Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.

No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.

Refs #350

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:52:48 -04:00
le king fu
d2ded515fd fix(license): name key-entry errors, keep the edition off the activation token, flag revoked keys
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m11s
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
  UnknownEdition. validate_with_key returns it (ExpiredSignature maps
  to LicenseExpired), and store_license, read_license and
  validate_license_key reject with the same JSON as the network
  commands. store_license_in and read_license_in take a directory, so
  the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
  resolve_license_entitlements no longer takes the activation token,
  and current_entitlements reads only license.key
  (entitlements_from_dir). A token that had expired (2-year server
  lifetime) or came from another machine (restored data) used to drop
  a valid key to free, while a key copied without its token kept its
  edition. get_activation_status and persist_activation_token keep the
  strict token validation, and the key keeps its strict validation
  (CWE-613).

Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
  static render reaches it) names a refused key, through
  parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
  whole session, because the card unmounts when the user leaves
  Settings > Users. Only a different key clears it. Card-level
  message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
  deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
  beyond 50 years (licenseExpiry.ts, for the 100-year keys of
  maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
  the price authFailed message points to activating the machine.

Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).

Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.

Fixes #347
Fixes #360
Refs #363

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:39:01 -04:00
le king fu
57a0fbc2f7 feat(upsell): activate "Get Base", honest Premium note, purchase path in Free, Account card text
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
- upsellCtaFor(tier) decides what a locked screen offers: Base is
  purchasable, Premium stays disabled with "Premium isn't sold online
  yet". Pure and tested on the whole entitlements matrix.
- Shared CTAs, one component per file: PurchaseButton ("Get Base") and
  DownloadLatestButton open the localized product page (purchaseUrl) and
  need no router; HaveKeyButton renders only inside BrowserRouter.
- UpsellGate uses them; the purchase link never calls onNavigate, so
  modal hosts stay open. ProfileFormModal reuses UpsellGate instead of
  its compact panel, keeps its Cancel button, and closes after "I
  already have a key" (onNavigate={onClose}).
- In Free, the Updates card offers Get Base, I already have a key and
  Download the latest version; the error page (outside the router)
  offers Get Base and the download link. No update check, no network
  call.
- i18n FR/EN: Account card text, Premium note, Updates notEntitled text,
  download label, editions guide step and tip; the "coming soon" note
  key is gone. Tests pin the upsell key set and the decided texts.
- Docs: ADR 0017 amendment, architecture (UpsellGate row, shared CTAs,
  upsellCtaFor, multi-profile row, counts), user guide section 12;
  CHANGELOG FR/EN.

Proven red: Premium made purchasable 2, balance sold as Base in the
matrix 4, the old note key back in a locale 1, Account text altered 1,
download label altered 1.

Fixes #346

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:50:45 -04:00
le king fu
a051b96c87 fix(license): apply the review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
PR Check — Rust / rust (pull_request) Successful in 8m48s
- A 404 without a known code (missing route, proxy error page) is now
  pinned as a server error by two table rows, so "key not found" can
  never be shown for it.
- The message-key test spells out the expected key of every code
  instead of comparing the map with itself, and checks the keys are
  distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
  AppHandle, tested against a mock server (404 no_active_activation ->
  done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
  (selectCanDeactivate), so a machine deactivated during a reload
  cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
  rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
  activation. The edition does not change with or without an
  activation token, so nothing is observable; refresh() stays.

Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:27:43 -04:00
le king fu
7d667f7ca3 fix(license): call /v1 routes with product, name server refusals, localize purchase URL
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m40s
PR Check — Rust / rust (pull_request) Successful in 8m52s
- Activation, deactivation and verify POST to /v1/licenses/* with
  "product":"simpl-resultat" instead of the legacy /licenses/* paths,
  which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
  read from error.code of the /v1 envelope. A 200 {valid:false} from
  verify is now a coded refusal instead of an empty machine list, and an
  HTML error page on a non-2xx maps to server instead of
  invalid_response. Deactivation is idempotent: no_active_activation
  counts as done.
- The three calls live in *_inner functions without AppHandle, tested
  against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
  survives the list load it triggers (machine_limit_reached opens the
  list), a deactivation removes the machine locally without re-running
  verify (4 requests on the machine-limit path, under the server's
  5/min/IP limit), and refresh() follows every successful activation or
  deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
  /{fr|en}/produits/simpl-resultat.

test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.

Fixes #270

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

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

Resolves #331

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

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

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

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

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

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

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

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

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

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

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

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

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

Also here:

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

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

Resolves #324
2026-08-13 13:16:52 -04:00
le king fu
554373e7d8 feat(gating): UI guard — RequireFeature + UpsellGate + i18n
All checks were successful
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m28s
Add the reusable gating guard components on top of the #297 foundation:

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

Resolves #298

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

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

Resolves #297
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:05:10 -04:00
le king fu
2314a64213 feat(categories): merge custom categories into the standard taxonomy (#259)
Custom categories with no standard match were shown as read-only text in
the migration wizard and force-parented under a catch-all bucket. They now
get the same inline target picker as seeded rows: picking a standard leaf
merges the custom category — its transactions, budgets, keywords and
suppliers are reassigned to the leaf — and deactivates it. Leaving a custom
unmapped keeps the previous behaviour and never blocks the wizard.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:43:47 +00:00
le king fu
a982f9ed9d Merge issue-279 (dashboard Cartes convergence) into main
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-11 21:22:29 -04:00
le king fu
9ee5ad353f fix(reports): thread accountIds into Cartes KPI + seasonality series
All checks were successful
PR Check / rust (pull_request) Successful in 21m29s
PR Check / frontend (pull_request) Successful in 2m24s
getCartesSnapshot forwarded the account filter only to the top-movers and
budget sub-reports, leaving fetchMonthlyFlows (KPIs, sparklines, 12-month
overlay) and fetchSeasonality unfiltered. The Dashboard is the first page to
expose the account filter over these series, so its KPI cards showed
unfiltered totals while the category bars / trend / top-movers respected the
filter — figures that did not reconcile on the same screen.

Thread an optional accountIds through both fetchers (parameterized
`source_id IN (...)` via inPlaceholders) and pass it from getCartesSnapshot,
so the whole dashboard honours the filter — which is what the #279 CHANGELOG
entry already promises. No CHANGELOG change: the code now matches the note.

Resolves #279

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:16:09 -04:00
le king fu
193ef1d9dd feat(dashboard): converge home page on the Cartes report model
All checks were successful
PR Check / rust (pull_request) Successful in 20m42s
PR Check / frontend (pull_request) Successful in 2m22s
DashboardPage now reuses the Cartes report's presentational widgets
(KpiCard with MoM/YoY deltas, top movers, budget adherence) sourced from
getCartesSnapshot against a Dashboard-owned reference month (defaults to
the last complete month). The expense-only pie chart is replaced by a
ranked bar chart of top expense categories (resurrecting the previously
unused CategoryBarChart), and the account (import-source) filter
introduced on Trends/Compare/Budget now also applies to the Dashboard's
own transactional widgets via a Dashboard-local accountIds state (kept
separate from useReportsPeriod, since the Dashboard owns its own two
temporal axes).

A new net-worth tile surfaces the Balance sheet's latest total
(getSnapshotTotalsByDate) — a distinct metric from every transactional
card here, so it stays hidden (never a misleading "$0") until at least
one balance account has a recorded snapshot, reusing deriveLandingState
rather than re-inferring emptiness from nulls. It is not scoped by the
account filter (balance_accounts is a disjoint concept from
import_sources) and is fetched independently on mount.

The category-over-time trend chart now passes typeFilter "expense",
fixing a latent mismatch where a revenue category could silently show
up in a chart titled "expenses over time".

Resolves #279
2026-07-11 17:13:23 -04:00
le king fu
14755eb155 feat(reports): BVA (actual vs budget) income-first + result lines
All checks were successful
PR Check / rust (pull_request) Successful in 20m38s
PR Check / frontend (pull_request) Successful in 2m20s
Align BudgetVsActualTable on the income-statement gold standard already
shipped for the real-vs-real compare report (#253/#256): flip TYPE_ORDER
to income-first (revenue -> expense -> transfer) and replace the flat
"Total" row with interleaved Result before transfers / Net result lines.

Roll-up logic extracted into a new pure, tested module
(budgetVsActualResults.ts) mirroring compareResults.ts, with one sign
difference: BVA amounts already arrive signed the accounting way
(expense actual/budget are negative), so the operating result is a
straight add of income + expense rather than a subtraction.

Resolves #277

Generated autonomously by /autopilot run of 2026-07-11
2026-07-11 16:59:16 -04:00
le king fu
fe9ae0118c feat(reports): adopt shared FilterPanel on Compare and Budget
Mounts <FilterPanel> on ReportsComparePage (next to the existing
CompareReferenceMonthPicker) and BudgetPage (next to the existing
YearNavigator), keeping each page's temporal control unchanged. Threads
accountIds from useReportsPeriod through useCompare into
getCompareMonthOverMonth/getCompareYearOverYear, and through
CompareBudgetView into getBudgetVsActualData — closing the gap where
that sub-tab silently ignored the filter while the rest of the Compare
page respected it.

Budget: the issue named getBudgetVsActualData as the target for
useBudget, but that hook never calls it (it's exclusive to
CompareBudgetView/Dashboard/Cartes) — its only real actuals fetch is
getActualTotalsForYear(year - 1), the previous-year reference column.
Extended that function with the same optional accountIds pass-through
established by #273, rather than wiring in an unrelated call shape.

Both hooks fetch their account checkbox list via getAllImportSources,
mirroring the useTrends/#275 pattern. Empty selection = no filter,
byte-identical to pre-#276 output (regression-tested).

Resolves #276

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:52:21 -04:00
le king fu
b40381fb89 docs(reports): fix stale COMPARE_DELTA_SQL comment references
The constant became the compareDeltaSql() function in the previous
commit (Issue #273); three comments still named the old constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:17:48 -04:00
le king fu
4a93c60ea7 feat(reports): plumb accountIds[] account filter into 7 report services
Give every report service an optional accountIds?: number[] filter that
narrows results to a subset of import sources (transactions.source_id),
matched via a parameterized IN (...) clause — one bound placeholder per
id, never a joined string (CWE-89). New shared inPlaceholders() helper
(src/utils/sqlFilters.ts) generates the placeholder list so the pattern
lives in exactly one place.

New accountIds param: getCompareMonthOverMonth, getCompareYearOverYear,
getBudgetVsActualData, getCartesSnapshot (forwarded to its
getCompareMonthOverMonth + getBudgetVsActualData sub-calls, so the
Cartes dashboard's top-movers and budget-adherence cards respect an
active filter instead of silently ignoring it).

Signature change scalar -> plural: getMonthlyTrends, getCategoryOverTime,
getExpensesByCategory (sourceId?: number -> accountIds?: number[]). No
scalar production caller of these three passed sourceId today, so only
the test call-sites needed updating to the array shape.

Omitted/empty accountIds adds no clause at all, so every service stays
byte-identical to its pre-#273 SQL/results — pinned by a regression test
per service (new budgetService.test.ts / dashboardService.test.ts files,
extended reportService.test.ts / reportService.cartes.test.ts).

This is backend plumbing only: no report page exposes an account filter
control yet (follow-up issues #274-#276 add the shared <FilterPanel> and
wire it into Trends/Compare/Budget).

Resolves #273

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:15:37 -04:00
le king fu
19cdad2718 feat(reports): getCategoryOverTime -> id-keyed trends tree (sidecar) + tree-based results
Adds an id-keyed hierarchical `tree: OverTimeRow[]` sidecar to
getCategoryOverTime, built via the generic buildLeafDrivenTree (#263) as
buildOverTimeTree — leaf-driven, income-first, no top-N / no "Other" bucket.
The name-keyed pivot (data/categories/colors/types/categoryIds) is left
BYTE-IDENTICAL, so the Trends chart and the dashboard render unchanged.

computeOverTimeResults now consumes the id-keyed tree LEAVES (never the
`is_parent` subtotals) grouped by each leaf's category_id-resolved type, so
the section subtotals and the Result-before/net lines are exact: two homonym
categories of different types no longer collide, and a non-top-N income or
transfer category is no longer lumped into "Other" as an expense.

CategoryOverTimeTable renders the tree leaves (every category, id-safe)
instead of the top-N pivot; the chart keeps reading the top-N-capped pivot so
its stacked series stay bounded. OverTimeRow mirrors CategoryDelta's snake_case
hierarchy block (parent_id/is_parent/depth/category_type) so it composes with
collapsibleRows / useCollapsibleGroups for the #265 hierarchy work.

Tests: rewrote overTimeResults.test.ts onto the tree (homonym regression +
leaves-only-not-parents); added buildOverTimeTree suite (nesting, income-first,
grand-total invariance, orphan, A->B->A cycle depth guard) and a
getCategoryOverTime tree-wiring test proving the pivot stays top-N+Other while
the tree carries every category. 720 vitest green, build + tsc green.

Resolves #264

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:30:50 -04:00
le king fu
5a1ce42034 refactor(reports): extract a generic leaf-driven tree builder
All checks were successful
PR Check / rust (pull_request) Successful in 23m41s
PR Check / frontend (pull_request) Successful in 2m27s
Factor the hand-rolled hierarchy walk out of buildCompareTree into a
generic buildLeafDrivenTree<T>(leaves, opts): the shared skeleton (leaf/
orphan split, ancestor "relevant" set, DB-order adjacency, buildNode
recursion with the MAX_TREE_DEPTH guard + loop-break, magnitude sibling
ordering, orphan append, contiguous income->expense->transfer section
sort) now lives in one place, parameterised by injection points
(categoryIdOf, makeLeaf, makeSubtotal, decorateDirectLeaf, makeOrphan,
sortKey, isSubtotal, sectionOf, sectionOrder, maxDepth).

buildCompareTree becomes a thin specialisation supplying only the delta
arithmetic; output is byte-identical (compare tests unchanged as the
non-regression guard). Exported (with TreeCatMeta / TreeSectionType /
LeafDrivenTreeOptions) so the trends tree reuses the exact same skeleton.

Behavior-preserving refactor: no user-facing change, no CHANGELOG entry,
no DB migration.

Resolves #263

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:07:20 -04:00
le king fu
450229522b polish(reports): drop dead category_type projection from the monthly query (#256 review)
getCategoryOverTime's monthly pivot selected COALESCE(c.type,'expense') AS
category_type but nothing read it — the `types` map is built from the top-N
query. Remove the unused projection and its row-type field. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:55:16 -04:00
le king fu
44976767d1 Merge PR #257: feat(reports) trend-by-category = income statement (#256)
# Conflicts:
#	.gitignore
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-05 20:53:11 -04:00
le king fu
82040abc74 feat(reports): category-over-time table as result analysis (sections + net result per month)
All checks were successful
PR Check / rust (pull_request) Successful in 22m9s
PR Check / frontend (pull_request) Successful in 2m28s
Apply the compare report's "result analysis" presentation to Trends -> By
category (table view). Categories are grouped into Income -> Expenses ->
Transfers sections with per-month and total subtotals; the old mixed "Total"
row (which summed absolute magnitudes into a meaningless figure) is replaced by
a Net result row per month (income - expenses + transfers) plus a Result before
transfers row shown only when transfers exist, both coloured by sign. Category
cells stay neutral levels.

- reportService.getCategoryOverTime: project COALESCE(c.type,'expense') in both
  SELECTs and return an additive `types` map (built from the top-N rows, like
  `colors`). Dashboard widget and CategoryOverTimeChart are untouched.
- shared types: add `types` to CategoryOverTimeData.
- new pure module overTimeResults.ts (computeOverTimeResults) with unit tests -
  the per-month result reducer, mirroring the compare's pure Totals helper.
- i18n: add reports.compare.resultNet / resultBeforeTransfers (FR+EN); reuse the
  existing sections.* and total* keys.
- .gitignore: anchor the autopilot `reports/` rule to `/reports/` so it no
  longer swallows new files under src/components/reports/.

Section order is income-first (Revenus -> Depenses -> Transferts), per the
issue; the existing COMPARE_TYPE_ORDER is expense-first, so a local
OVER_TIME_TYPE_ORDER is used rather than reusing it.

Resolves #256

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:15:50 -04:00
le king fu
c7914c2ca5 fix(reports): exclude income from Cartes top movers (#253 regression)
All checks were successful
PR Check / rust (pull_request) Successful in 22m23s
PR Check / frontend (pull_request) Successful in 2m25s
/pr-review on #255 caught a cross-consumer regression: broadening
COMPARE_DELTA_SQL to surface income (this PR) also feeds getCartesSnapshot,
whose "top movers" card is a spending view (up = red). A salary rise would land
under "biggest increases" coloured red — inverted meaning.

Filter significantMovers to expense leaves ((category_type ?? "expense") ===
"expense"), mirroring ComparePeriodChart. The surviving expense output is
byte-identical to pre-#253. Add a Cartes regression test (an income category
with the biggest delta must not top the movers list) — the existing test mocked
the compare SQL without category types, so it stayed silently green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:14:23 -04:00
le king fu
f45845e408 feat(reports): real-vs-real compare as an income statement + previous-month default
All checks were successful
PR Check / rust (pull_request) Successful in 23m4s
PR Check / frontend (pull_request) Successful in 2m26s
The "actual vs actual" compare was expenses-only (WHERE amount < 0), so revenue
categories were invisible — you could not tell a surplus from a deficit. Make it
an income statement (the product's namesake "résultat"):

- COMPARE_DELTA_SQL: broaden the WHERE and per-type CASE to include `income` as
  a signed SUM (like `transfer`), so revenue credits survive the outflow filter.
  Expenses stay ABS-of-outflows, byte-identical. Transfer netting (#243) intact.
- Section order is now income -> expense -> transfer (COMPARE_TYPE_ORDER).
- ComparePeriodTable: replace the now-meaningless flat grand total with two
  result lines — "result before transfers" (revenues - expenses) and the net
  "result" (after transfers) — extracted into a pure, tested compareResults
  module. Delta colours are direction-aware (income/result up = green, spending
  up = red); result amounts are coloured by sign (surplus/deficit).
- ComparePeriodChart stays a spending view: filter to expense leaves so revenue
  bars don't mix into the same axis.
- useCompare: skip the initial period-sync so the compare opens on the previous
  (last complete) month instead of the civil-year December that useReportsPeriod
  yields by default.

Also anchor the `reports/` gitignore to `/reports/` — the unanchored rule was
silently ignoring new files under src/components/reports/.

Tests: new compareResults.test.ts (result roll-up, unbalanced transfer, deficit,
subtotal exclusion); reportService.test.ts updated for the broadened SQL, the
income bucket rule, and income-first section order. Build + 685 vitest green.

Resolves #253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:36:57 -04:00
le king fu
f3e8e94b16 feat(reports): hierarchical real-vs-real compare with subtotals
Render the real-vs-real comparable report as a parent/child category tree
with subtotal rows, mirroring the budget report — rows grouped into
expense/income/transfer sections with per-section and grand totals and a
subtotals-on-top/bottom toggle. The compare service now builds the tree on
top of the flat per-category deltas, so leaf-category figures are unchanged
and the #243 transfer netting is preserved (a balanced transfer group
subtotals to ~0, i.e. the group's net).

- reportService: buildCompareTree() synthesizes subtotal rows from the flat
  leaves + category metadata; getCompareMonthOverMonth/YoY return the tree
  (COMPARE_DELTA_SQL and rowsToDeltas untouched).
- CategoryDelta gains optional parent_id/is_parent/depth/category_type.
- ComparePeriodTable: sections, depth indentation, reorderRows toggle,
  section/grand net totals.
- Cartes top-movers and ComparePeriodChart filter to leaf rows only.
- reorderRows constraint loosened to the two fields it reads.
- i18n (FR+EN) section labels; CHANGELOG entries.

Resolves #247

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:32:38 -04:00
le king fu
4b000d022f fix(reports): net transfer-type categories in real-vs-real compare
All checks were successful
PR Check / rust (pull_request) Successful in 23m23s
PR Check / frontend (pull_request) Successful in 2m25s
Reports > Compare (real vs real) summed only outflows (SUM(ABS(amount))
filtered on amount < 0), so a `transfer` category like "Paiement CC" -
whose debit and matching credit should cancel - always showed the sum of
its debits instead of ~0.

Net transfer-type categories via a signed SUM(t.amount) while keeping the
expense behavior (ABS + amount < 0) for every other type, and broaden the
WHERE so transfer credits survive the expense filter. Both MoM and YoY now
share a single COMPARE_DELTA_SQL constant so they stay in sync. Real-vs-
budget already nets (signed SUM, no filter) and is unchanged.

Resolves #243

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:08:17 -04:00
le king fu
8cb7e53698 fix(db): serialize DB access to fix "database is locked"; live-update log console
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s
Database lock (the reported 0.10.0 regression):
- tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
  (Pool::connect => max_connections = 10) and exposes no JS transaction
  primitive. BEGIN and COMMIT issued as separate db.execute calls could land on
  different pooled connections, stranding an open write transaction on an idle
  connection — a permanent write lock until app restart. Triggered when the
  Bilan page's concurrent reads interleaved with an in-flight snapshot save.
- Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction
  holds it across the whole BEGIN..COMMIT so a transaction's statements never
  span connections (and the saves become genuinely atomic). A reentrancy guard
  lets nested getDb() calls run directly instead of deadlocking. Applied to all
  5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines,
  proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via
  in-place helper extraction. New db.test.ts covers serialization,
  cross-transaction non-interleaving, reentrancy, and lock release on error.

Log console (Settings -> Systems -> Journaux):
- getLogs returned the same mutated array reference, so useSyncExternalStore
  (identity comparison) never re-rendered on a new log -> the console never
  updated live. getLogs now returns an immutable snapshot rebuilt on each
  mutation (stable between mutations, new identity after each).
- Add logInfo/logWarn/logError app-logging API and instrument the snapshot save
  (info on success, error on failure) so DB issues surface in the console.

tsc + 635 vitest + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:50:39 -04:00
le king fu
76ddad66c9 feat(balance): per-security drill-down + latent gain in accounts table (#216)
Surface unrealized (latent) gain on the existing balance surfaces, no new
visualization (decision 2026-06-04).

Service:
- AccountLatestSnapshot gains account `kind` + `latest_snapshot_line_id`, so
  the table knows which rows are detailed and where their holdings live.
- getAccountLatentGainByLine(lineId) folds a line's holdings through the
  existing computeUnrealizedGain (shares the book_cost=0 / NULL -> N/A guard).
- rollupLatentGain(accounts): pure aggregation by asset class, by envelope
  (vehicle_type, 'none' bucket), and grand total; per-bucket % denominator is
  the known book_cost only (null when none), mirroring the aggregate guard.

Hook (useBalanceOverview):
- Prefetches per-detailed-account latent gain in parallel (failures isolated),
  exposes latentGainByAccount + latentGainRollup. Simple accounts skipped.

UI:
- BalanceAccountsTable: expandable detailed rows -> per-security value + latent
  gain %; a latent-gain column (shown only when a detailed account has one);
  a summary block aggregating latent gain by asset class / envelope.
- BalanceOverviewCard: total latent gain line (hidden without detailed accounts).
- N/A rendered (never divide-by-zero) when book_cost is NULL or 0; partial-%
  flag when some positions lack a cost basis.

Native non-CAD currency display de-scoped this round (untestable while all
securities are CAD). Modified Dietz return columns (#204) unchanged.

i18n: balance.latentGain.* in FR + EN. Focused unit tests for
getAccountLatentGainByLine and rollupLatentGain (grouping, 'none' envelope,
null-% / unknown book_cost, empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:49:57 -04:00