feat(balance): securities service + detailed snapshot save (#212) #221
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#221
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-212-service-securities"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Resolves #212
Stacked on #220 (base branch
issue-211-conversion-v16, which itself stacks on #219 -> main). Review/merge in order #219 -> #220 -> this.Service layer for detailed (per-security) balance accounts
findOrCreateSecurity(UPSERT on normalizedUPPER(TRIM)symbol,ON CONFLICT(symbol) DO UPDATE; callable inside the save transaction via an optional executor so a brand-new symbol creates its security then its holding atomically),listSecurities,getSecurity,updateSecurity.saveSnapshotAtomic/upsertSnapshotLines-- for a detailed line (one carrying aholdingsarray), the aggregatedbalance_snapshot_linesrow stores the rounded-cent SUM (qty/price NULL) and itsbalance_snapshot_holdingsare rewritten in the SAMEBEGIN/COMMIT: capturelastInsertId, DELETE existing holdings, find-or-create each security, INSERT each holding, recomputevalue = SUM(holdings)server-side. A holding-insert failure rolls the whole save back (tested).upsertSnapshotLinesis now wrapped in an explicit transaction too. Simple / legacy-priced scalar path is byte-for-byte unchanged.Math.round(v*100)/100, summed, compared exactly to the line value -- no float tolerance on the detailed path (avoids N-holding rounding accumulation; covered by an N>=20 test).PRICED_VALUE_TOLERANCEkept for the scalar priced path.validateDetailedSnapshot: detailed+holdings => line qty/price NULL AND value === rounded-cent SUM; detailed without holdings => pre-pivot aggregated tolerated.validateLineKindInvariantsuntouched.updateBalanceAccountrejectsdetailed -> simplewith typedaccount_kind_detailed_has_holdingswhen holdings exist (UI gating alone is insufficient). Addskind/detailed_sinceto the account input + SELECTs.getHoldingsForLatestSnapshot(prefill -- latest snapshot holdings per security, excludes quantity 0),listHoldingsBySnapshotLine(drill-down).computeUnrealizedGain: per-holding + aggregatedvalue - book_costand%;book_cost = 0OR NULL => % = null (N/A, no divide-by-zero); NULL book_cost excluded from the aggregate and flagged (has_unknown_book_cost).DO-NOT-TOUCH preserved:
getSnapshotTotalsByDate/ByCategoryAndDate/ByVehicleAndDateandcomputeAccountReturnare unchanged -- aggregations and Modified Dietz still readbalance_snapshot_lines.value.Save-input type shape (for #213/#214 downstream)
SnapshotLineInputgainsholdings?: SnapshotHoldingInput[]-- presence of the field (even an empty array) marks the line detailed;undefinedkeeps the simple/priced scalar path.SnapshotHoldingInput = { symbol, asset_type, currency?, security_name?, quantity, unit_price, value, book_cost?, price_source?, price_fetched_at? }(security referenced by normalized symbol for in-txn find-or-create). An emptyholdingsarray models a detailed account at/just after its pivot with no positions yet.Gate
npm run buildok .npm test(584 passed) ok .cargo checkok (no Rust change). New tests cover every new function: casing dedup, accept/reject incl. N>=20 holdings, computeUnrealizedGain book_cost=0/NULL, detailed->simple guard, atomic save + rollback.Generated autonomously by /autopilot run of 2026-06-06
Adversarial review — PR #221 (issue #212) · APPROVE ✅
Link 3/9, the riskiest TS PR. Verified the FULL diff (+1324/−93, 2 files) plus the v14/v16 schema, the TS types, and a byte-for-byte diff of every DO-NOT-TOUCH function. Re-ran the gate against the actual head content in an isolated overlay (the local tree is on
mainand would have given a stale pass):vitest167/167 green,tsc --noEmit0 errors once the stacked headtypes/index.tsis in place.Transactional integrity — correct
saveSnapshotAtomicandupsertSnapshotLinesboth wrap the whole rewrite in one explicitBEGIN … COMMIT,ROLLBACKon any throw with the original error preserved over a rollback failure. The aggregated line INSERT → capturelastInsertId→DELETE FROM balance_snapshot_holdings→ find-or-create security →INSERTholding all run on the same executor inside that single transaction.findOrCreateSecurity(input, exec)threads the open-txn handle: it issues onlyINSERT … ON CONFLICT … / SELECT, no nestedBEGIN(grep confirms the only BEGINs are in the 3 save funcs + the pre-existingproposeStarterAccounts). A nested begin would have errored — it doesn't.holding INSERT failstest asserts the last call isROLLBACKand that noCOMMITever ran (no partial line/holdings).Rounded-cent invariant — correct
roundToCent(v)=Math.round(v*100)/100applied per holding, summed, thenroundToCentagain, compared exactly (!==) toroundToCent(line.value)— no float ε on the detailed path.insertSnapshotLineWithHoldingsrecomputes the stored line value server-side from the same rounded SUM, so the line is authoritative regardless of caller arithmetic. Covered at N=21 (21×0.005 ⇒ 0.21 exact; 0.22 rejected) in bothvalidateDetailedSnapshotandsaveSnapshotAtomic.PRICED_VALUE_TOLERANCEretained only for the untouched scalar priced path.DO-NOT-TOUCH — byte-for-byte unchanged (verified by
diff)getSnapshotTotalsByDate,getSnapshotTotalsByCategoryAndDate,getSnapshotTotalsByVehicleAndDate,computeAccountReturn(Modified Dietz) andvalidateLineKindInvariantsare identical base↔head. The simple/legacy-priced scalar INSERT path in the save helpers is preserved verbatim.detailed→simple guard — correct, not bypassable
Service backstop in
updateBalanceAccount: counts holdings scoped to the account (JOIN balance_snapshot_lines l … WHERE l.account_id = $1, not global), throws typedaccount_kind_detailed_has_holdings, fires before the UPDATE. Runs only on the dangerous direction (existing.kind==='detailed' && kind==='simple'); simple→detailed skips the count (test asserts exactly one SELECT). UPDATE params[…, kind=$7, detailedSince=$8, id=$9]line up.book_cost guards — correct
computeUnrealizedGain:book_cost===0⇒gain=value, gain_pct=null;book_cost===null/undefined⇒gain=null, gain_pct=null, excluded fromtotal_book_cost/total_gain, flagshas_unknown_book_cost. Aggregatetotal_gain_pct = totalBookCost===0 ? null : …. No divide-by-zero on any path; all four branches tested.SQL / params / symbol normalization
$1…); the only string-literal SQL isNULL/'manual'constants — no interpolation of user data. Holdings INSERT column list matchesbalance_holdings_schema.sqlexactly (book_cost nullable; qty/price/value NOT NULL, always finite per validation).normalizeSecuritySymbol = UPPER(TRIM)matches the v16 SQLUPPER(TRIM(a.symbol))and thesymbol TEXT COLLATE NOCASE UNIQUE(v14);ON CONFLICT(symbol) DO UPDATE … COALESCE(name)preserves id + keeps metadata fresh. Casing-dedup tested (aapl/BtC→ same row).getHoldingsForLatestSnapshotresolves latest per account viasnapshot_date DESC LIMIT 1over lines that carry holdings, thenquantity <> 0(sold-out positions excluded; sell-then-rebuy reappears).listHoldingsBySnapshotLinejoins the security and filters by line.Tests — genuine, not over-mocked
167/167 pass on head. No
.skip/.only/.todo.beforeEachresetsmockSelect/mockExecuteso themockResolvedValueOncesequences don't leak. Coverage spans every new function incl. rejects (empty symbol, bad asset_type, scalar-on-detailed, value mismatch, invalid kind, security_not_found), the in-txn executor threading, atomic save, and rollback. The updatedupsertSnapshotLinestests correctly re-index for the new BEGIN/COMMIT framing.Non-blocking nits (optional)
findOrCreateSecurity'sON CONFLICT … currency=$3, asset_type=$4overwrites an existing security's currency/asset_type on every save (onlynameis COALESCE-guarded). Intentional per the doc comment ("keeps metadata fresh"), but a later save with a different/default currency for an existing symbol would silently rewrite it. Consider COALESCE-guarding currency too, or documenting that last-write-wins is intended. Not a correctness bug for the current single-currency (CAD) model.computeUnrealizedGainsumstotal_valuefrom raw stored values (already cents fromroundToCentat write time) — fine; just noting the rounding guarantee lives at the write path, not here.Verdict: APPROVE. No must-fix issues. Privacy-first preserved (all local SQLite, no network). Stacked correctly on #220→#219; merge in order.