fix(stats): scope reports by account access instead of authorship - #520
Conversation
The `/stats/*` reports scoped their rows with `{ creator: userId }`, so the
recipient of a shared account saw its balance and its transaction list but
zeros in every report. `{ accessibleTo }` already existed on `AccessPolicy`
for exactly this and was never wired in — the transactions list gets the same
semantics through `'pre-scoped' + getAccessibleAccountIdsForUser`, which is
why the list was right while the reports were not.
Balance history is a different cause with the same symptom: it scoped its
accounts on `Accounts.userId`, so it resolves the account set through
`getAccessibleAccountIdsForUser` too.
Cash flow and expenses history take `planned: { visibleTo: userId }` rather
than `'include'`: once the row scope spans a shared account, `'include'`
would count another member's plans as this caller's forecast.
One existing expectation moves with the scope. The shared-account case in
`get-cash-flow.e2e.ts` asserted `expenses === 20` — the recipient's own row
only — which is the authorship scoping this fixes. Its subject, that a
recipient's transaction resolves the owner's category instead of "Unknown",
is unchanged and still asserted.
Closes letehaha#510
`/stats/balance-history?accountId=` takes a different branch from the unscoped call and authorized on ownership, so with the account set widened the same endpoint answered with the shared account in one shape and an empty series in the other. `canUserAccessResource` with `read` keeps it an authorization gate — the queries below it still trust that this passed — while letting through the accounts whose balance and transactions the caller can already see.
|
I have read the CLA Document and I hereby sign the CLA |
|
All contributors have signed the CLA. Thank you! |
|
recheck |
|
Hey @mariofox, thanks for the PR – the writeup made the review a much easier job 🙏 First, the test change you asked to check first: the Your three questions: A – keep it. B – total balance moving together with point 3 is the right call. It's literally derived from the same series as the balance-history chart ( C – you were right to flag it, and it's actually bigger than the denominator mismatch. Please revert
Now the one real problem: Net worth doesn't actually stay personal. The "never go through
So a recipient's net worth now includes the full balance of accounts merely shared with them, while portfolios/vehicles/ventures stay owner-only – mixed-scope numbers on one chart. Two concrete symptoms:
What I'd ask for to finish this up:
Everything else checks out: Let me know if anything's unclear or you want me to take any of it! |
Widening `buildAccountWhere` reached further than the balance surfaces it was
meant for. `getAggregatedBalanceHistory` and `getPerAccountBalanceHistory`
share `getBalanceHistoryRows` with `getBalanceHistory`, so net-worth history,
net-worth drivers and combined balance history silently started counting
accounts merely shared with the caller — while their portfolio, vehicle and
venture components stayed owner-only, putting two scopes on one chart.
`AccountScope` makes the choice explicit at every call site with no default,
the same way `AccessPolicy` does for transaction reads: `'accessible'` for the
balance surfaces (`/stats/balance-history`, `getTotalBalance`), `'owned'` for
the three net-worth services. `'owned'` restores the previous predicate
exactly, so the owner-scoped loan opening back-fill and credit-limit
adjustment match their series again.
`savings-transactions.ts` goes back to `{ creator }`. Both of its consumers
are personal surfaces — the "share of savings" card, whose numerator is
owner-only `PortfolioTransfers`, and the net-worth drivers' savings intake —
so widening it moved a denominator without its numerator.
The existing net-worth suites are all single-user, so none of this was
visible to them; the new cases assert the split from both sides — a shared
account stays out of the recipient's net worth, and stays in their balance
history and total balance.
|
Thanks for catching the net-worth one — that was a real defect and the description claim was For the record on how I missed it: I checked "does net worth go through this?" by grepping the All three are in: 1.
2. 3. The net-worth boundary test. Three cases in the same file, and they pin your product line
I verified it fails on the previous head by flipping the four One thing your list doesn't reach
Your product line implies Unrelated, but you should know: a flake I tripped over
So it needs the two files to land in the same worker DB in that order, which is why it comes and |
Fixes #510. Scopes the reports by account access instead of authorship, so they agree
with the balance and the transaction list a recipient already sees.
get-cash-flow.e2e.ts,"shared-account regression: recipient tx using owner category resolves correctly (no "Unknown" leak)":That number was the authorship scoping this PR removes, so it could not survive the fix. The
test's actual subject — a recipient's transaction resolving the owner's category instead of
"Unknown"— is untouched and still asserted. It is the only existing assertion this PRchanges, and if you read it as an intended contract rather than an artifact, then this whole
approach needs rethinking, so please check it before the rest.
Your review, point by point
1. The 6 sites →
{ accessibleTo: userId }—get-expenses-history,get-cash-flow,get-cumulative-data,get-earliest-transaction-date,get-pivot,stats/utils/savings-transactions.2.
planned: 'include'→{ visibleTo: userId }inget-cash-flow.tsandget-expenses-history.ts. Worth recording why it costs a recipient nothing: on a sharedaccount only the owner may plan (
"Only the account owner can create planned transactions"),so
{ visibleTo }keeps the owner's plans out of the recipient's forecast while keeping therecipient's own plans on their own accounts. The two reports now use the same planned
semantics as the transactions list (
get-transactions.ts:115).3.
/stats/balance-history—buildAccountWhereresolves the account set throughgetAccessibleAccountIdsForUserinstead ofAccounts.userId.4. Outside
/stats—payees/payee-stats.tsandsubscriptions/get-subscriptions-summary.ts.Left alone as you asked: budget stats, and the
get-category-transaction-count/deleteCategorypair.Three things that need your call
A —
/stats/balance-history?accountId=takes a different branch.get-balance-history-for-account.tsauthorized withAccounts.findOne({ id: accountId, userId }), so with point 3 applied the endpoint wouldreturn the shared account unscoped but an empty series when asked for that same account by id.
Fixed with
canUserAccessResource(..., read)— beyond the list you gave me, so it is aseparate commit: drop it and the rest still stands.
B —
/stats/total-balancemoves with point 3.getTotalBalancedelegates togetBalanceHistory, so a recipient's total balance now includes the shared account. It followsfrom what you asked for, and it is the one number where "reachable" versus "mine" is a product
decision rather than a consistency fix.
C —
savings-transactions.tsand the "share of savings" card. That file feedsgetInvestmentContributions, where it is the denominator; the numerator isPortfolioTransfers.findAll({ where: { userId } }), owner-only, because portfolios are not ashareable
RESOURCE_TYPE. Widening only the denominator makes a recipient's contribution shareshrink as soon as they have a shared account. Applied as listed — say the word and I'll scope
that one call site back to
{ creator }.What I deliberately did not touch
Net worth stays personal:
get-net-worth-history,get-net-worth-driversandget-combined-balance-historyrun their own owner-scoped account queries and never go throughgetBalanceHistoryRows, so they remain self-consistent. Loans, vehicles, ventures andportfolios are not shareable resource types, so their
userIdscoping is unchanged.Tests
packages/backend/src/services/sharing/shared-account-stats.service.e2e.ts— the owner/recipientscenario from the report, over HTTP, on
tests/helpers/share.ts. Both directions are asserted,because the change widens visibility:
history count both sides (350, not 250), and balance history by account id serves the shared
account;
All eleven fail on
devand pass here.Performance
The widened scope lands on
transactions_account_id_time_idx, added for the read path in20260704000001-add-stats-performance-indexes.ts, and it is the plan the transactions listalready runs. Each report now also pays
getAccessibleAccountIdsForUser— three indexedlookups, the same ones the transactions list already pays.
payee-statsis the exception: itno longer uses the
(userId, payeeId, time DESC)composite, so its doc comment was correctedrather than left saying something untrue.