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>
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>
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>
The site checkout has been live since 2026-09-29 and the control
purchase of 2026-09-30 proved the chain down to revocation on refund,
so "no tag before the go-live" no longer has a reason to exist: the
desktop release is now a dependency of the go-live issue.
Also records that maximus-api#63 and maximus-api#70 are deployed and
that the terms of sale of la-compagnie-maximus#157 are published, adds
the spec-onboarding-phase1 milestone to the backlog, and moves the
latest decision entry from 2026-10-01 (UTC) to 2026-09-30, the local
merge date used by every other entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The seven issues of the spec-onboarding-phase1 milestone cite
docs/analyse-onboarding-2026-09.md as their source analysis, but the
file had stayed untracked since 2026-09-24 and only existed on one
machine. Phases 2 and 3 of the onboarding work are described nowhere
else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- store_license_in drops activation.token when the stored key changes.
The token names the previous license to the price server (its sub is
the license id, the key carries the email), so the card showed the
machine as activated while prices were refused for the old license.
The same key entered again keeps its token; a refused key changes
nothing.
- get_activation_status goes through activation_status_in(dir,
machine_id, key), tested in a temporary directory: a missing,
expired, foreign-machine, foreign-key or unreadable token reads as
not activated. It alone decides "Not activated" and "Activate".
- LicenseCard is keyed by licenseIdentity(info): another key remounts
the body, which re-reads the activation and drops the previous key's
machine list.
- invalidSignature asks to check the copy first. jsonwebtoken checks
the signature before decoding the payload, so a damaged copy often
reads as a signature failure (characterization test). User guide and
changelogs aligned.
- Render tests: the other errors stay visible under Machines, and the
edition's green check gives way to an alert for a revoked key.
Refs #347
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
UnknownEdition. validate_with_key returns it (ExpiredSignature maps
to LicenseExpired), and store_license, read_license and
validate_license_key reject with the same JSON as the network
commands. store_license_in and read_license_in take a directory, so
the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
resolve_license_entitlements no longer takes the activation token,
and current_entitlements reads only license.key
(entitlements_from_dir). A token that had expired (2-year server
lifetime) or came from another machine (restored data) used to drop
a valid key to free, while a key copied without its token kept its
edition. get_activation_status and persist_activation_token keep the
strict token validation, and the key keeps its strict validation
(CWE-613).
Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
static render reaches it) names a refused key, through
parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
whole session, because the card unmounts when the user leaves
Settings > Users. Only a different key clears it. Card-level
message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
beyond 50 years (licenseExpiry.ts, for the 100-year keys of
maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
the price authFailed message points to activating the machine.
Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).
Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.
Fixes#347Fixes#360
Refs #363
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Render tests for the upsell CTAs (UpsellCtas.render.test.tsx): the
reviewer's probe, which needs no jsdom (react-dom/server in the node
environment, plus the FilterPanel.test.tsx pattern of calling a
component as a function). It pins the Base/Premium CTA on UpsellGate,
the router guard of HaveKeyButton, the localized product URL, the
Premium note and the onNavigate forwarding, none of which the suite
guarded before.
- A failure to open the browser (openUrl) is now logged through
logError in PurchaseButton, DownloadLatestButton and LicenseCard,
instead of vanishing as an unhandled rejection that never reaches the
in-app logs. Tested for both shared buttons.
- upsellCtaFor: comment the trap in making Premium purchasable, since
PurchaseButton sells Base only.
- ADR 0017 amendment: the Account card text holds in practice only;
current_entitlements still grants Premium to an active subscription,
a dormant path until a subscription is sold.
- User guide and in-app docs: the purchase step now comes before the
key entry, and the Balance button is named "Get Premium".
- CHANGELOG: "Maximus Account" in English, "every screen that requires
it" in both files (the Balance screen sells nothing), and the French
elision before a guillemet.
Refs #346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- upsellCtaFor(tier) decides what a locked screen offers: Base is
purchasable, Premium stays disabled with "Premium isn't sold online
yet". Pure and tested on the whole entitlements matrix.
- Shared CTAs, one component per file: PurchaseButton ("Get Base") and
DownloadLatestButton open the localized product page (purchaseUrl) and
need no router; HaveKeyButton renders only inside BrowserRouter.
- UpsellGate uses them; the purchase link never calls onNavigate, so
modal hosts stay open. ProfileFormModal reuses UpsellGate instead of
its compact panel, keeps its Cancel button, and closes after "I
already have a key" (onNavigate={onClose}).
- In Free, the Updates card offers Get Base, I already have a key and
Download the latest version; the error page (outside the router)
offers Get Base and the download link. No update check, no network
call.
- i18n FR/EN: Account card text, Premium note, Updates notEntitled text,
download label, editions guide step and tip; the "coming soon" note
key is gone. Tests pin the upsell key set and the decided texts.
- Docs: ADR 0017 amendment, architecture (UpsellGate row, shared CTAs,
upsellCtaFor, multi-profile row, counts), user guide section 12;
CHANGELOG FR/EN.
Proven red: Premium made purchasable 2, balance sold as Base in the
matrix 4, the old note key back in a locale 1, Account text altered 1,
download label altered 1.
Fixes#346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The Activate button also stays disabled while a deactivation runs
(selectCanActivate): an activation racing the deactivation after a
409 could reach the server first, clear the machine-limit message and
leave the next click to a 429.
- activate_body builds the activation request and omits machine_name
when the host name is unreadable; the server answers 400 invalid_input
to a null. A pure test pins it.
- A deactivation whose 2xx body cannot be read stays an invalid_response.
Counting it as a success would make the command delete this machine's
local token for nothing. A mock-server test pins it.
Proven red on the fixed tree: Activate live during a deactivation 1,
machine_name sent as null 1, unreadable 2xx body read as empty 1.
The same review found that maximus-api answers 500 to the reactivation
of a machine deactivated before (unique constraint without is_active):
tracked in maximus/maximus-api#70, outside this PR.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- 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>
- A 404 without a known code (missing route, proxy error page) is now
pinned as a server error by two table rows, so "key not found" can
never be shown for it.
- The message-key test spells out the expected key of every code
instead of comparing the map with itself, and checks the keys are
distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
AppHandle, tested against a mock server (404 no_active_activation ->
done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
(selectCanDeactivate), so a machine deactivated during a reload
cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
activation. The edition does not change with or without an
activation token, so nothing is observable; refresh() stays.
Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Activation, deactivation and verify POST to /v1/licenses/* with
"product":"simpl-resultat" instead of the legacy /licenses/* paths,
which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
read from error.code of the /v1 envelope. A 200 {valid:false} from
verify is now a coded refusal instead of an empty machine list, and an
HTML error page on a non-2xx maps to server instead of
invalid_response. Deactivation is idempotent: no_active_activation
counts as done.
- The three calls live in *_inner functions without AppHandle, tested
against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
survives the list load it triggers (machine_limit_reached opens the
list), a deactivation removes the machine locally without re-running
verify (4 requests on the machine-limit path, under the server's
5/min/IP limit), and refresh() follows every successful activation or
deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
/{fr|en}/produits/simpl-resultat.
test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.
Fixes#270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Desktop wave #270 -> #346 -> #347 and site #153 planned in twin
milestones, one /autopilot run per repository. Review found that the
2-year activation token silently drops activated machines to Free
(handled in #347). 100-year keys decided out of wave (maximus-api#63,
la-compagnie-maximus#157); machine id hashing tracked in #350.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- issue-license (new): issue licenses by hand through maximus-api's admin
endpoint (testers, gifts, refund compensation, admin account); the admin
key and the issued keys never enter the transcript. Third-party
beneficiaries are named by their Logto id, never by email: the
repository is public.
- release step 0: when a release tightens edition gating, tester keys must
be entered before the tag, since an install without a key receives that
version and then nothing more.
- release step 9: scripts/verify-release.sh checks the 7 assets by suffix
and the content of latest.json, instead of a script rewritten each time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
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>
Forgejo has no comments on releases. Point the trace step at a section
appended to the release notes and record the PATCH call, whose body
replaces the notes wholesale.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159y7AeM6djQLDN2vZ8soPa
Record the release (run 402, 7 assets + latest.json verified), the two
Premium tester keys issued before the tag, the VPS Tailscale node-key
expiry recovery, and the pending step 10 (real update cycle) as the
active follow-up.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013CNa9VVF5C3y47MX1SSJ1S
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
The line said the constraint regresses on "the next cargo update". Measured:
`cargo update -p anyhow` moves nothing, `-p hyper` moves all seven. The
trigger is reaching the networking/TLS subtree, which is exactly where the
advisories land. Also records what the analysis overturned: #310 did notice
the drift and judged it harmless on Linux, where it is — the cost lands on
the Windows build (3 windows-sys crates compiled vs 5), and there is no
correctness risk since all seven declare ranges covering both states.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhPofpLQTZUbkVYR3i2e2D
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
`downloadAndInstall` is one call that downloads AND installs
(updater.rs:723-729), and the `Finished` event is a no-op in
useUpdater.ts:119-121. `READY_TO_INSTALL` is therefore dispatched only
after `dpkg -i` returns, so the pkexec prompt opens while the card still
reads "Téléchargement en cours…".
The checklist told the tester to tick `readyToInstall` before the prompt.
That ordering neutralized the polkit precondition the page exists to
enforce: with no agent the app hangs in `downloading`, not in
`readyToInstall`, so a tester following the checklist files "the download
stalls" instead of "no polkit agent" — the exact false trace this page is
written to prevent.
Also states plainly that at `readyToInstall` the .deb is already on disk
and the button only calls `relaunch()`; the label misleads on this target.
Three smaller corrections from the same review:
- The update triggers are four manual sites, not two: UpdateCard idle /
upToDate refresh / error retry, plus ErrorPage.tsx:82 — which only
detects and offers no download path. The load-bearing claim, "no
automatic check in the app", was already right.
- "Si ça casse" now spells out that DELETE and PUT hit different API
prefixes (/api/v1/packages/ vs /api/packages/, release.yml:217-219,
which carries a comment about exactly this). Replaying both against one
URL 404s at the worst possible moment.
- The SKILL.md changelog entry moves back into date order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 9 inspected latest.json and stopped there. That proves the file is well
formed, not that it installs: the TLS download, signature verification,
installer execution and relaunch are exercised by no test at all — cargo check
and cargo test only prove that code compiles. A regression there surfaces at
update time, on a user's machine, and automatic updates are a Base+ feature.
Written as a manual checklist rather than automation because it needs two real
desktop environments. The value is in the preconditions, which are what make
the difference between running the test and only appearing to:
- Test from a machine still on the PREVIOUS version. check() compares
release.version > current strictly, so testing on the machine that just
built the release shows "up to date" and the checklist gets ticked as
"nothing to update" — the silent skip it exists to prevent, now with a
paper trail claiming it passed.
- A Base+ key on each machine, with any activation.token from another machine
removed. Auto-update is entitlement-gated: useUpdater dispatches NOT_ENTITLED
before check() ever runs, and a token bound to a different machine_id
silently resolves the edition back to Free.
- A live polkit agent on Linux, and record which prompt actually appeared —
install_deb cascades pkexec -> zenity/kdialog -> terminal sudo, so without an
agent the app appears to hang instead of failing.
The two target flows are written separately because they genuinely differ: on
Windows downloadAndInstall never returns (the process exits and NSIS takes
over, so readyToInstall/installing never render), while on Linux the app stays
alive through a polkit prompt and a restart control.
The doc states what the test does NOT prove: `tar` stays unexercised. Its
vulnerable path is only reached by install_appimage and the macOS .app.tar.gz
branch; our .deb goes through `pkexec dpkg -i` and our NSIS path launches the
.exe. That corrects #315's own premise.
The failure path is documented as an incident playbook rather than a
"rollback", because it is not one. Republishing the previous latest.json stops
propagation, but check()'s strict comparison means users already on the broken
version are never offered the older one — the real fix is a vN+1.
rpm is recorded as known-broken, not unverified, and tracked in #320.
Resolves#315
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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#312Resolves#313
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review finding on #330, two defects with one root cause.
Desjardins' fingerprint is date/description/montant/solde — four labels any
Canadian bank could emit — and MIN_SIGNATURE_LABELS = 4 did not deliver the
property it promised, because matching was by SUBSET. Any
Date;Description;Montant;Solde file was announced 'Format Desjardins reconnu'.
A variant built only from generic labels must now describe the header exactly;
one carrying a discriminating label (chequenumber, categorie, memo) keeps
subset matching, so extra columns stay fine once something identifies the bank.
Worse, a matched signature's single amount column short-circuited the
sparse-complementary scan instead of being arbitrated against it. A
Date;Description;Debit;Credit;Montant;Solde file reads correctly as debit/credit
before #330 and became one unsigned column after, importing every deposit as an
expense. The scan now runs first; a signature's amount column only wins when the
pair contains it — which is what RBC's genuine Cheque Number / CAD$ case needs,
and it still passes.
Refs #330
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review finding on #327. detectDescriptionColumn returned the lexically
preferred column with no check on the data, unlike the date (replayed at 0.8)
and the amount (constrained to the shape candidates). The dictionary lists
'transaction' as a description keyword, and Tangerine exports
Date,Transaction,Name,Memo,Amount where Transaction holds DEBIT/CREDIT — so the
description moved off the merchant name and keyword categorisation died.
Cardinality tells free text from an enum: a description repeats almost nothing,
an enum repeats almost everything. Average length does not — Note and Libelle
are both short, so a length veto would reject legitimate columns.
This fixes the cause. #330 had rescued the case through the Tangerine signature
alone, leaving every unrecognised file with a Transaction column broken; that
test now asserts the correct mapping with and without a signature.
Refs #327
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review finding on #325. The debit/credit rule tested isNaN(debit) && isNaN(credit),
which only caught the case where BOTH sides fell. The unused column carries 0,00
in exactly the files this rule exists to fix, so an unreadable debit beside a
0,00 credit computed 0 - 0 = 0 and imported silently — the very bug, one cell
over. Replayed on the PR's own unused-column-zero fixture with a currency
suffix: 6 transactions imported at 0,00 with no error row.
A mapped cell that is not empty but does not parse now fails the row whatever
its sibling holds. An EMPTY cell keeps meaning 'this column does not apply to
this row' and contributes zero, which is the normal shape of the format.
Refs #325
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last link of the ten-link import-format stack. Links 1-9 deliberately wrote
no changelog and no documentation, to avoid a conflict at every level of a
linear pile; this link owes all of it.
- ADR 0019 records the structuring decision: the import format is a fully
persisted value, never re-inferred. It documents the three independent paths
by which it used to be lost (hardcoded restore, header drift, data export),
and why a single codec with a completeness test closes the class rather than
a composed type -- the two carriers are structurally incompatible
(`has_header` is boolean on one and number on the other). `template_id` is
recorded as a provenance label, never re-read as format.
- `docs/architecture.md` gains a dedicated "Import CSV" section covering the
codec, the lexical detection layer and its separate dictionary module, the
bank signatures, the now-mandatory preview step, and sources/templates in
the SREF envelope. Migration v17 and its four CHECK-guarded columns are
listed in the migrations table.
- Stale counts corrected against the tree, not by arithmetic: 16 -> 17
migrations (both files), `src/components/import/` 13 -> 14, `src/utils/`
4 -> 13. Tables (20) and indexes (24) were re-measured and are NOT stale --
v17 is an ALTER TABLE only -- so they are left as they are, with the reason
written down. ADR 0018, missing from the ADR table, is added.
- The user guide and the `docs.*` keys in both locales carry the same new
import journey: automatic detection, confidence score, mandatory preview
with its signed recap, sign inversion, recognised bank formats, drift panel,
and the safe repair path.
- Both changelogs carry the same entries, translated, verified section by
section including issue references. The `public/` copies sync automatically
via `syncChangelogs()`.
1176 vitest green, build clean, cargo check clean. No DB migration.
Resolves#332
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exporting then re-importing data destroyed every import configuration:
dataExportService serialised only categories, suppliers, keywords and
transactions, then ran DELETE FROM import_sources on restore and replaced
them with a synthetic 'Data Import' source. After restoring a backup, every
source had to be reconfigured by hand.
- Serialise import_sources and import_config_templates into the envelope,
with an explicit format_version; a file without one is the earlier format
and its missing arrays are treated as empty.
- Wrap wipe + restore in withTransaction, which the service had nowhere:
a constraint violation mid-restore used to destroy financial history with
no rollback.
- Restore templates BEFORE sources (template_id is a foreign key), upserting
by name and remapping template_id through the resolved ids, so restoring
into a profile that already has templates no longer hits UNIQUE(name).
- Whitelist amount_mode and sign_convention at the import boundary with a
readable message rather than an SQLite constraint error.
Resolves#331
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failure modes, both anchored on the header row.
A KNOWN BANK IS NOW READ BY NAME. `bankSignatures.ts` declares the
documented export layout of Desjardins, RBC, Banque Nationale and
Tangerine as a set of normalized header labels plus a delimiter and a
preamble quirk. The table is evaluated BEFORE the generic dictionary and
an unknown file falls straight through to it, unchanged.
The signatures are not decorative. The generic dictionary matches
keywords as substrings, one role at a time, which reads two of these
four layouts wrong — both frozen as counterfactual test pairs, same
rows, header renamed:
- Tangerine writes `Date,Transaction,Name,Memo,Amount`. `Transaction`
is a description keyword, so every row of the file was labelled with
its direction word instead of the merchant.
- RBC writes its amount column `CAD$`, which no amount keyword
matches, next to a nearly empty `Cheque Number`. Those two are
sparse-complementary, so the shape scan paired them as debit/credit
and the one row carrying a cheque number imported as -247.95 instead
of -6.95.
A signature stays a set of PREFERENCES all the same: they are written
from documented layouts, without real statements, so every hint is
dropped the moment the data contradicts it. The single exception is the
amount mode, which outranks the sparse-complementary scan — nothing
inside an RBC file can tell that pair from a genuine one — and even that
is refused unless the declared columns are candidates the shape scan
proposed. Failing degrades to the generic path; it never breaks.
FORMAT DRIFT IS NOW REPORTED INSTEAD OF IMPORTED. Every successful
import records the normalized labels of its header row in
`import_sources.header_signature`, as a JSON array and not a hash: the
panel has to be able to name the columns that moved. On the next import,
a header that normalizes differently opens a `FormatDriftPanel` above
the preview — column by column, `Montant : 3 -> 4` — with the two
outcomes that exist: adopt the re-detected format, or keep the stored
one. A cosmetic rename (`Montant` -> `MONTANT ($)`) normalizes
identically and says nothing.
A source whose file has no header row keeps `header_signature` NULL and
drift detection is inoperative on it. Documented, not worked around:
a signature invented from the data would fire on every import.
THE REPAIR PATH IS NOW IN THE INTERFACE, in the drift panel and beside
the preview's sign flip. `findDuplicates` matches on date AND
description AND amount, so re-importing a file "now that it reads right"
does not correct the rows already written — it doubles them, and a
flipped sign produces mirror pairs that net to zero in every report. The
only safe path is deleting the faulty import from the history first.
The drift re-detection reuses `detectFormatForFile`, so there is still
exactly one detector; the static guard on its caller count moves from
two to three deliberately. Its score and bank badge are dropped straight
after: they measure the format the panel offers, not the one in use.
Resolves#330
A confidence score reports how many rows were READ, never what they say:
the `all-positive` fixture scores a perfect 100 % while every credit is
imported as an expense, because each of those rows is perfectly readable.
Nothing between that score and the database looked at the signs — the
preview was an optional modal of 20 rows with no totals, and the final
confirmation listed the delimiter and the date format but neither the
amount mode nor the sign convention.
The `file-preview` step had been declared in `ImportWizardStep` since the
beginning and no dispatch ever aimed at it. It is a real step now,
traversed at every import and gated by nothing — in particular not by the
detection score, which is sign-blind by construction.
- `useImportWizard`: `parseAndPreview` parses and stops at the preview,
replacing `parsePreview` and the `parseAndCheckDuplicates` that jumped
straight to the duplicates ("skips preview step"); `checkDuplicates`,
dead code until now, is the preview's next button, so the rows the user
validated are the rows that get checked.
- `summarizeParsedRows`: the recap, pure and tested — outflows and their
total, inflows and theirs, rows in error. Totals stay SIGNED, since
magnitudes would hide the one thing the recap exists to expose. Computed
over the whole file, never over the twenty rows displayed.
- `flipSignFormat` + "Inverser les signes": the correction lands on the
CONFIGURATION, so it is persisted with the source and the next file from
that bank reads right on its own. In debit/credit mode it swaps the two
column indices rather than toggling a convention `mapRow` ignores there,
where a toggle would have been inert.
- `ImportConfirmation` states the amount mode, the sign convention (in the
mode that applies it) and the column mapping, named by header.
- `FilePreviewModal` removed: it was the redundant surface, and editing
the table alone would have mutated a still-live copy of it.
The `all-positive` KNOWN DEFECT marker is dropped rather than deleted. Its
three original expectations still hold — an unsigned file carries no
direction and detection cannot invent one — and two cases were added: the
recap tell (six outflows, zero inflows) and the honest limit, that
flipping this particular file only produces its mirror image.
1088 vitest (1054 before), tsc and vite build clean. No DB migration.
Resolves#329
Detection handed back a configuration it had never tested, and only ever
ran behind the magic-wand button. A source opened for the first time
therefore started on `defaultConfig` — `;`, `DD/MM/YYYY`, columns 0/1/2 —
plausible enough to import a whole file wrong rather than fail visibly.
`detectImportFormat` now REPLAYS what it just decided over every data row
of the file and returns the rate as a `DetectionScore`. The replay runs
`mapRow`, the same pure function `parseFilesInternal` runs at import time,
under the same column-level decimal arbitration: two mappers would be the
exact divergence this chantier removes — a score reading 100 % while the
import wrote different amounts. A test asserts the two agree row for row
on every fixture of the corpus.
The threshold is 90 %, and it only colours the banner. Below it the panel
warns and prints the detailed count ("132 of 150 rows read"); at or above
it the banner is neutral. Nothing is blocked, because a perfect score says
nothing about the SIGN of what was read — `all-positive` scores 100 %
while every credit imports as an expense — and the preview step (#329) is
the real net, traversed at every import.
Detection now also fires on its own, guarded on `!existing`: a source that
has never been configured. A source that HAS one is never re-detected, the
stored format wins. The condition is deliberately not `!restored` — a
stored format that fails to decode already reports its own error, and
detecting over it would replace that message with a silent guess.
The button and the automatic run share one `detectFormatForFile`, so the
button replays detection instead of running a second, drifting variant of
it. The score is cleared when its source changes, when the format is
edited by hand (compared through the codec, so a rename keeps it) and when
a template overwrites the format — a banner vouching for a configuration
nobody measured is the misinformation it exists to remove.
Finally, the sign-convention selector is hidden in debit/credit mode.
`mapRow` computes `credit - debit` on magnitudes there and never reads
`signConvention`, so the control changed nothing. Hidden, not reset: the
stored value is left untouched.
Tests: 1054 vitest (+20), build and cargo check green. No DB migration.
CHANGELOG and docs are centralized in link 10 of the stack per the plan.
Resolves#328
Detection reasoned on the shape of the data alone, so it could not tell a debit
column from a credit one: a file laid out `Date;Description;Credit;Debit` was
mapped by position and every sign of the import came out inverted — silently,
since the total is merely negated and no aggregate check notices.
A new `headerDictionary.ts` carries the FR/EN transaction dictionary (date,
description, amount, debit, credit, balance) plus the two matching helpers,
moved out of `csvAutoDetect.ts` whose holdings tables stay untouched: `montant`
is an exclusion token there and the primary amount keyword here, so the two
tables cannot be merged. Moving the generic helpers rather than exporting them
keeps the dependency one-way.
`csvAutoDetect` puts that layer in front of the shape heuristics. Labels resolve
the debit/credit order, the date, description and single-amount columns, and
give `detectHeader` a second signal for a header row carrying a bare number.
Every hint is a preference the data can veto — a labelled date column must still
parse, a labelled balance column is never excluded if it would leave nothing to
map — and a mute file (no header row, unknown labels) falls back to the shape
heuristics unchanged.
Files pairing unsigned amounts with an adjacent D/C indicator column are now
detected and REFUSED with a dedicated message, instead of being configured as
`positive_expense` and importing every deposit as an expense. Detection reports
that reason through `detectImportFormat`; `autoDetectConfig` keeps its previous
shape for the callers that only need the configuration.
Resolves#327
Three ways an imported amount could be silently wrong, all of them passing
validation, all of them fixed here.
The two-column rule was `isNaN(credit) ? -debit : credit`, so the credit always
won. Many banks write `0,00` in the unused column rather than leaving it empty,
and `isNaN(0)` is false -- every debit of such a file imported as 0,00 and the
expense simply vanished, with no error anywhere. The rule is `credit - debit` on
magnitudes now, which needs no special case for a `0,00` cell (zero is the
identity of the subtraction) and implements the documented convention even when
an export negates its debits. A row unreadable in BOTH columns is an error
instead of a free 0,00 transaction.
`parseFrenchAmount` ended on `parseFloat`, which returns the longest valid
PREFIX instead of rejecting. Measured before the fix: `"50,00-"` -> 5000,
`"1 234,56 CR"` -> 123456, `"100,00 CAD"` -> 10000. A factor-100 error, and it
passes `isNaN`, so those rows counted as VALID everywhere downstream -- which
would have defeated the signed preview (#329), the safety net of the whole
chantier. Validation is anchored over the whole normalized string now and any
residual character yields NaN.
NaN, not a rescued magnitude, for a trailing `CR`/`DB` or currency code. Two
reasons: `CR`/`DB` carry a DIRECTION, so returning a magnitude for both would
trade a loud failure for a silent SIGN error (the D/C-indicator shape is refused
upstream by design, #328); and `"100,00 CAD"` is structurally identical to
`"2025 Montant"`, so whitelisting a trailing word to rescue the first re-blinds
`detectHeader` on the second. Two accounting forms ARE legitimate and supported:
parentheses `(50,00)` and a trailing sign `50,00-`.
The `?? 0` fallbacks read column 0 -- usually the date -- when the mapping was
incomplete. An unmapped amount column is an explicit row error now, reported
ahead of any per-row problem since it is a format error affecting every row.
`1.234` is 1234 in a French column and 1.234 in an English one, and no rule
applied to that cell ALONE can tell. `detectDecimalSeparator` arbitrates from
the decisive siblings of the column and `parseFrenchAmount` takes the verdict as
an option. Detection deliberately stays out of it: it runs before a column is
known to be an amount column at all, so the verdict is applied where the value
actually becomes a transaction.
The rule itself moves out of the hook as a pure `mapRow(raw, format)` in
`importFormat.ts`. That is what lets the corpus tests run the REAL rule -- the
hand-written mirror in `csvAutoDetect.test.ts` and the static guard pinning five
`parseFilesInternal` expressions are both deleted -- and what stops the
detection score (#328) and the signed preview (#329) each re-implementing it.
Hardening is global: the parser is shared by 11 call sites, 8 in
`csvAutoDetect.ts` and 3 in the holdings CSV import (#245), where a price cell
`150,25 CAD` used to store 15025. It is refused now and `buildDetailedLines`
raises on the empty price. An unreadable QUANTITY was worse -- coerced to 0, so a
zero-value position saved in silence; the draft keeps the offending text instead
and the existing `snapshot_priced_quantity_required` fires.
Row errors become i18n keys (`import.rowErrors.*`) rather than the raw English
literals rendered straight into the preview table, since this adds a
user-visible string. The report table also carries raw exception messages, so
both render sites resolve through `isRowErrorKey` and never feed `t()` anything
that is not ours.
Test churn, per link 1's handoff (update the expectation, drop the marker, never
delete the test): the three `#325` KNOWN DEFECT blocks in `amountParser.test.ts`
flip, plus `unused-column-zero` in `csvAutoDetect.test.ts`. One block tagged
`#328` flips too -- `header-numeric-label`, whose own comment reads "#328 adds a
lexical signal to detectHeader, and #325 anchors the parser [...] either fix
closes this". The anchored parser landed first. The other `#328` blocks
(`debit-credit-reversed`, `absolute-indicator`) and the `#329` block are
verified unchanged. CHANGELOG and docs stay centralized in the last link of the
stack, as the plan specifies.
989 vitest (963 before), tsc + vite build clean, cargo check clean. No DB
migration.
Resolves#325
The root bug of this chantier. `import_sources` carried no `amount_mode` and no
`sign_convention` until v17, so restoring a configured source re-inferred the
mode from `mapping.debitAmount !== undefined` and wrote
`signConvention: "negative_expense"` outright (useImportWizard.ts:321-323). A
credit-card statement configured for positive expenses came back on the default
convention at its second import and `parseFilesInternal` negated every amount:
expenses landed as income, with no error shown anywhere. The format is a read
value now, not a guessed one.
Two types and a codec, not one composed type. The four carriers are
structurally incompatible -- `ImportSource.has_header` is declared boolean,
`ImportConfigTemplate.has_header` is a number, `SourceConfig` is camelCase on a
parsed mapping -- so the guarantee cannot come from a shared shape. It comes
from `src/utils/importFormat.ts` being the single conversion point between
`ImportFormatRow` (persisted: snake_case, mapping as JSON, `has_header`
normalized to 0/1) and `ImportFormat` (domain), and from its completeness test.
That test is enforced on two levels, and both were mutation-checked:
`FORMAT_FIELD_PAIRS` is typed `Record<keyof ImportFormat, keyof
ImportFormatRow>`, so a field added to the format fails to BUILD until it is
listed; the test then compares each codec's real output keys against that table,
so a field listed but not wired fails the TEST. Dropping `sign_convention` from
`formatToRow` -- the shape of the original bug -- fails 14 tests.
`formatFromRow` validates rather than falls back. The v17 CHECK admits
`absolute_indicator` so the third amount mode ships without another migration,
but the app cannot map one: falling through to the `single` branch would read
the wrong column for every row, and anything other than `positive_expense`
would silently mean `negative_expense`. It raises an `ImportFormatError`
carrying an i18n key, and the wizard opens on a fresh configuration so
"reconfigure this source" stays an action the user can actually take.
Also here:
- The config write moves from `checkDuplicatesInternal` to `executeImport`, so
an import abandoned at the duplicate step leaves no configuration behind. It
is the only write point in the hook and a guard test holds that.
- Switching amount mode prunes the abandoned mode's columns, so the mode owns
the mapping rather than the reverse. The column the `<select>` merely displays
is deliberately not materialized -- #325 turns an unmapped amount column into
an explicit row error, and writing a 0 here would make it unreachable. The
mode and the pruned mapping land in ONE state update: the panel's handlers
each spread the same `config` prop, so two calls would see the same stale
value.
- `template_id` is recorded and restored as provenance only, never re-read as
format. `selectedTemplateId` is no longer blanked on every source selection.
An acceptance test rewrites a template end to end and asserts the linked
source reads identically, plus a non-vacuity check that the template really
changed.
- Both template writers go through the codec too, so a new format field cannot
reach one table and miss the other.
`parseFilesInternal` is untouched: link 1's static guard on its five pinned
expressions still passes. 39 new tests (963 vitest total, was 924), build clean,
`cargo check` clean, no migration.
Resolves#324
`import_sources` carried only the mechanical CSV settings. The two fields that
decide how an amount is READ -- `amount_mode` and `sign_convention` -- lived
only on `import_config_templates`. That asymmetry is the root bug of this
chantier: restoring a saved source re-inferred the mode from the mapping and
hardcoded `signConvention: "negative_expense"` (useImportWizard.ts:321-323), so
a source configured with positive expenses silently flipped back on its second
import. After v17 both tables carry the same eight format fields.
v17 is strictly additive -- v1 to v16 are untouched, and the diff is pure
insertion. The four columns are defaulted or nullable so the ALTERs are safe on
a populated database:
- amount_mode / sign_convention carry a CHECK, same pattern as v15 on
balance_accounts.kind. amount_mode admits 'absolute_indicator' from the start
so the third amount mode ships without another migration, while the database
still refuses a corrupted value today.
- header_signature stores the normalized header labels seen at the last
successful import, for drift detection.
- template_id is a provenance tag only, never re-read as format: the eight
source columns are authoritative, so editing a template changes no linked
source. ON DELETE SET NULL keeps the source and its format when a template
goes.
The backfill reproduces exactly the rule the wizard applied on the fly, so no
source changes behaviour on migration. It tests `column_mapping LIKE
'%debitAmount%'` rather than json_extract, so the migration depends on no JSON1
extension in the bundled SQLite. sign_convention is deliberately not
backfilled: its DEFAULT restores precisely the value the code hardcoded, the
only past convention that can be inferred.
The four columns are mirrored into consolidated_schema.sql. They are inert
there on the production path -- that script runs after every migration and only
uses CREATE TABLE IF NOT EXISTS, so new profiles receive them from v17 -- but
it stays the tested reference definition, and a parity test now compares it
against the v1->v17 chain column by column, DEFAULT by DEFAULT, CHECK by CHECK
and FK by FK. Both halves of that test were mutation-checked to confirm they
fail on drift.
5 new tests (111 Rust total): v17 on a populated v16 database with a child row,
the backfill against 5 mapping shapes, the CHECKs, the provenance-tag
semantics, and the consolidated parity.
Resolves#323
First link of the import-format stack. `autoDetectConfig`, `detectAmountMode`,
`detectSingleAmount`, `preprocessQuotedCSV` and `parseFrenchAmount` had no test
at all on a suite of 871, while carrying every imported amount. This adds the
reference the rewrite (#323-#332) measures itself against, before any of it
moves.
Corpus — 11 synthetic files under `src/__fixtures__/csv/`, no real statement
data, covering shapes a single real statement never contains at once: signed
amount, debit/credit, debit/credit in reversed column order, unused column
filled with `0,00`, preamble before the header, header carrying a number,
header cell starting with digits, no header row, all-positive amounts,
whole-line-quoted (Desjardins style), absolute amount + D/C indicator.
Every expectation was derived by running the code, not by reading it. Four
cases are frozen as DEFECTIVE, each named `KNOWN DEFECT` with the right answer
in a comment and the issue that owes the fix:
- reversed debit/credit — the pair is assigned by column position, never by
label, so every sign is inverted while the total merely negates (#328)
- unused column at `0,00` — the rule branches on `isNaN(credit)` and `"0,00"`
parses to 0, so every debit imports as zero (#325)
- header cell starting with digits — `parseFloat` returns the numeric prefix,
`detectHeader` reads the header as data (#328/#325)
- absolute amount + D/C indicator — the indicator column is ignored entirely
and every credit imports as an expense (#328)
`parseFrenchAmount` is pinned form by form, including the prefix-scan defect
the spec flagged: `"100,00 CAD"` yields 10000 and passes `isNaN`. Per the
/review-spec revision, the assertions reach the holdings call sites too, which
surfaced the same x100 leak in the #245 holdings import — a price cell of
`"150,25 CAD"` stores the position at 15025.
The end-to-end tests replay the wizard's row-mapping rule from a mirror, since
it still lives inside a `useCallback` and the repo has no jsdom. A guard test
asserts the production expressions are still literally present, so the mirror
cannot drift; #325 extracts `mapRow` and must then delete it.
No production file is modified. 924 vitest green (was 871), build clean.
Resolves#326