Skip to content

fix(stats): scope reports by account access instead of authorship - #520

Merged
letehaha merged 4 commits into
letehaha:devfrom
mariofox:fix/stats-shared-accounts
Aug 28, 2026
Merged

letehaha merged 4 commits into
letehaha:devfrom
mariofox:fix/stats-shared-accounts

Conversation

@mariofox

Copy link
Copy Markdown
Contributor

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.

⚠️ One existing expectation changes — please look at this first

get-cash-flow.e2e.ts, "shared-account regression: recipient tx using owner category resolves correctly (no "Unknown" leak)":

-    expect(period.expenses).toBe(20); // only recipient's tx ($20 expense)
+    expect(period.expenses).toBe(50); // owner's $30 + recipient's $20

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 PR
changes, 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 } in get-cash-flow.ts and
get-expenses-history.ts. Worth recording why it costs a recipient nothing: on a shared
account 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 the
recipient'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 — buildAccountWhere resolves the account set through
getAccessibleAccountIdsForUser instead of Accounts.userId.

4. Outside /stats — payees/payee-stats.ts and
subscriptions/get-subscriptions-summary.ts.

Left alone as you asked: budget stats, and the get-category-transaction-count /
deleteCategory pair.

Three things that need your call

A — /stats/balance-history?accountId= takes a different branch.
get-balance-history-for-account.ts authorized with
Accounts.findOne({ id: accountId, userId }), so with point 3 applied the endpoint would
return 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 a
separate commit
: drop it and the rest still stands.

B — /stats/total-balance moves with point 3. getTotalBalance delegates to
getBalanceHistory, so a recipient's total balance now includes the shared account. It follows
from 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.ts and the "share of savings" card. That file feeds
getInvestmentContributions, where it is the denominator; the numerator is
PortfolioTransfers.findAll({ where: { userId } }), owner-only, because portfolios are not a
shareable RESOURCE_TYPE. Widening only the denominator makes a recipient's contribution share
shrink 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-drivers and
get-combined-balance-history run their own owner-scoped account queries and never go through
getBalanceHistoryRows, so they remain self-consistent. Loans, vehicles, ventures and
portfolios are not shareable resource types, so their userId scoping is unchanged.

Tests

packages/backend/src/services/sharing/shared-account-stats.service.e2e.ts — the owner/recipient
scenario from the report, over HTTP, on tests/helpers/share.ts. Both directions are asserted,
because the change widens visibility:

  • the recipient's expenses-amount, cash-flow, cumulative, pivot, earliest-date and balance
    history count both sides (350, not 250), and balance history by account id serves the shared
    account;
  • a user with no share still sees nothing;
  • the owner's planned rows stay out of the recipient's cash flow;
  • the recipient's own plans on their own accounts stay in;
  • the owner still sees their own plans on the account they share out.

All eleven fail on dev and pass here.

Performance

The widened scope lands on transactions_account_id_time_idx, added for the read path in
20260704000001-add-stats-performance-indexes.ts, and it is the plan the transactions list
already runs. Each report now also pays getAccessibleAccountIdsForUser — three indexed
lookups, the same ones the transactions list already pays. payee-stats is the exception: it
no longer uses the (userId, payeeId, time DESC) composite, so its doc comment was corrected
rather than left saying something untrue.

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.
@mariofox

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

All contributors have signed the CLA. Thank you!
Posted by the CLA Assistant Lite bot.

github-actions Bot added a commit that referenced this pull request Aug 24, 2026
@mariofox

Copy link
Copy Markdown
Contributor Author

recheck

@letehaha

Copy link
Copy Markdown
Owner

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 20 → 50 in get-cash-flow.e2e.ts is fine. That 20 was exactly the authorship scoping this PR removes, and the test's actual subject (category resolution, not "Unknown") is untouched. No concerns there.

Your three questions:

A – keep it. canUserAccessResource is the right gate there, and thanks for splitting it into a separate commit.

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 (get-total-balance.ts:27), so the two have to agree. The product line I want to hold overall: balance surfaces (balance history, total balance) = what you can reach; net-worth surfaces = what you own.

C – you were right to flag it, and it's actually bigger than the denominator mismatch. Please revert savings-transactions.ts back to { creator }. That one's on me – it was in my original list – but both of its consumers are personal surfaces:

  1. get-investment-contributions – the numerator is owner-only PortfolioTransfers, exactly as you described
  2. get-net-worth-drivers/index.ts:543 – savings intake for net worth, which should stay personal (see below)

Now the one real problem:

Net worth doesn't actually stay personal. The "never go through getBalanceHistoryRows" part of the description isn't right – getAggregatedBalanceHistory and getPerAccountBalanceHistory live in the same get-balance-history.ts and both delegate to getBalanceHistoryRows, so the widened buildAccountWhere reaches:

  • get-net-worth-history/index.ts:245,253,258,276 – all four account partitions
  • get-net-worth-drivers/index.ts:554 – the cash component
  • get-combined-balance-history/index.ts:378,397

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:

  • loan opening back-fill: Accounts.findAll({ userId, accountCategory: loan }) stays owner-scoped (get-net-worth-history/index.ts:270, get-combined-balance-history/index.ts:391), so a shared loan-category account contributes Balances rows but never its opening balance
  • getCreditLimitAdjustment({ userId }) is owner-scoped against a series that now includes shared credit cards

What I'd ask for to finish this up:

  1. Make the scope explicit: thread an accountScope: 'accessible' | 'owned' param through getBalanceHistory / getAggregatedBalanceHistory / getPerAccountBalanceHistory down to buildAccountWhere, with no default so every caller states it. accessible for /stats/balance-history + getTotalBalance, owned for the three net-worth services.
  2. Revert savings-transactions.ts to { creator } (point C above).
  3. One recipient-side e2e on a net-worth endpoint proving a shared account doesn't move it – a mirror of the "boundary holds" section you already have.

Everything else checks out: { visibleTo } matches the transactions list exactly, excludeFromStats survives everywhere, and the e2e suite is exactly the shape I hoped for. With those three items this is ready to merge 👍

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.
@mariofox

Copy link
Copy Markdown
Contributor Author

Thanks for catching the net-worth one — that was a real defect and the description claim was
mine to get right.

For the record on how I missed it: I checked "does net worth go through this?" by grepping the
three services for getBalanceHistoryRows, which is the private helper, and they don't name it —
they call getAggregatedBalanceHistory / getPerAccountBalanceHistory in the same file, which
do. One hop too shallow, and the conclusion inverted. Worth noting the suite couldn't have saved
me either: get-net-worth-history, get-net-worth-drivers, get-combined-balance-history,
loans-in-net-worth and credit-limit-in-stats contain zero uses of signUpSecondUser or
createShareInvitation, so they are all single-user and the widening was invisible to them.
Which is exactly why your item 3 was the right thing to ask for.

All three are in:

1. accountScope, no default. export type AccountScope = 'accessible' | 'owned', threaded
through getBalanceHistory / getAggregatedBalanceHistory / getPerAccountBalanceHistory into
buildAccountWhere. Making it required rather than defaulted meant the compiler enumerated the
call sites instead of me, and it came to exactly nine:

  • accessible — stats.controller.ts:51, get-total-balance.ts:27
  • owned — get-net-worth-history.ts:245,253,258,276, get-net-worth-drivers.ts:554,
    get-combined-balance-history.ts:378,397

'owned' restores the literal previous predicate ({ userId, excludeFromStats: false }), so the
two symptoms you named resolve with it: the loan opening back-fill and
getCreditLimitAdjustment are owner-scoped again on those surfaces, matching their series.

2. savings-transactions.ts back to { creator }. Thanks for the second consumer — I had
only traced get-investment-contributions and missed get-net-worth-drivers/index.ts:543. The
comment now records both reasons so it doesn't get widened again by someone reading only the
/stats/* rule.

3. The net-worth boundary test. Three cases in the same file, and they pin your product line
against itself rather than just asserting a zero:

  • a shared account stays out of the recipient's net worth (assets.cash and assetsTotal are 0
    on every point)
  • the same account does show in their balance history and total balance
  • the owner still sees it in their own net worth

I verified it fails on the previous head by flipping the four owneds back to accessible:
Expected: 0, Received: 5000 — the recipient's net worth absorbing the whole shared balance.

One thing your list doesn't reach

getTotalBalance is balance series − getCreditLimitAdjustment({ userId }). The series is now
accessible, the adjustment is still owner-only, so for a recipient with a shared credit card the
card's balance lands in the series but its limit is never subtracted. On
get-combined-balance-history:408 this self-heals — that one is owned now — but
get-total-balance.ts:28 still mixes the two.

Your product line implies getCreditLimitAdjustment should take the same accountScope
(accessible for total balance, owned for combined), but that's a product call and it wasn't in
your list, so I left it alone rather than assume. Happy to add it here or leave it for a separate
PR — say which and I'll do it.

Unrelated, but you should know: a flake I tripped over

get-net-worth-drivers.e2e.ts → "values a foreign-currency holding through the USD-pivot
cross-rate"
failed in two of my full-suite runs and passed in isolation every time, including on
the commit before this one. It is not this PR, and the mechanism is worth having:

initialize-historical-rates.e2e.ts leaves rows in ExchangeRates. Its afterEach restores the
seed set only if (currentCount < originalSeedCount) — it undoes tests that delete rates, but
nothing removes the ones initializeHistoricalRates adds. Running that file and then dumping
the table gives:

USD->AED  2026-01-30  3.672898
USD->AED  2026-01-31  3.672898

3.672898 is AED_PER_USD from tests/mocks/exchange-rates/data.ts, and both dates land inside
the JAN window the drivers test uses. Its beforeEach prunes only date < JAN.start, so an
in-window leak walks straight through, and the bucket reads at period end — 2026-01-31 — picking
3.672898 over the rate 4 the test seeded: 10 x 120 x 3.672898 = 4407.48 -> 4407, which is
the number that shows up.

So it needs the two files to land in the same worker DB in that order, which is why it comes and
goes with Jest's scheduling and why sharded CI may or may not show it. I left both files alone —
out of scope here — but the smallest fix is widening that beforeEach to the whole window, and
the more general one is having initialize-historical-rates.e2e.ts clean up what it adds.

@letehaha
letehaha self-requested a review August 28, 2026 11:53
@letehaha
letehaha merged commit d7f9194 into letehaha:dev Aug 28, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stats endpoints on shared accounts count only the caller's own transactions (balance and transaction list disagree with reports)

2 participants