Service layer for detailed (per-security) balance accounts:
- findOrCreateSecurity (UPSERT on normalized UPPER(TRIM) symbol, callable
in-txn via an executor), listSecurities, getSecurity, updateSecurity.
- saveSnapshotAtomic / upsertSnapshotLines: a detailed account (line carrying
a holdings array) writes its aggregated line (value = rounded-cent SUM,
qty/price NULL) AND its holdings in the SAME BEGIN/COMMIT; the line id is
captured, existing holdings DELETEd, each security find-or-created and each
holding INSERTed. A holding-insert failure rolls the whole save back. Simple
/ legacy-priced scalar path is unchanged. upsertSnapshotLines is now wrapped
in an explicit transaction for the same atomicity.
- validateDetailedSnapshot: detailed+holdings => line qty/price NULL and
value === rounded-cent SUM(holdings) compared EXACTLY (no float tolerance);
detailed without holdings => pre-pivot aggregated tolerated.
validateLineKindInvariants stays byte-for-byte for the scalar path.
- roundToCent helper; detailed path uses per-holding cent rounding then exact
comparison to avoid N-holding rounding accumulation (decision 2026-06-03).
- Service backstop in updateBalanceAccount: detailed->simple rejected with a
typed error (account_kind_detailed_has_holdings) when holdings exist; adds
kind/detailed_since to the account input + SELECT.
- getHoldingsForLatestSnapshot (prefill; excludes quantity-0 positions),
listHoldingsBySnapshotLine (drill-down).
- computeUnrealizedGain: per-holding and aggregated value - book_cost and %;
book_cost = 0 OR NULL => gain % null (no divide-by-zero); NULL book_cost
excluded from the aggregate and flagged.
Existing aggregators (getSnapshotTotalsBy*) and computeAccountReturn untouched.
Unit tests for every new function incl. casing dedup, N>=20 holdings rounding,
book_cost=0/NULL, detailed->simple guard, atomic save + rollback. Existing
upsertSnapshotLines/updateBalanceAccount tests updated for the BEGIN/COMMIT
wrapping and the kind/detailed_since columns.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue 3 of overnight-2026-06-01-bilan-axe-vehicule. Builds the tracking UI
on top of the merged data layer (#202) and input UI (#203).
- Service: getSnapshotTotalsByVehicleAndDate(range) mirrors the by-category
aggregation with GROUP BY COALESCE(a.vehicle_type, 'none') so null-envelope
accounts land in a single 'none' bucket (never a SQL NULL key). Add
vehicle_type to getAccountsLatestSnapshot SELECT + type.
- useBalanceOverview: new groupAxis ('class'|'vehicle') state ORTHOGONAL to
chartMode; loads byVehicle alongside byCategory. Default groupAxis='class'.
- BalanceEvolutionChart + BalancePage: stacked-mode sub-toggle 'Par classe
d'actif' (default) / 'Par enveloppe'. Vehicle legend reuses the #203
vehicleType.* labels; the 'none' bucket uses balance.vehicle.none.
- BalanceAccountsTable: 4 return columns collapsed by default with a toggle,
persisted across sessions via userPreferenceService key balance_show_returns.
- i18n FR/EN: balance.chart.axis.{byAssetClass,byVehicle}, balance.vehicle.none,
balance.accountsTable.toggleReturns.{show,hide} (+ axisLegend aria label).
Tests: npm run build green (0 type errors); vitest 3314 passed. Added 5
service tests for the 'none' bucket + mixed envelopes + date range.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds defense-in-depth: each iteration runs a SELECT COUNT(*) WHERE name=?
AND balance_category_id=? AND archived_at IS NULL inside the BEGIN/COMMIT
block, immediately before its INSERT. On a hit, the iteration skips the
INSERT silently and the returned ids array excludes the skipped starter.
Rationale: balance_accounts has no UNIQUE constraint on (name, category)
and the upstream pre-filter (getStarterCollisions) is best-effort. If a
race or a bypass slips a duplicate through, the in-txn check catches it
without surfacing a confusing error to the user.
Existing two tests in StarterAccountsModal.test.tsx updated to mock the
new SELECT call sequence; new test "skips silently when in-txn collision
check finds an existing account" added.
Suggestion S3 from PR #185 review (#187).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getStarterCollisions now filters `archived_at IS NULL` so a starter
account the user voluntarily archived no longer blocks re-creation
through the StarterAccountsModal. Matches the rest-of-codebase
convention (active = is_active=1 AND archived_at IS NULL).
Suggestion S4 from PR #185 review (#187).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part 1 — New profiles: seed 4 starter accounts in
consolidated_schema.sql (Compte chèque/CELI/REER/Compte
non-enregistré, currency CAD, is_active=1) right after the
balance_categories seeds. Categories resolved via SELECT subquery
on the seeded `key` values for robustness.
Part 2 — Existing profiles: StarterAccountsModal proposes the same
4 starters at first /balance visit. Default-checked checkboxes,
collision rule (case-insensitive trim name + matching category)
disables matches with a "Déjà présent" tooltip. The atomic helper
`proposeStarterAccounts` wraps the inserts in BEGIN/COMMIT (rolls
back on error). user_preferences.balance_starter_proposed records
{shown_at, accepted} so the modal never reappears, dismissed or
confirmed.
Part 3 — docs/adr/0012-balance-two-level-model.md (Proposed):
captures the future vehicles × compositions model for reflection,
no code change. Numbered 0012 because 0011 was already taken by
the providers-best-effort-yahoo ADR. Linked from architecture.md
ADR table and Bilan section.
Tests: StarterAccountsModal.test.tsx covers STARTER_ACCOUNTS shape,
getStarterCollisions (case-insensitive trim, category-scoped) and
proposeStarterAccounts (insert order, COMMIT, ROLLBACK on failure).
No render tests — mirrors the BalanceOnboardingCard pattern (no
jsdom configured).
Resolves#179
useSnapshotEditor.save now validates all simple/priced lines in-memory
before any DB write, then delegates to a new saveSnapshotAtomic helper
that wraps INSERT snapshot + INSERT lines in an explicit BEGIN/COMMIT
transaction (ROLLBACK on catch). Pattern matches categorizationService.
Migration v11 cleans existing orphan snapshots in profiles that hit the
old race; new orphans are no longer possible thanks to the transaction.
Resolves#176
SQLite raised "misuse of aggregate function MIN()" because MIN was used
in the WHERE clause of a scalar subquery. Replace with ROW_NUMBER()
OVER (PARTITION BY account_id ORDER BY snapshot_date ASC) filtered on
rn = 1.
Adds vitest coverage and a regression test for /balance load.
Resolves#175
Priced balance categories now carry an explicit `asset_type`
('stock' | 'crypto') so PriceFetchControl can route to the right
provider without symbol heuristics. ETH = Ethan Allen NYSE AND
Ethereum crypto are no longer ambiguous.
Migration v10 adds a nullable column and backfills the two seeded
priced categories (key='stock','crypto'). Legacy custom priced rows
stay NULL until the user edits the category — SnapshotLineRow hides
the price-fetch button when asset_type is NULL on a priced row, so
manual entry remains available.
Service-side validation rejects priced creation without asset_type
('asset_type_required') and rejects values outside ('stock','crypto')
('asset_type_invalid'). Simple kind coerces asset_type to NULL.
The CategoryVariant of AccountForm shows the selector only when
kind=priced, requires it on submit, and resets it on kind switch.
i18n keys added under balance.category.assetType.* (FR + EN).
Tests:
- 4 new Rust migration tests in lib.rs (column add, seed backfill,
legacy row stays NULL, CHECK rejects 'gold')
- 6 new vitest cases on createBalanceCategory + listBalanceAccounts
asserts c.asset_type AS category_asset_type in the join
- balance-flow integration test updated to pass asset_type='stock'
No new test for SnapshotLineRow render guard — project lacks
@testing-library/react + jsdom; the guard is one boolean expression
covered by manual QA per autopilot decisions in PR #167.
Fixes#169
- prices.fetchPrice wraps invoke('fetch_price', ...) with local rate-limit (1/2s), in-flight dedup, exp backoff on 5xx (2/4/8s, max 3 retries), no retry on 4xx/429, hard 100/session cap
- 9 vitest tests with vi.useFakeTimers() (happy, 401/403/404, 429 no-retry, 5xx retries, dedup, pacing, session cap)
- Annexe B i18n mapping wired (PriceError → balance.priceFetching.errors.* keys)
- Session cap checked before rate-limit/dedup; failures do not consume budget (MEDIUM decision)
Closes#156
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue #142 / Bilan #4 — UI for transfer linking + per-account returns.
- New `LinkTransfersModal.tsx`: portal modal with date-range / category /
free-text filters, multi-select with auto-proposed direction (`in` for
negative bank amounts, `out` for positive — flippable per row).
Submits via sequential `linkTransfer` calls; reports per-row failures
inline (most common case: `transfer_already_linked` on a re-submit).
- `BalanceAccountsTable.tsx`: 4 new columns rendered side-by-side —
3M / 1A / Since-inception (Modified Dietz via `compute_account_return`)
+ Unadjusted (`(V_end - V_start) / V_start`). Returns load lazily
after mount via `Promise.all` over (account × horizon); per-cell
failure leaves the slot at "—" without blocking the rest of the
table. The actions menu gains a *Link transfers* item that bubbles
the request up to the parent page. New props:
`sinceCreationDate` (anchors the since-inception horizon) and
`onLinkTransfers` (modal opener).
- `BalancePage.tsx`: hosts the new modal, loads the categories list
once on mount for the filter dropdown, fetches the union of
`listAccountTransfers` per account so the chart can render markers,
and threads the earliest snapshot date down to the table. Reload
is triggered after the modal reports at least one successful link.
- `balance.service.ts`: dropped the unused `BalanceAccountTransfer`
import to satisfy `tsc --noUnusedLocals`.
`npm run build` clean. `npm test` → 429 passed. Manual sanity check:
the table renders "…" placeholders during the per-row return load,
then resolves to either a percentage or a "—" with the partial
tooltip when the underlying snapshot endpoint is missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #142 / Bilan #4 — TS bridge for the Modified Dietz command + plain
CRUD for transfer linking.
Types (`src/shared/types/index.ts`):
- `BalanceTransferDirection` ('in' | 'out')
- `BalanceAccountTransfer` (raw row) +
`BalanceAccountTransferWithTransaction` (joined view)
- `AccountReturn` (mirrors the Rust struct, ready to receive the invoke
payload as-is)
Service (`src/services/balance.service.ts`):
- `computeAccountReturn(accountId, periodStart, periodEnd)`: resolves the
active profile's `db_filename` from `loadProfiles()` and calls the
`compute_account_return` Tauri command.
- `linkTransfer(accountId, transactionId, direction, notes?)`: INSERT
with duplicate guard (typed `transfer_already_linked` error instead of
raw SQL UNIQUE failure).
- `unlinkTransfer(accountId, transactionId)`: DELETE with
`transfer_not_linked` guard for stale-UI calls.
- `listAccountTransfers(accountId, dateRange?)`: joined SELECT for
modal/list rendering.
- `listLinkedTransactionIds()`: returns a `Set<number>` for the
transaction icon (one query, in-memory `.has()` lookups thereafter).
- `listAllLinkedTransfersForTooltip()`: returns
`Map<transactionId, links[]>` for tooltip rendering.
- `suggestTransferDirection(amount)`: pure helper for the modal — maps
negative bank amounts to 'in', positive to 'out'.
- `isLinkedTransactionFkError(error)`: detects the canonical SQLite "FK
constraint failed" text so `transactionService.deleteTransaction` can
surface a clear i18n message.
- 5 new error codes added to `BalanceErrorCode`.
Tests (`balance.service.test.ts`): 22 new vitest cases bringing the file
to 85 passed. Mocks `@tauri-apps/api/core` `invoke` and
`./profileService` `loadProfiles`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add four service helpers used by the upcoming `/balance` overview:
- getSnapshotTotalsByDate(range?) — SUM(value) GROUP BY snapshot_date
with an optional inclusive [from, to] range. LEFT JOIN preserves
empty snapshots as zero rows so the chart shows continuity.
- getSnapshotTotalsByCategoryAndDate(range?) — same aggregation broken
down by balance_categories.key, returned as one row per snapshot
date with a `byCategory` map. Powers the stacked-area variant.
- getAccountsLatestSnapshot() — one row per active account with the
value of its most-recent snapshot line (NULL when none exists).
Filters archived accounts via WHERE is_active = 1 AND archived_at
IS NULL, matches the listBalanceAccounts default.
- getAccountsPeriodAnchor(range) — earliest snapshot_date >= from
per account, with the value at that date — the anchor used to
compute the per-account Δ% column on the accounts table.
Tests cover empty DB, single/multi snapshot, archived exclusion via
SQL inspection, date-range params (from-only, both bounds, open).
Refs: #141
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the snapshots + lines section of the balance service for Issue #146
(Bilan #1b). Simple-kind only — quantity / unit_price are forced to NULL
both at the SQL CHECK level (already in v9) and at the service level
(`upsertSnapshotLines` validates ahead of time). Priced-kind upsert lands
in #140.
New service exports:
- listSnapshots / getSnapshotByDate / getSnapshotById / getPreviousSnapshot
- createSnapshot (throws snapshot_date_taken when UNIQUE per date violated
so the UI can redirect to edit mode)
- updateSnapshot / deleteSnapshot (cascade lines via FK)
- listLinesBySnapshot / upsertSnapshotLines (rewrite-all strategy)
New BalanceErrorCode entries: snapshot_date_required, snapshot_date_taken,
snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported.
New shared types: BalanceSnapshot, BalanceSnapshotLine.
22 new vitest cases cover: invalid-date guards, unique-per-date violation,
simple-kind null invariant on inserts, NaN/Infinity rejection,
clear+rewrite line semantics, getPreviousSnapshot strict-before ordering.
Refs #146
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>