* 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>
98 lines
4.4 KiB
TypeScript
98 lines
4.4 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
|
|
import { describeClaimedElsewhere, partitionByClaim } from '../claimed-accounts'
|
|
import type { StoredAccount } from '../../types'
|
|
|
|
function account(over: Partial<StoredAccount> & { uid: string }): StoredAccount {
|
|
return { currency: 'SEK', ...over }
|
|
}
|
|
|
|
describe('partitionByClaim', () => {
|
|
it('keeps unclaimed accounts in the main list and moves claimed ones aside', () => {
|
|
const own1 = account({ uid: 'a', iban: 'SE1' })
|
|
const own2 = account({ uid: 'b', iban: 'SE2', enabled: false })
|
|
const foreign = account({
|
|
uid: 'c',
|
|
iban: 'SE3',
|
|
enabled: false,
|
|
claimed_by_company_id: 'company-b',
|
|
claimed_by_company_name: 'Testbrand Holding AB',
|
|
})
|
|
|
|
const { own, claimedElsewhere } = partitionByClaim([own1, foreign, own2])
|
|
|
|
expect(own).toEqual([own1, own2])
|
|
expect(claimedElsewhere).toEqual([foreign])
|
|
})
|
|
|
|
it('treats a legacy double-claim (no flag, enabled here) as own', () => {
|
|
// The callback lets the active company's standing state outrank a sibling
|
|
// claim, so such an account carries no flag: it must stay visible.
|
|
const legacy = account({ uid: 'a', iban: 'SE1', enabled: true })
|
|
expect(partitionByClaim([legacy]).own).toEqual([legacy])
|
|
expect(partitionByClaim([legacy]).claimedElsewhere).toEqual([])
|
|
})
|
|
|
|
it('keeps a flagged account visible when it is enabled here (invariant breach must not hide a syncing feed)', () => {
|
|
const flaggedButEnabled = account({ uid: 'a', iban: 'SE1', enabled: true, claimed_by_company_id: 'x' })
|
|
const flaggedNoEnabledField = account({ uid: 'b', iban: 'SE2', claimed_by_company_id: 'x' })
|
|
const { own, claimedElsewhere } = partitionByClaim([flaggedButEnabled, flaggedNoEnabledField])
|
|
// enabled missing means enabled (back-compat default), so both stay own.
|
|
expect(own).toEqual([flaggedButEnabled, flaggedNoEnabledField])
|
|
expect(claimedElsewhere).toEqual([])
|
|
})
|
|
|
|
it('treats a carried deselection without a claim as own', () => {
|
|
const deselected = account({ uid: 'a', iban: 'SE1', enabled: false, deselected_elsewhere: true })
|
|
expect(partitionByClaim([deselected]).own).toEqual([deselected])
|
|
})
|
|
|
|
it('preserves order within each partition and handles an empty list', () => {
|
|
expect(partitionByClaim([])).toEqual({ own: [], claimedElsewhere: [] })
|
|
const rows = ['1', '2', '3', '4'].map((n, i) =>
|
|
account({ uid: n, enabled: false, claimed_by_company_id: i % 2 ? 'x' : undefined }),
|
|
)
|
|
const { own, claimedElsewhere } = partitionByClaim(rows)
|
|
expect(own.map((a) => a.uid)).toEqual(['1', '3'])
|
|
expect(claimedElsewhere.map((a) => a.uid)).toEqual(['2', '4'])
|
|
})
|
|
})
|
|
|
|
describe('describeClaimedElsewhere', () => {
|
|
it('names the claimant when every account belongs to the same company', () => {
|
|
const rows = [
|
|
account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
|
account({ uid: 'b', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
|
]
|
|
expect(describeClaimedElsewhere(rows)).toBe('2 konton synkas i Testbrand AB')
|
|
})
|
|
|
|
it('uses the singular noun for one account', () => {
|
|
const rows = [account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' })]
|
|
expect(describeClaimedElsewhere(rows)).toBe('1 konto synkas i Testbrand AB')
|
|
})
|
|
|
|
it('falls back to a generic phrase when claimants differ or a name is missing', () => {
|
|
const mixed = [
|
|
account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
|
account({ uid: 'b', claimed_by_company_id: 'y', claimed_by_company_name: 'Testbrand Holding AB' }),
|
|
]
|
|
expect(describeClaimedElsewhere(mixed)).toBe('2 konton synkas i andra bolag')
|
|
|
|
const unnamed = [account({ uid: 'a', claimed_by_company_id: 'x' })]
|
|
expect(describeClaimedElsewhere(unnamed)).toBe('1 konto synkas i ett annat bolag')
|
|
})
|
|
|
|
it('does not merge two different companies that share a display name', () => {
|
|
const sameNameDifferentIds = [
|
|
account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
|
account({ uid: 'b', claimed_by_company_id: 'y', claimed_by_company_name: 'Testbrand AB' }),
|
|
]
|
|
expect(describeClaimedElsewhere(sameNameDifferentIds)).toBe('2 konton synkas i andra bolag')
|
|
})
|
|
|
|
it('returns an empty string for no accounts', () => {
|
|
expect(describeClaimedElsewhere([])).toBe('')
|
|
})
|
|
})
|