fix(bank): keep other companies' accounts out of the EB account picker (#2141)

* fix(bank): keep other companies' accounts out of the EB account picker

At one-session banks (SEB) a single BankID consent returns every account
the signer can see across all their companies, so a reconnect from
company A carries company B's accounts. PR #2116 made those arrive
unchecked, labelled and unmirrored; they were still listed in company
A's picker and in the connection's account list in settings, which read
as "the wrong company's data in my books" (user report, Deepgrid group).

- New lib/claimed-accounts.ts: partitionByClaim() splits a connection's
  accounts on claimed_by_company_id; describeClaimedElsewhere() renders
  the one-line Swedish summary. Unit-tested, including the legacy
  double-claim (no flag, stays own) and carried-deselection cases.
- AccountPickerDialog: main list, "Markera alla" and the "x av y valda"
  counter cover own accounts only. Claimed accounts sit behind a
  collapsed "N konton synkas i <bolag>" disclosure (still tickable: a
  claim is a strong hint, not proof of ownership). Row markup extracted
  into renderAccountRow so both lists share it.
- BankConnectionStatus: foreign rows dropped from the details list and
  the "x av y konton synkas" count; one muted summary line instead.

No data or callback changes; brand-new never-claimed accounts still list
unchecked, since Enable Banking's account resource carries no owner org
number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi

* fix(bank): close the review and skeptic findings on the claimed-accounts picker

Review (CodeRabbit): describeClaimedElsewhere decided "same claimant" on
the display name; two companies can share a name. Now keyed on
claimed_by_company_id as well, with a test.

Skeptics (correctness + regression):
- The empty-own-list message asserted "synkas redan i andra bolag" even
  for a consent with no accounts at all (failed connect, nothing ticked
  at the bank). Now only when claimed accounts exist; otherwise a plain
  "inga konton" message.
- "Markera alla" stayed enabled but inert with zero own accounts:
  allSelected is now vacuously true there, so the button disables.
- A claimed account ticked inside the disclosure kept counting after the
  disclosure was collapsed: the disclosure line now names the ticked
  count so the "x av y valda" counter never exceeds what is visible.
- The pending_selection row in settings still counted foreign accounts
  ("3 konton tillgängliga" beside a picker saying none): now own
  accounts, with a dedicated line when everything is claimed elsewhere.
- Claim flags did not survive an in-place renewal (accountsMetadata is
  rebuilt without them and the guard skipped seen-on-row accounts), so
  the sibling's accounts returned to the main list unlabeled on the next
  reconnect. The callback now re-derives the label from a fresh lookup
  for accounts that stay disabled here; released claims clear themselves.
  Two callback tests.

Skeptic (compliance) hardening:
- partitionByClaim requires enabled === false alongside the flag, so a
  flagged-but-enabled row (any future writer) can never hide a syncing
  account.
- The sibling company's name is data-ph-masked on the settings summary
  line and the disclosure line, matching the row label.

Declined: CodeRabbit docstring-coverage warning (repo has no docstring
requirement; the touched functions carry inline rationale comments).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi

* fix(bank): keep a re-stamped sibling claim out of the cash-account mirror

CodeRabbit round 2: the renewal branch that re-derives the claim label
left the account out of guardDisabledUids, so the mirror below still ran
upsertFromPsd2 for it. The first connect never mirrored that account
(#2116), so a renewal would have planted the sibling's IBAN in this
company's cash_accounts and burned a 19xx slot for an account that stays
off. Now excluded like a fresh claim; the renewal test asserts only the
own account is mirrored.

Declined (recorded for the summary): compliance-swarm advisory that the
sibling's account metadata reaches the client. Both companies belong to
the same signed-in user and the data arrives under that user's own PSD2
consent; the ownership decision is already made server-side in the
callback, the picker only renders it. Non-blocking, no cross-user data.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-04 08:54:50 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 11017ef9ee
commit 158108ec01
7 changed files with 469 additions and 106 deletions
@@ -589,7 +589,30 @@ async function finalizeConnection(
priorEnabledByUid.has(account.uid) ||
(normalizedIban ? priorEnabledByIban.has(normalizedIban) : false) ||
pairedPriorUidByNewUid.has(account.uid)
if (seenOnThisRow) continue
if (seenOnThisRow) {
// The carried enabled/disabled state stands. The claim label is
// metadata on top of it: accountsMetadata is rebuilt without the prior
// flags, so without this an in-place renewal would drop the label and
// the picker would list the sibling's accounts as plain unchecked own
// accounts again. Re-stamp it only on an account that stays disabled
// here (an enabled one is the active company's standing state, which
// outranks any claim), and only from a fresh lookup, never from the
// stale prior flag.
if (account.enabled === false && crossCompany !== null) {
const claim = normalizedIban ? crossCompany.claims.get(normalizedIban) : undefined
if (claim) {
account.claimed_by_company_id = claim.companyId
if (claim.companyName) account.claimed_by_company_name = claim.companyName
// Keep it out of the cash_accounts mirror too: the first connect
// never mirrored it (see guardDisabledUids below), and mirroring
// it now would plant the sibling's IBAN in this company's routing
// table and burn a 19xx slot for an account that stays off.
guardDisabledUids.add(account.uid)
claimedCount += 1
}
}
continue
}
if (crossCompany === null) {
// Fail closed: without the claim set a free account cannot be told from