feat(balance): reducer holdings + dispatch account.kind (#213) #222
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#222
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-213-reducer-dispatch"
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 #213
Stacked on #212 (base branch
issue-212-service-securities).What this is
The state/plumbing layer of the per-title detail feature: a structural rewrite of
useSnapshotEditorto hold N holdings per detailed account, and a switch of all simple/detailed dispatch fromcategory_kindto the account's ownkind. The full multi-security entry UI (SecurityPicker, rich sub-rows) is the next issue (#214). Simple accounts behave identically.Reducer state shape (for #214/#215 downstream)
values: Record<accountId, string>— simple accounts (scalar, unchanged).holdings: Record<accountId, HoldingDraft[]>— detailed accounts, one draft per title.HoldingDraftis a string-typed editable mirror ofSnapshotHoldingInput(symbol, asset_type, currency, security_name, quantity, unit_price, book_cost, price_source/at) plus a stable clientrowIdfor React keys.ADD_HOLDING/REMOVE_HOLDING/SET_HOLDING_FIELD(+ existingSET_VALUE/PREFILL/RESET). The deadpricedValues/SET_PRICED_FIELDscalar-priced path is removed — post-#211 no account is simple-category-priced anymore.Holdings flow to save
buildDetailedLinesproduces oneSnapshotLineInputper detailed account carrying itsholdingsarray (presence marks the line detailed; aggregatedvalue= rounded-cent SUM).buildSimpleLinesproduces scalar lines for non-detailed accounts. Blank holding rows are skipped; a partially-filled row throws a typed error before any DB write. Both feed the existingsaveSnapshotAtomic.Dispatch on account.kind
LOADED: edit-mode hydrates baskets vialistHoldingsBySnapshotLine(keeps saved price); new-mode prefills viagetHoldingsForLatestSnapshot(drops price, qty-0 excluded server-side, book_cost carried).SnapshotLineRow/SnapshotEditor/AccountFormgate onaccount.kind.category.kindsurvives only as the suggested seed default for a NEW account.Notable decisions
simple(CreateBalanceAccountInputhas nokind;balance.service.tsis out of this issue's scope). Create-as-detailed + pivot date is #215. On edit,kindis forwarded only on a simple→detailed change; the selector is locked for an already-detailed account (the detailed→simple downgrade is service-guarded).[Unreleased]entry; the whole #210–#212 chain added none).Gate
npm run build✅ ·npm test✅ (603 passed, 19 new reducer/helper tests).Generated autonomously by /autopilot run of 2026-06-06
Adversarial review — PR #222 (Issue #213) — APPROVE ✅
Link 4/9 (reducer rewrite). Reviewed the full diff (1083+/363−, 8 files) against the base branch
issue-212-service-securitiesand the actual service/types contracts. This is a clean, complete migration.Dispatch migration — complete ✅
Every dispatch site in the touched files now keys on the account's OWN
kind:SnapshotLineRowL78account.kind === "detailed"useSnapshotEditorLOAD (L539 edit / L560 new),save(L692detailedAccountIds),prefillFromPrevious(L658)AccountForm(AccountVariant) L157values.kind === "detailed"(wasisPriced)The remaining
values.kind === "priced"reads inAccountForm.tsx(L423–539) belong to the separateCategoryVariantusingCategoryFormValues.kind: BalanceCategoryKind— category kind, correctly left aspriced. Not an account-dispatch leftover. No residualcategory_kinddispatch anywhere.Dead-path removal — safe ✅
PricedEntry/SET_PRICED_FIELD/pricedValues/setLineQuantity/setLineUnitPrice/onQuantityChange/onUnitPriceChangeare fully removed. Grepped all 239 src TS/TSX files (onlySnapshotEditPage→SnapshotEditor→SnapshotLineRowconsume these) — zero dangling references. The simple scalar path inSnapshotLineRowis unchanged (// Simple variant — unchanged from #146).Reducer correctness ✅
filter, SET_HOLDING_FIELD =mapwith field spread — all immutable, other baskets untouched.listHoldingsBySnapshotLine(keepPrice:true); new-mode prefills viagetHoldingsForLatestSnapshot(keepPrice:false, drops price+source, keeps qty+book_cost).rowIdvia monotonicnextRowId()(h{n}), distinct per row (tested).Save wiring — matches service contract exactly ✅
buildDetailedLinesALWAYS emitsholdings(even[]), which is precisely how the service'sisDetailedLine(line.holdings !== undefined) classifies detailed — confirmed againstvalidateAllLines/insertSnapshotLineWithHoldings.buildSimpleLinesemitsaccount_kind:"simple", neverholdings.account.kind(set built fromstate.accounts), so a detailed account under a simple category routes through holdings and a stray scalarvalues[id]is excluded (tested).BalanceServiceErrorbefore any DB write (#176 invariant preserved).round(qty*price*100)/100per holding thenround(sum*100)/100per line; serviceroundToCentper holding summed and compared EXACTLY toroundToCent(line.value)— equality holds.Prefill ✅
qty-0 exclusion (
h.quantity <> 0), book_cost carry, and sold-then-rebought ("latest non-zero wins") are all ingetHoldingsForLatestSnapshot's SQL;holdingsFromServiceHoldingsmaps book_cost through (NULL →"", tested).state.accountscorrectly added tosave's dep array.Create-as-detailed scoping — reasonable, not misleading ✅
The kind selector at CREATION is preview-only: the CREATE
payloadis typedCreateBalanceAccountInputwhich has nokindfield (TS-enforced — can't accidentally persist it), so a new account issimple. ThedetailedCreateHint("New accounts start as a single amount; convert one to by-title from the accounts list") renders exactly when!isEditing && kind==='detailed', so the UX is honest. The convert path it promises works today: editing asimpleaccount fromAccountsPageand switching the selector forwardsupdatePayload.kind→editAccount→updateBalanceAccount(applieskind, guards detailed→simple viaaccount_kind_detailed_has_holdings). Only the pivot-date wizard (detailed_since) is deferred to #215 — a refinement, not a broken path. Selector correctly locked for already-detailed accounts.Tests ✅
19
it()/ 5describe, no.only/.skip, import the realreducer/buildSimpleLines/buildDetailedLines/holdingsFromServiceHoldings(onlyservices/dbmocked). They substantively prove: holdings-action immutability, RESET/PREFILL merge, LOADED-vs-PREFILL price handling, the SUM/rounding (20.01+30.10→50.11), blank-skip + partial-throw, and the dispatch-on-account.kind regression (detailed under simple category → holdings path, stray scalar ignored).i18n ✅
New keys
balance.account.form.kind.*andbalance.snapshot.detailed.*present in bothfr.jsonanden.json; no hardcoded user-facing strings in the touched components.Non-blocking nits (no fix required)
SET_HOLDING_FIELD'sfield: keyof Omit<HoldingDraft,"rowId">withvalue: stringis slightly loose — it would type-allow settingasset_type/price_source(non-string fields) to a string. No caller does (only symbol/quantity/unit_price are wired), so no runtime impact. Could narrow to the 3 string fields in a later pass.HoldingSubRow'sonPriceFetched={(price) => …}ignores thecurrencysecond arg (CAD-only MVP) — same as the pre-existing priced row; fine.No must-fix issues. APPROVE.
— Adversarial review, autopilot