Commit graph

156 commits

Author SHA1 Message Date
le king fu
cdf4df85f7 fix(license): apply the second review of #364
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m15s
- Key entry and machine activation can no longer overlap. An activation
  in flight writes the token of the key it read: a key stored meanwhile
  ended up next to the previous key's token, the dead end 3f90635
  closes. "Enter a key" and the form's submit are disabled while an
  activation runs (LicenseKeyForm gains a busy prop), and "Activate"
  while a key is being stored.
- The "Activated" badge is hidden for a revoked key: the server
  deactivated every machine of the license.
- ADR 0017: the #347 amendment also points at section 1, which still
  mentions the machine-binding downgrade.

Refs #347

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:44:51 -04:00
le king fu
3f90635298 fix(license): apply the review of #364
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
PR Check — Rust / rust (pull_request) Successful in 10m35s
- store_license_in drops activation.token when the stored key changes.
  The token names the previous license to the price server (its sub is
  the license id, the key carries the email), so the card showed the
  machine as activated while prices were refused for the old license.
  The same key entered again keeps its token; a refused key changes
  nothing.
- get_activation_status goes through activation_status_in(dir,
  machine_id, key), tested in a temporary directory: a missing,
  expired, foreign-machine, foreign-key or unreadable token reads as
  not activated. It alone decides "Not activated" and "Activate".
- LicenseCard is keyed by licenseIdentity(info): another key remounts
  the body, which re-reads the activation and drops the previous key's
  machine list.
- invalidSignature asks to check the copy first. jsonwebtoken checks
  the signature before decoding the payload, so a damaged copy often
  reads as a signature failure (characterization test). User guide and
  changelogs aligned.
- Render tests: the other errors stay visible under Machines, and the
  edition's green check gives way to an alert for a revoked key.

Refs #347

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:58:22 -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
170b22d356 fix(upsell): apply the review of #362
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m39s
- Render tests for the upsell CTAs (UpsellCtas.render.test.tsx): the
  reviewer's probe, which needs no jsdom (react-dom/server in the node
  environment, plus the FilterPanel.test.tsx pattern of calling a
  component as a function). It pins the Base/Premium CTA on UpsellGate,
  the router guard of HaveKeyButton, the localized product URL, the
  Premium note and the onNavigate forwarding, none of which the suite
  guarded before.
- A failure to open the browser (openUrl) is now logged through
  logError in PurchaseButton, DownloadLatestButton and LicenseCard,
  instead of vanishing as an unhandled rejection that never reaches the
  in-app logs. Tested for both shared buttons.
- upsellCtaFor: comment the trap in making Premium purchasable, since
  PurchaseButton sells Base only.
- ADR 0017 amendment: the Account card text holds in practice only;
  current_entitlements still grants Premium to an active subscription,
  a dormant path until a subscription is sold.
- User guide and in-app docs: the purchase step now comes before the
  key entry, and the Balance button is named "Get Premium".
- CHANGELOG: "Maximus Account" in English, "every screen that requires
  it" in both files (the Balance screen sells nothing), and the French
  elision before a guillemet.

Refs #346

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:47:04 -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
b8e67c3d4f fix(license): apply the second review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 8m54s
- The idempotent-deactivation test now pins the route's other 404:
  license_not_found stays an error. The previous negative case was a
  403 that the deactivate route never returns, so swallowing
  license_not_found (an unknown key shown as deactivated, the local
  token deleted) went unnoticed.
- A test deactivates this machine and checks that the re-read activation
  status brings the Activate button back. Removing that re-read used to
  leave the suite green.
- The Activate button stays disabled while the list loads
  (selectCanActivate), the counterpart of selectCanDeactivate: a verify
  answer landing after a successful activation can no longer mark as
  fresh a list that misses this machine.
- CHANGELOG: the old /simpl-resultat address is redirected by the site
  since la-compagnie-maximus#153, so the "Buy" bullet now says the
  button opens the product page directly, in the app's language. The
  test comment is moved to the past tense.

Proven red on the fixed tree: idempotence swallowing license_not_found
1, no status re-read after deactivation 1, Activate live during a list
load 1.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:09:01 -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
9abf4743e5 docs(changelog): scope the "no behaviour change" claim to the app's own code
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 8m57s
Review note on #349: the entry said "no behaviour change", which holds
for this application's code but not for the TLS layer it links. rustls
0.23.45 closes a handshake that breaks RFC 8446 section 5.1 where 0.23.36
accepted it — that change of behaviour is the fix itself, and saying
otherwise understates what a reader is being told.

Changelog text only, in both languages. The lockfile is untouched, so
the CI evidence gathered on run 412 still stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:24:10 -04:00
le king fu
8ea3410c82 fix(deps): update rustls to 0.23.45 to clear RUSTSEC-2026-0285
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m17s
The daily audit has failed every morning since 2026-09-15 (runs 405 to
411, 7 consecutive reds). Nothing was pushed to main in that window —
the last commit is 97ee3c3 from 09-13. RUSTSEC-2026-0285 was published
on 09-14, after that morning's run, and the first red is the next day's.
Rustls accepted TLS 1.3 handshake messages sent at the wrong encryption
level when they followed a key-changing message inside the same record,
where RFC 8446 section 5.1 requires closing the connection instead.
Medium severity, confidentiality only: the handshake transcript stays
authenticated, so this cannot be used to alter or complete a handshake
from a network position. Patched in 0.23.45.

rustls is reachable on both shipped targets, checked per triple rather
than with --target all, by a single path: tauri-plugin-updater ->
reqwest 0.13.2, the automatic update check and download. That is
narrower than h2 in #343 — the app's own reqwest 0.12.28 goes through
hyper-tls and native-tls, so the licence, auth, price and feedback calls
were never on this path. A published fix plus a reachable crate is rule
1 of .cargo/audit.toml, so this gets fixed rather than suppressed; the
suppression list and the check-rust guard are both untouched.

rustls-webpki moves with it, 0.103.13 to 0.103.15, because 0.23.45
tightens its bound from ^0.103.5 to ^0.103.14. That is required rather
than collateral, and it keeps the four advisories #310 cleared by
pinning 0.103.13.

Unlike #344, no stanza was hand-written: `cargo update -p rustls
--precise 0.23.45` left the seven windows-sys pointers on 0.61.2
untouched, so cargo's own output was kept. That also narrows the premise
of #345 — the drift is triggered by hyper and what it pulls, not by the
TLS subtree at large, since rustls sits squarely in it and moved
nothing. 666 packages before and after, Cargo.toml untouched, and
`cargo metadata --locked` accepts the result without rewriting it.

Verified: cargo check --locked and cargo test --locked green (111
passed), cargo-audit 0.22.2 exits 0 with 12 allowed warnings, and the
same binary still exits 1 on the previous lockfile — so the green is a
real change of state, not a differently configured tool. Both checksums
match the sha256 of the downloaded .crate archives, and compiling rustls
0.23.45 and rustls-webpki 0.103.15 proves those archives were the ones
actually used.

Resolves #348

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:09:39 -04:00
le king fu
920dedde10 chore: release v0.15.0
All checks were successful
Release / build-and-release (push) Successful in 22m32s
Bump 0.14.0 -> 0.15.0 in the five manifests, open the 0.15.0 section in
both changelogs (17 entries: edition-based feature gating, the CSV import
rework with migration v17, three dependency advisory batches), and fix the
stale "Version actuelle" line in CLAUDE.md (was 0.6.3).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013CNa9VVF5C3y47MX1SSJ1S
2026-09-12 14:33:37 -04:00
le king fu
5154c5276b fix(deps): update h2 to 0.4.19 to clear RUSTSEC-2026-0258
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 10m1s
The daily audit has failed every morning since 2026-08-19 (runs 375 to
391, 17 consecutive reds). Nothing was pushed to main in that window:
RUSTSEC-2026-0258 was published on 08-17 and reached the advisory-db two
days later. Empty HTTP/2 DATA frames are queued without limit, so an
undrained stream grows memory without bound and can overflow into a
panic. Low severity, denial of service, patched in 0.4.16.

h2 is reachable on both shipped targets, checked per triple rather than
with --target all: through tauri-plugin-updater -> reqwest 0.13.2 for
the automatic updater, and through the app's own reqwest 0.12.28 for the
licence, auth, price and feedback calls. A published fix plus a
reachable crate is rule 1 of .cargo/audit.toml, so this gets fixed
rather than suppressed; the suppression list and the check-rust guard
are both untouched.

Only the h2 stanza moves. Running `cargo update -p h2` also re-resolves
seven windows-sys pointers from 0.61.2 down to 0.48/0.52/0.59, which is
what #321 deliberately reverted three weeks ago after proving 0.61.2 is
the state of the v0.14.0 tag and therefore the only windows-sys
configuration that has actually compiled under Windows. Letting the
re-resolution stand would regress that decision silently, so the bump is
applied to the lockfile as a two-line change instead. `cargo metadata
--locked` accepts the result without rewriting it.

Verified: cargo check and cargo test --all-targets green (111 passed),
cargo-audit 0.22.2 exits 0 with the same 22 allowed warnings as before,
and the same binary still exits 1 on the previous lockfile — so the
green is a real change of state, not a differently configured tool.

Resolves #343

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhPofpLQTZUbkVYR3i2e2D
2026-09-04 15:44:29 -04:00
le king fu
2d4caecae8 fix(deps): resolve the quick-xml advisories, unyank deep-link and spin
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m23s
The removal trigger #312 was written for had already fired — I filed the issue
without checking whether a newer plist existed. plist 1.10.0 ships quick-xml
0.41.0, which carries the fix, within tauri's existing bound:

    cargo update -p plist -> plist 1.8.0 -> 1.10.0
                             quick-xml 0.38.4 -> 0.41.0

So RUSTSEC-2026-0194 and -0195 are resolved rather than accepted, and leave
.cargo/audit.toml the day they entered it. rsa is now the only entry, and the
guard loops on that crate alone; its rationale comment is re-pointed
accordingly, since it was written entirely around quick-xml/plist.

Also bumps the two yanked crates (#313). tauri-plugin-deep-link 2.4.8 -> 2.4.9:
upstream's 2.4.9 is a single commit, "Fix broken iOS custom URL schemes", so
the defect behind the yank is iOS-only and never reached this desktop app —
v0.14.0 shipping 2.4.8 was not a user-facing problem, which is why neither
Security nor Fixed applies to it in the changelog. spin 0.9.8 -> 0.9.9; every
0.9.x up to 0.9.8 is yanked, which reads as a bulk yank rather than a defect.

The #310 changelog bullet is amended rather than contradicted: it sits in the
same unreleased section and would otherwise ship two opposing claims in the
same release notes. Two of its statements were wrong. It said three advisories
remained (now one), and it said tar sits on "real code paths in the shipped
app" — tar is compiled, but its vulnerable extraction path is only reached by
the AppImage and macOS installers this project does not bundle. The
rustls-webpki half stands: TLS runs on every update check.

ADR 0018's decision is untouched; an amendment header marks the passages that
are now historical, including the "override is impossible" alternative, which
plist 1.10.0 made false the same day.

cargo audit from the repo root: 0 vulnerabilities, warnings 23 -> 21 (the two
yanked ones). From src-tauri/ it reports 1 — that is the cwd sensitivity of
.cargo/audit.toml, not a regression. Guard: 2 checks + canary, exit 0.
cargo check + cargo test green (106 tests). Lock diff: 4 packages, 666 before
and after.

Resolves #312
Resolves #313

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:51:46 -04:00
le king fu
e2b8eb8b22 docs: architecture, ADR 0019, user guide and changelog for the import format
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m38s
Last link of the ten-link import-format stack. Links 1-9 deliberately wrote
no changelog and no documentation, to avoid a conflict at every level of a
linear pile; this link owes all of it.

- ADR 0019 records the structuring decision: the import format is a fully
  persisted value, never re-inferred. It documents the three independent paths
  by which it used to be lost (hardcoded restore, header drift, data export),
  and why a single codec with a completeness test closes the class rather than
  a composed type -- the two carriers are structurally incompatible
  (`has_header` is boolean on one and number on the other). `template_id` is
  recorded as a provenance label, never re-read as format.
- `docs/architecture.md` gains a dedicated "Import CSV" section covering the
  codec, the lexical detection layer and its separate dictionary module, the
  bank signatures, the now-mandatory preview step, and sources/templates in
  the SREF envelope. Migration v17 and its four CHECK-guarded columns are
  listed in the migrations table.
- Stale counts corrected against the tree, not by arithmetic: 16 -> 17
  migrations (both files), `src/components/import/` 13 -> 14, `src/utils/`
  4 -> 13. Tables (20) and indexes (24) were re-measured and are NOT stale --
  v17 is an ALTER TABLE only -- so they are left as they are, with the reason
  written down. ADR 0018, missing from the ADR table, is added.
- The user guide and the `docs.*` keys in both locales carry the same new
  import journey: automatic detection, confidence score, mandatory preview
  with its signed recap, sign inversion, recognised bank formats, drift panel,
  and the safe repair path.
- Both changelogs carry the same entries, translated, verified section by
  section including issue references. The `public/` copies sync automatically
  via `syncChangelogs()`.

1176 vitest green, build clean, cargo check clean. No DB migration.

Resolves #332

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:13:25 -04:00
le king fu
a14258b147 fix(deps): update postcss to 8.5.23, accept the react-router advisory
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
npm update postcss moves it 8.5.13 -> 8.5.23, clearing GHSA-r28c-9q8g-f849
(path traversal in previous-source-map auto-loading via a sourceMappingURL
comment, arbitrary .map disclosure, 7.5 high). No overrides entry needed,
unlike #241: vite declares postcss ^8.5.3 and 8.5.23 is published, so the
existing range already permitted the fix and only the lockfile carried a
stale resolution. nanoid 3.3.11 -> 3.3.16 comes along as postcss's own
dependency, within its declared range.

postcss IS the CSS pipeline, so a green build only proves compilation. The
emitted stylesheet was diffed across the bump and is byte-for-byte identical
(same content hash, same asset filename).

The remaining react-router advisory (GHSA-qwww-vcr4-c8h2, RSC Mode CSRF
bypass) is accepted rather than fixed. It targets React Server Components,
which a Tauri desktop app never runs — App.tsx mounts a client-only
BrowserRouter and src/ has no createStaticHandler, StaticRouter or server
rendering. There is also nothing to move forward to: react-router-dom is
frozen at 7.18.1 since v8 merged the package into react-router, so npm's
proposed "fix" is a downgrade to 7.11.0, and leaving the affected range
means migrating to react-router v8. Re-evaluation trigger tracked in #317.

Unlike the Rust side, no CI gate is involved: check-frontend.yml runs no
npm audit step, so nothing turns red. That expectation is now written down
in docs/architecture.md and CLAUDE.md so the two permanent high findings do
not read as a regression.

npm audit: 3 findings -> 2 (high 3 -> 2), postcss cleared.
npm ci + npm run build + 871 vitest green.

Resolves #311

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:00:11 -04:00
le king fu
f4b09b028e fix(deps): clear 6 reachable RustSec advisories, accept 3 unreachable ones
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m38s
PR Check — Rust / rust (pull_request) Successful in 8m40s
cargo update -p rustls-webpki -p tar moves rustls-webpki 0.103.9 -> 0.103.13
and tar 0.4.44 -> 0.4.46, both within the existing Cargo.toml bounds. They sit
under tauri-plugin-updater, which downloads and unpacks application updates, so
all six of their advisories were reachable in the shipped binary.

The remaining three can neither be fixed nor reached. quick-xml (2x 7.5 high)
is pulled by plist, which tauri only needs for Apple bundling: its per-target
trees are empty for both shipped targets and it appears solely under
x86_64-apple-darwin. Its fix is >= 0.41.0 while plist requires ^0.38, a
semver-incompatible boundary [patch.crates-io] cannot cross. rsa has no
published fix at all and is never compiled — its only parent is sqlx-mysql, an
artifact of sqlx's multi-backend graph on a SQLite project.

Leaving those three to red the daily gate forever would reproduce the signal
loss that #232 removed the `|| true` to fix, so they move into a versioned
.cargo/audit.toml. Entries are keyed by advisory ID, never by crate, so a new
advisory against the same crate still reds the gate; each carries its
reachability proof and its removal condition.

A blocking step in check-rust.yml re-proves that justification on every PR
touching src-tauri/ or .cargo/, and fails if a suppressed crate enters a
shipped target's graph — the scenario that would rot the list is itself a
src-tauri change. It separates cargo tree's exit status from its output (an
absent crate and a failed invocation both print nothing) and asserts a canary
crate is still found, so its silence proves something.

cargo audit: 9 vulnerabilities -> 0, warnings unchanged at 23
(cargo-audit 0.22.2, advisory-db 0bfde9d6 of 2026-07-27).
cargo check + cargo test green (106 tests); npm build + 871 vitest green.

Resolves #310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:45:15 -04:00
le king fu
01da65c215 docs(gating): ADR 0017 + architecture + user guide + CHANGELOG
Document the edition-gating work (#297-#301):

- ADR 0017 (accepted): tier->features matrix, signed features[] override
  fail-closed in Free (CWE-863), UI-only enforcement as an assumed GPL
  soft-paywall (server-enforced price fetching stays the only hard gate),
  non-destructive downgrade, dev-override behind an explicit Cargo
  feature (CWE-489), rejected alternatives.
- architecture.md: new 'Gating par edition' section (entitlements matrix,
  LicenseContext, useEntitlement, RequireFeature/UpsellGate, NavLock,
  profileGate, Rust side), rewritten entitlements.rs section (auto-update
  now Base+, stale 'open to free' note removed), gated routes listed in
  the routing section, hooks table updated (useLicense removed in #297 ->
  useEntitlement/useIsPremium), ADR index + header refreshed.
- guide-utilisateur.md + docs.editions.* i18n keys (FR/EN) wired into
  DocsContent: new 'Editions' section with the Free/Base/Premium table,
  unlock flow and non-destructive locking tips.
- CHANGELOG.md + CHANGELOG.fr.md: one global [Unreleased] entry listing
  the modules now gated Base (Budget, Adjustments, advanced reports,
  multi-profile, auto-update) and Premium (Balance), the visible-but-
  locked upsell with disabled 'coming soon' purchase CTA, and the
  data-preserving behaviour.

Resolves #302

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:48:55 -04:00
le king fu
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
9c18e10281 chore: release v0.14.0
All checks were successful
Release / build-and-release (push) Successful in 24m21s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:56:17 -04:00
le king fu
9f628aa9f4 refactor(categories): unify both category trees' collapse state onto useCollapsibleGroups
Replace the two hand-rolled Set-of-ids collapse state machines in the category
trees with the shared useCollapsibleGroups hook (a strict superset after #288),
keeping each tree's distinct recursive render and CategoryTree's drag-and-drop
untouched.

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

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

Resolves #290

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

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

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

Resolves #289

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

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

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

Resolves #288

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:58:18 -04:00
le king fu
9a88e32a06 chore: release v0.13.0
All checks were successful
Release / build-and-release (push) Successful in 23m25s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:12:10 -04: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
6dbaaa25fe Merge issue-277 (BVA income-first) into main
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-11 21:21:20 -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
ab67c68605 feat(budget): flip grid to income-first + add Résultat rows
All checks were successful
PR Check / rust (pull_request) Successful in 21m4s
PR Check / frontend (pull_request) Successful in 2m20s
Align BudgetTable on the income-statement standard shipped for the
compare/trend reports: sections now read Income -> Expense -> Transfer
(useBudget's TYPE_ORDER and BudgetTable's typeOrder both flipped), and
the previously unlabeled grand-total row is replaced by two interleaved
result rows (Result before transfers / Net result), computed by a new
pure, tested module (budgetTableResults.ts) mirroring compareResults.ts's
shape. Roll-up covers the previous-year-actual, budgeted-annual, and
budgeted-monthly columns alike.

All categories remain displayed (the grid stays a full edit surface) --
no collapse, no empty-row toggle, matching this issue's frozen decisions.

Resolves #278
2026-07-11 16:59:01 -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
30682004d3 feat(reports): adopt shared FilterPanel on the Trends page
Mounts <FilterPanel> (from #274) on ReportsTrendsPage next to the period
selector, and threads accountIds from useReportsPeriod through useTrends
into getMonthlyTrends/getCategoryOverTime (both already accountIds-aware
since #273). The account checkbox list reuses the same getAllImportSources
query as the Transactions single-source filter. Empty selection = no
filter, byte-identical to pre-#275 output.

Resolves #275
2026-07-11 15:41:13 -04:00
le king fu
70b2cd2e42 feat(reports): shared FilterPanel component (temporal slot + account multi-select)
Adds src/components/reports/FilterPanel.tsx, the shared filter bar for the
report pages. It renders the page's own temporal control (PeriodSelector /
CompareReferenceMonthPicker / YearNavigator / …) as-is via a `temporalControl`
ReactNode prop, plus a checkbox multi-select over `accounts: ImportSource[]`.
No `temporalMode` enum — that design was rejected to avoid mixing state
sources; the panel owns no temporal state at all.

Empty `accountIds` means no filter (all sources). Selection toggling is a
pure, exported `toggleAccountId` helper. Copy says "sources"/"import
sources" everywhere, never "comptes"/"accounts", so the filter reads as
distinct from the Bilan module's own "Compte" vocabulary.

This issue creates the standalone component only; wiring it into report
pages is left to follow-up issues (#275/#276).

Resolves #274
2026-07-11 15:31:58 -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
65be6c7482 feat(reports): extend useReportsPeriod with account filter foundation
All checks were successful
PR Check / rust (pull_request) Successful in 21m28s
PR Check / frontend (pull_request) Successful in 2m22s
Add accountIds: number[] to the shared useReportsPeriod hook, URL-backed
via a dedicated `sources` query param (comma-separated, bookmarkable like
the existing period/from/to). Purely additive: the hook's existing shape
is unchanged, so all 6 consumers (useTrends, useCompare, useCategoryZoom,
ReportsCategoryPage, ReportsPage, ReportsComparePage) keep compiling as-is.

Also adds the shared ReportFilters { period, accountIds } type for
follow-up issues to consume, and exports pure parseAccountIds/
serializeAccountIds helpers (same hookless-testability pattern as
resolveReportsPeriod). URL parsing validates each token as a finite
integer via a strict regex + Number.isSafeInteger, dropping invalid
tokens individually rather than discarding the whole selection.

This is foundation only: no UI control exposes the filter yet, and no
service reads accountIds yet (both land in follow-up issues of the
"rapports uniformes" epic).

Resolves #272

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:01:43 -04:00
le king fu
fe87313ba3 Merge PR #267/#268/#269: tendances hierarchiques (#263/#264/#265) 2026-07-07 20:31:26 -04:00
le king fu
36abad7a2e feat(reports): CategoryOverTimeTable hierarchical + collapse + result interleave
Renders the trends-by-category table from the #264 id-keyed tree as an indented
parent/child hierarchy (parity with ComparePeriodTable): top-level parents carry
a chevron and fold their subtree down to a subtotal, with an "expand all /
collapse all" button. Collapsed by default; expansion state is persisted under a
trends-specific localStorage key (reports-trends-expanded), distinct from the
comparable tables. The "result before transfers" line is interleaved before the
transfers section instead of sitting at the bottom.

Collapse is purely visual: section subtotals, before/net results and totals come
from computeOverTimeResults over the raw tree, never the visible rows.

New pure module overTimeTableModel.ts (section grouping + collapse accessors)
keeps the component thin and unit-testable without a React render harness;
overTimeResults.ts is left untouched. 8 new tests; build + 728 vitest green.

Resolves #265

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:46:35 -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
64ae00a847 bugfix(reports): open Trends on byCategory + table by default
All checks were successful
PR Check / rust (pull_request) Successful in 24m51s
PR Check / frontend (pull_request) Successful in 2m34s
The Trends report previously opened on the global monthly chart, which
has no income section — so a user's revenue (e.g. their pay) was hidden
until they manually switched to the "By category" table view.

Open the report on byCategory + table by default so the income section
(and therefore payroll) is visible immediately. Two scoped call-site
changes:
- useTrends initial subView: "global" -> "byCategory"
- ReportsTrendsPage viewMode fallback: readViewMode(key, "table")

Both defaults are scoped to Trends. The real-vs-real compare report
(distinct "reports-viewmode-compare" storage key) is unaffected, and a
user's own persisted view choice still wins over the new fallback.

Resolves #262

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:58:25 -04:00
le king fu
616cccec37 feat(reports): collapse/expand sub-categories in comparable reports (#254)
All checks were successful
PR Check / rust (pull_request) Successful in 22m52s
PR Check / frontend (pull_request) Successful in 2m24s
The two hierarchical comparable reports — real-vs-real Compare and
real-vs-budget — now let the user collapse or expand each top-level
category's sub-categories. Groups start collapsed by default (#260): only
the parent's subtotal row shows until the user expands it, and each expanded
group is remembered per report.

- New pure module utils/collapsibleRows.ts (visibility filter, group-key
  extraction, expanded-set (de)serialization) + useCollapsibleGroups hook
  wrapping the localStorage-persisted expanded set. Persisting the *expanded*
  set (not the collapsed one) makes "all collapsed" the zero/default state.
- A chevron toggles each top-level parent; an "Expand all / Collapse all"
  button toggles them together. Section subtotals and result lines keep
  summing every leaf, so collapsing never changes any total.
- Accessors mirror each table's own depth/parent logic so hidden rows are
  exactly a group's indented descendants.
- i18n keys reports.collapse.{expandAll,collapseAll} (FR/EN); CHANGELOG.

CategoryOverTimeTable (Trends -> by category) is intentionally left out: its
rows are a flat top-N category list with no parent/child hierarchy to fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:18:52 -04:00
le king fu
f3ccb115ae chore: release v0.12.0
All checks were successful
Release / build-and-release (push) Successful in 25m5s
2026-07-05 20:59:03 -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
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
206f89e1b1 chore: release v0.11.0
All checks were successful
Release / build-and-release (push) Successful in 24m28s
2026-07-05 17:10:00 -04:00
le king fu
9124c888de Merge PR #252: feat(categories) editable migration target with type-ahead (#246) 2026-07-05 17:04:15 -04:00
le king fu
2a3cc07a0e Merge PR #251: feat(balance) CSV import of holdings in detailed snapshot (#245) 2026-07-05 17:03:00 -04:00
le king fu
ead62943fe Merge PR #250: feat(balance) tile-based landing hub + reachable account management (#244) 2026-07-05 17:01:20 -04:00
le king fu
e45736bbde feat(categories): editable migration target with type-ahead (#246)
All checks were successful
PR Check / rust (pull_request) Successful in 22m32s
PR Check / frontend (pull_request) Successful in 2m28s
Make the v1 target editable on EVERY row of the categories-migration
preview (StepSimulate), not only the "needs review" ones. Each row now
renders a reused CategoryCombobox (accent-insensitive type-ahead,
keyboard, hierarchical) in place of the read-only text / flat <select>.

A pure adapter (taxonomyToComboboxCategories) flattens the full v1
taxonomy into Category-shaped rows once in StepSimulate and passes it as
a prop to the rows. The full tree (not just leaves) is fed so mid-tree
default targets such as "Divertissement" (id 1710, the default for
"Jeux, Films & Livres") both display and can be re-picked. The target
cell stops click/keydown propagation so picker interaction never toggles
the row preview. Reducer/guard behavior unchanged: resolving a "none"
row still bumps confidence to medium, and the unresolved-count guard
stays functional.

Resolves #246
2026-07-04 18:17:10 -04:00
le king fu
c8c765e2f0 feat(balance): CSV import of holdings in detailed snapshot
All checks were successful
PR Check / rust (pull_request) Successful in 22m45s
PR Check / frontend (pull_request) Successful in 2m25s
A detailed (by-security) account can now import its positions from a CSV
instead of adding each security one by one. An "Import CSV" button next to
"Add a title" opens a native picker; the delimiter, encoding and columns
(symbol, quantity, optional price + book_cost) are auto-detected via new
csvAutoDetect helpers and adjustable in a small mapping editor. Rows sharing
a symbol are merged (SUM qty + book_cost, first price) and the batch is merged
into the basket by normalized symbol, so no UNIQUE(snapshot_line_id,
security_id) violation can occur at save. A CSV without a price column imports
quantities with an empty unit_price (fetch/type later) — the existing atomic
save path is unchanged (no SQL/Rust change).

- csvAutoDetect: autoDetectHoldingColumns + analyzeHoldingsCsv (+ tests)
- useSnapshotEditor: holdingsFromCsvRows + IMPORT_HOLDINGS action + importHoldings (+ tests)
- HoldingsCsvImportModal: file pick -> parse -> mapping editor + preview
- i18n FR/EN under balance.snapshot.detailed.importCsv.*
- CHANGELOG (Added / Ajouté)

Resolves #245

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:04:12 -04:00
le king fu
82550d6d38 feat(balance): tile-based landing hub + reachable account management
All checks were successful
PR Check / rust (pull_request) Successful in 25m34s
PR Check / frontend (pull_request) Successful in 2m30s
Decouple the /balance empty-state guard into three explicit states via a
new pure helper `deriveLandingState` (empty / accounts-no-snapshot /
populated). Having accounts but no snapshot no longer strands the user on a
snapshot-only onboarding card: each state now renders navigation tiles
(HubReportNavCard, modeled on the Reports hub), and "Manage accounts"
(/balance/accounts) is reachable in every state, including the populated
dashboard.

- Remove BalanceOnboardingCard (component + test) and the dead
  balance.onboarding.* i18n keys; add balanceLanding.ts + test.
- Add balance.hub.* and balance.landing.* keys in FR and EN.
- CHANGELOG (EN + FR) under [Unreleased].

Resolves #244
2026-07-04 17:43:43 -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