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:
co-authored by
Claude Fable 5.1
parent
11017ef9ee
commit
158108ec01
@@ -559,6 +559,94 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-stamps the sibling claim on a disabled account across an in-place renewal', async () => {
|
||||
// accountsMetadata is rebuilt from the bank's list on every callback, so a
|
||||
// claim label stored on the previous consent would be lost on renewal and
|
||||
// the sibling's account would come back as a plain unchecked own account.
|
||||
const capturedUpdates = mockConnectionFlow({
|
||||
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'expired',
|
||||
accounts_data: [
|
||||
{ uid: 'acc-own-old', iban: 'SE1234', name: 'Företagskonto', currency: 'SEK', enabled: true },
|
||||
{
|
||||
uid: 'acc-foreign-old', iban: 'SE9999', name: 'Annat bolags konto', currency: 'SEK',
|
||||
enabled: false, claimed_by_company_id: 'company-2', claimed_by_company_name: 'Other AB',
|
||||
},
|
||||
],
|
||||
})
|
||||
mockCrossCompanyContext.mockResolvedValue({
|
||||
claims: new Map([['SE9999', { companyId: 'company-2', companyName: 'Other AB' }]]),
|
||||
deselectedIbans: new Set(),
|
||||
activeCompanyIbans: new Set(['SE1234']),
|
||||
})
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
accounts: [
|
||||
{ uid: 'acc-own-new', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
|
||||
{ uid: 'acc-foreign-new', account_id: { iban: 'SE9999' }, name: 'Annat bolags konto', currency: 'SEK' },
|
||||
],
|
||||
access: { valid_until: '2027-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'SEB', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
await response.text()
|
||||
|
||||
const accountsData = capturedUpdates[0].accounts_data as Array<{
|
||||
uid: string
|
||||
enabled: boolean
|
||||
claimed_by_company_id?: string
|
||||
claimed_by_company_name?: string
|
||||
}>
|
||||
const own = accountsData.find(a => a.uid === 'acc-own-new')
|
||||
const foreign = accountsData.find(a => a.uid === 'acc-foreign-new')
|
||||
expect(own?.enabled).toBe(true)
|
||||
expect(own?.claimed_by_company_id).toBeUndefined()
|
||||
expect(foreign?.enabled).toBe(false)
|
||||
expect(foreign?.claimed_by_company_id).toBe('company-2')
|
||||
expect(foreign?.claimed_by_company_name).toBe('Other AB')
|
||||
// The re-stamped claim stays out of the mirror exactly like a fresh one:
|
||||
// only the own account gets a cash_accounts row.
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
|
||||
expect((mockUpsertFromPsd2.mock.calls[0][2] as { external_uid: string }).external_uid).toBe('acc-own-new')
|
||||
})
|
||||
|
||||
it('drops a stale claim on renewal when the sibling no longer books the account', async () => {
|
||||
// The label is re-derived from a fresh lookup, never copied from the prior
|
||||
// row: once company B has released the IBAN, company A's picker must show
|
||||
// it as a plain unchecked account again, not as "synkas i B".
|
||||
const capturedUpdates = mockConnectionFlow({
|
||||
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'expired',
|
||||
accounts_data: [
|
||||
{
|
||||
uid: 'acc-old', iban: 'SE9999', name: 'Konto', currency: 'SEK',
|
||||
enabled: false, claimed_by_company_id: 'company-2', claimed_by_company_name: 'Other AB',
|
||||
},
|
||||
],
|
||||
})
|
||||
mockCrossCompanyContext.mockResolvedValue({
|
||||
claims: new Map(),
|
||||
deselectedIbans: new Set(),
|
||||
activeCompanyIbans: new Set(),
|
||||
})
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
accounts: [{ uid: 'acc-new', account_id: { iban: 'SE9999' }, name: 'Konto', currency: 'SEK' }],
|
||||
access: { valid_until: '2027-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'SEB', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
await response.text()
|
||||
|
||||
const accountsData = capturedUpdates[0].accounts_data as Array<{
|
||||
uid: string
|
||||
enabled: boolean
|
||||
claimed_by_company_id?: string
|
||||
}>
|
||||
expect(accountsData[0].enabled).toBe(false)
|
||||
expect(accountsData[0].claimed_by_company_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries a deselection made on another connection row onto a fresh connect', async () => {
|
||||
// C2: "Synkas ej" chosen under one company must not come back pre-checked
|
||||
// when a new connection row (any company) sees the same IBAN.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user