fix(enable-banking): only hard-warn about sibling connections at observed one-session banks (#2005)
* fix(enable-banking): only hard-warn about sibling connections at observed one-session banks The same-bank warning dialog fired for every bank whenever the user's other companies held a connection, claiming the siblings could stop syncing. That is only true for banks binding one active AIS session per PSU (observed: SEB). Prod shows Handelsbanken tolerates at least four concurrent sessions syncing daily, and the generic warning made a multi-company user abandon a legitimate renewal of an expired Handelsbanken connection. The decision now lives in a pure, tested module (connection-warning.ts): - One-session banks (SEB): hard warning on fresh connect and renewal alike. - Other banks, renewal: no dialog; the connection already coexisted. - Other banks, fresh connect: calm confirmation so a user who meant to renew notices they are about to create a second connection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PUKATngZZzqkoDesyxKR3 * fix(enable-banking): fail closed for banks with unknown session policy, exempt shared-session siblings Skeptic findings on the first cut: - Unknown banks were treated like verified multi-session banks (silent renewal), turning absence of evidence into evidence of absence. They now keep the previous hedged warning on both paths; only Handelsbanken (verified 2026-08-28: 2-4 concurrent connections per user on DISTINCT session_ids, all syncing daily) gets the calm tier. - Siblings sharing the session being renewed are carried by fanOutSessionRenewal and never break, so they no longer trigger or inflate the warning; the absolute SEB claim is accurate for the remaining, genuinely separate sessions. - Company names are deduped and phrasing follows distinct company count (one company holding privat + foretag rows is "ett annat bolag"). - Null bank_name no longer throws (DB column is nullable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PUKATngZZzqkoDesyxKR3 * fix(enable-banking): count clashing companies by id, names are display-only CodeRabbit: two distinct companies sharing a name, or one whose name fails to resolve, undercounted to "ett annat bolag". SameBankClash now carries companyId as identity; the parenthetical name list stays deduped display. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PUKATngZZzqkoDesyxKR3 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
57d4359d1a
commit
7993700589
@@ -35,6 +35,7 @@ import {
|
||||
selectPageAttention,
|
||||
sortConnectionsByPrecedence,
|
||||
} from '../lib/connection-state'
|
||||
import { sameBankWarning } from '../lib/connection-warning'
|
||||
import type { BankConnection } from '@/types'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
@@ -91,7 +92,7 @@ export default function BankingSettingsPanel() {
|
||||
const [reusableSessions, setReusableSessions] = useState<ReusableSessionOffer[]>([])
|
||||
const [attachingConnectionId, setAttachingConnectionId] = useState<string | null>(null)
|
||||
const [otherCompanyConnections, setOtherCompanyConnections] = useState<
|
||||
{ bank_name: string; company_id: string }[]
|
||||
{ bank_name: string; company_id: string; session_id: string | null }[]
|
||||
>([])
|
||||
// Set when the OAuth callback pointed at a connection that belongs to a
|
||||
// different company than the active one: without this the picker simply
|
||||
@@ -228,12 +229,14 @@ export default function BankingSettingsPanel() {
|
||||
// competing for the bank's one-session-per-login slot.
|
||||
const { data: allConnections } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('bank_name, company_id, status')
|
||||
.select('bank_name, company_id, session_id, status')
|
||||
.in('status', ['active', 'pending_selection'])
|
||||
setOtherCompanyConnections(
|
||||
((allConnections || []) as { bank_name: string; company_id: string }[]).filter(
|
||||
(c) => c.company_id !== company.id
|
||||
)
|
||||
((allConnections || []) as {
|
||||
bank_name: string
|
||||
company_id: string
|
||||
session_id: string | null
|
||||
}[]).filter((c) => c.company_id !== company.id)
|
||||
)
|
||||
|
||||
// Reuse offers. Best-effort: a failure here costs the shortcut, never the
|
||||
@@ -274,31 +277,35 @@ export default function BankingSettingsPanel() {
|
||||
|
||||
/**
|
||||
* Warn before authorizing a bank where the same user already holds live
|
||||
* connections in other companies. Several ASPSPs bind one active AIS session
|
||||
* per PSU, so the new authorization silently invalidates the existing ones,
|
||||
* and nothing in the product tells the user until a sync fails days later.
|
||||
* Advisory only: legitimate multi-company setups must still be able to
|
||||
* proceed, so the dialog always offers a working "Fortsätt".
|
||||
* connections in other companies. The decision table lives in
|
||||
* lib/connection-warning.ts: banks with observed one-session-per-PSU
|
||||
* behavior get the hard warning, everything else gets a calm confirmation
|
||||
* on fresh connects and no dialog at all on renewals. Advisory only:
|
||||
* legitimate multi-company setups must still be able to proceed, so the
|
||||
* dialog always offers a working confirm.
|
||||
*/
|
||||
async function confirmSameBankConnections(bankName: string): Promise<boolean> {
|
||||
const clashes = otherCompanyConnections.filter((c) => c.bank_name === bankName)
|
||||
if (clashes.length === 0) return true
|
||||
async function confirmSameBankConnections(
|
||||
bankName: string,
|
||||
isReconnect: boolean,
|
||||
currentSessionId?: string | null,
|
||||
): Promise<boolean> {
|
||||
const clashes = otherCompanyConnections
|
||||
.filter((c) => c.bank_name === bankName)
|
||||
.map((c) => ({
|
||||
companyId: c.company_id,
|
||||
companyName:
|
||||
companies.find((entry) => entry.company.id === c.company_id)?.company.name ?? null,
|
||||
sessionId: c.session_id,
|
||||
}))
|
||||
|
||||
const names = clashes
|
||||
.map((c) => companies.find((entry) => entry.company.id === c.company_id)?.company.name)
|
||||
.filter((name): name is string => !!name)
|
||||
const companyList = names.length > 0 ? ` (${names.join(', ')})` : ''
|
||||
const count = clashes.length
|
||||
|
||||
return confirm({
|
||||
title: `Du har redan ${count} ${count === 1 ? 'anslutning' : 'anslutningar'} till ${bankName}`,
|
||||
description:
|
||||
`${bankName} är sedan tidigare ansluten i ${count === 1 ? 'ett annat bolag' : 'andra bolag'}${companyList}. ` +
|
||||
'Vissa banker tillåter bara en aktiv anslutning per inloggning: när du slutför den här kan de andra sluta synka ' +
|
||||
'och behöva förnyas. Fortsätt om du vet att din bank tillåter flera.',
|
||||
confirmLabel: 'Fortsätt',
|
||||
variant: 'warning',
|
||||
const warning = sameBankWarning({
|
||||
bankName,
|
||||
clashes,
|
||||
isReconnect,
|
||||
currentSessionId,
|
||||
})
|
||||
if (!warning) return true
|
||||
return confirm(warning)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,7 +377,7 @@ export default function BankingSettingsPanel() {
|
||||
// indefinitely, and a second click in that window would otherwise sail
|
||||
// past the guard above and start a concurrent connect flow.
|
||||
connectingRef.current = true
|
||||
if (!(await confirmSameBankConnections(bank.name))) {
|
||||
if (!(await confirmSameBankConnections(bank.name, false))) {
|
||||
connectingRef.current = false
|
||||
return
|
||||
}
|
||||
@@ -442,7 +449,7 @@ export default function BankingSettingsPanel() {
|
||||
if (connectingRef.current) return
|
||||
// Lock before the confirm await, same reason as handleConnectBank.
|
||||
connectingRef.current = true
|
||||
if (!(await confirmSameBankConnections(connection.bank_name))) {
|
||||
if (!(await confirmSameBankConnections(connection.bank_name, true, connection.session_id))) {
|
||||
connectingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
isOneSessionBank,
|
||||
isVerifiedMultiSessionBank,
|
||||
sameBankWarning,
|
||||
type SameBankClash,
|
||||
} from '../connection-warning'
|
||||
|
||||
function clash(over: Partial<SameBankClash> = {}): SameBankClash {
|
||||
return {
|
||||
companyId: 'company-1',
|
||||
companyName: 'Testbrand AB',
|
||||
sessionId: 'sess-other',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('bank tier matching', () => {
|
||||
it('matches SEB regardless of casing and whitespace', () => {
|
||||
expect(isOneSessionBank('SEB')).toBe(true)
|
||||
expect(isOneSessionBank(' seb ')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches Handelsbanken as verified multi-session', () => {
|
||||
expect(isVerifiedMultiSessionBank('Handelsbanken')).toBe(true)
|
||||
expect(isVerifiedMultiSessionBank('handelsbanken')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match banks merely containing the letters, nor null', () => {
|
||||
expect(isOneSessionBank('SEB Kort Bank')).toBe(false)
|
||||
expect(isOneSessionBank(null)).toBe(false)
|
||||
expect(isOneSessionBank(undefined)).toBe(false)
|
||||
expect(isVerifiedMultiSessionBank('Nordea')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameBankWarning', () => {
|
||||
const base = {
|
||||
bankName: 'Handelsbanken',
|
||||
clashes: [clash()],
|
||||
isReconnect: false,
|
||||
}
|
||||
|
||||
it('returns null when nothing clashes', () => {
|
||||
expect(sameBankWarning({ ...base, clashes: [] })).toBeNull()
|
||||
})
|
||||
|
||||
it('is silent when renewing at a verified multi-session bank', () => {
|
||||
// The regression this module fixes: a user abandoned a legitimate
|
||||
// Handelsbanken renewal because the old dialog warned about sibling
|
||||
// connections that HB demonstrably tolerates.
|
||||
expect(sameBankWarning({ ...base, isReconnect: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('calmly confirms a fresh second connection at a verified multi-session bank', () => {
|
||||
const warning = sameBankWarning(base)
|
||||
expect(warning).not.toBeNull()
|
||||
expect(warning?.confirmLabel).toBe('Anslut')
|
||||
expect(warning?.description).not.toContain('sluta synka')
|
||||
expect(warning?.description).toContain('Testbrand AB')
|
||||
})
|
||||
|
||||
it('hard-warns for a one-session bank on fresh connect and reconnect alike', () => {
|
||||
// A renewal also mints a new authorization, which at a one-session bank
|
||||
// revokes the sibling companies' session just like a fresh connect does.
|
||||
for (const isReconnect of [false, true]) {
|
||||
const warning = sameBankWarning({ ...base, bankName: 'SEB', isReconnect })
|
||||
expect(warning?.title).toBe('Du har redan 1 anslutning till SEB')
|
||||
expect(warning?.description).toContain('slutar de andra att synka')
|
||||
expect(warning?.confirmLabel).toBe('Fortsätt ändå')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the hedged warning for banks with unknown session policy, on both paths', () => {
|
||||
// Fail closed: absence of evidence about a bank is not evidence that it
|
||||
// tolerates parallel sessions, so unknown banks keep the old behavior.
|
||||
for (const isReconnect of [false, true]) {
|
||||
const warning = sameBankWarning({ ...base, bankName: 'Nordea', isReconnect })
|
||||
expect(warning).not.toBeNull()
|
||||
expect(warning?.description).toContain('Vissa banker tillåter bara en aktiv anslutning')
|
||||
expect(warning?.description).toContain('kan de andra sluta synka')
|
||||
expect(warning?.confirmLabel).toBe('Fortsätt')
|
||||
}
|
||||
})
|
||||
|
||||
it('exempts siblings sharing the session being renewed', () => {
|
||||
// fanOutSessionRenewal carries the renewed session to every sibling on
|
||||
// it, so they never break and must not trigger the warning: even at SEB,
|
||||
// even at an unknown bank.
|
||||
for (const bankName of ['SEB', 'Nordea']) {
|
||||
const warning = sameBankWarning({
|
||||
bankName,
|
||||
clashes: [clash({ sessionId: 'sess-current' })],
|
||||
isReconnect: true,
|
||||
currentSessionId: 'sess-current',
|
||||
})
|
||||
expect(warning).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('still warns about siblings on OTHER sessions when renewing', () => {
|
||||
const warning = sameBankWarning({
|
||||
bankName: 'SEB',
|
||||
clashes: [
|
||||
clash({ sessionId: 'sess-current', companyName: 'Delad AB' }),
|
||||
clash({ sessionId: 'sess-other', companyName: 'Separat AB' }),
|
||||
],
|
||||
isReconnect: true,
|
||||
currentSessionId: 'sess-current',
|
||||
})
|
||||
expect(warning?.title).toBe('Du har redan 1 anslutning till SEB')
|
||||
expect(warning?.description).toContain('Separat AB')
|
||||
expect(warning?.description).not.toContain('Delad AB')
|
||||
})
|
||||
|
||||
it('never exempts on null session ids', () => {
|
||||
// A null on either side proves nothing about sharing.
|
||||
const warning = sameBankWarning({
|
||||
bankName: 'SEB',
|
||||
clashes: [clash({ sessionId: null })],
|
||||
isReconnect: true,
|
||||
currentSessionId: null,
|
||||
})
|
||||
expect(warning).not.toBeNull()
|
||||
})
|
||||
|
||||
it('dedupes company names and phrases by company count', () => {
|
||||
// One company can hold two connections (privat + företag) to one bank:
|
||||
// that is still "ett annat bolag", named once.
|
||||
const warning = sameBankWarning({
|
||||
bankName: 'SEB',
|
||||
clashes: [
|
||||
clash({ sessionId: 'a' }),
|
||||
clash({ sessionId: 'b' }),
|
||||
],
|
||||
isReconnect: false,
|
||||
})
|
||||
expect(warning?.title).toBe('Du har redan 2 anslutningar till SEB')
|
||||
expect(warning?.description).toContain('ett annat bolag (Testbrand AB)')
|
||||
expect(warning?.description).not.toContain('Testbrand AB, Testbrand AB')
|
||||
})
|
||||
|
||||
it('pluralizes across distinct companies', () => {
|
||||
const warning = sameBankWarning({
|
||||
bankName: 'SEB',
|
||||
clashes: [
|
||||
clash({ companyName: 'Testbrand AB' }),
|
||||
clash({ companyId: 'company-2', companyName: 'Provbolaget AB', sessionId: 'sess-2' }),
|
||||
],
|
||||
isReconnect: false,
|
||||
})
|
||||
expect(warning?.description).toContain('andra bolag (Testbrand AB, Provbolaget AB)')
|
||||
})
|
||||
|
||||
it('counts companies by id, not display name', () => {
|
||||
// Two DISTINCT companies sharing a name, or a company whose name failed
|
||||
// to resolve, must still pluralize: names are display-only.
|
||||
const sameName = sameBankWarning({
|
||||
bankName: 'SEB',
|
||||
clashes: [
|
||||
clash({ companyId: 'company-1', companyName: 'Testbrand AB' }),
|
||||
clash({ companyId: 'company-2', companyName: 'Testbrand AB', sessionId: 'sess-2' }),
|
||||
],
|
||||
isReconnect: false,
|
||||
})
|
||||
expect(sameName?.description).toContain('andra bolag (Testbrand AB)')
|
||||
|
||||
const mixedNamedUnnamed = sameBankWarning({
|
||||
bankName: 'SEB',
|
||||
clashes: [
|
||||
clash({ companyId: 'company-1', companyName: 'Testbrand AB' }),
|
||||
clash({ companyId: 'company-2', companyName: null, sessionId: 'sess-2' }),
|
||||
],
|
||||
isReconnect: false,
|
||||
})
|
||||
expect(mixedNamedUnnamed?.description).toContain('andra bolag (Testbrand AB)')
|
||||
})
|
||||
|
||||
it('omits the company list when no names are known', () => {
|
||||
const warning = sameBankWarning({
|
||||
...base,
|
||||
bankName: 'SEB',
|
||||
clashes: [clash({ companyName: null })],
|
||||
})
|
||||
expect(warning?.description).not.toContain('(')
|
||||
})
|
||||
|
||||
it('survives a null bank name', () => {
|
||||
const warning = sameBankWarning({ ...base, bankName: null })
|
||||
expect(warning).not.toBeNull()
|
||||
expect(warning?.title).toContain('Banken')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Same-bank connection warning: decides IF and HOW to warn before a user
|
||||
* authorizes a bank that already has live connections in their other
|
||||
* companies.
|
||||
*
|
||||
* Three tiers, strictly evidence-based (fail closed for the unknown middle):
|
||||
*
|
||||
* - Banks OBSERVED to bind one active AIS session per PSU get a hard warning
|
||||
* on fresh connect and renewal alike: completing the authorization kills the
|
||||
* sibling sessions bank-side.
|
||||
* - Banks VERIFIED to tolerate concurrent sessions get no dialog on renewal
|
||||
* and a calm confirmation on fresh connect. The old generic scare dialog
|
||||
* made a real multi-company user abandon a legitimate Handelsbanken renewal.
|
||||
* - Everything else keeps the previous hedged warning on both paths: we do
|
||||
* not know the bank's session policy, and silence here would let a renewal
|
||||
* silently kill a sibling company's feed at a one-session bank we have not
|
||||
* identified yet.
|
||||
*
|
||||
* Siblings that SHARE the session being renewed are not warned about at all:
|
||||
* the renewal callback carries the new session to them (fanOutSessionRenewal
|
||||
* in session-sharing.ts), so they keep syncing whatever the bank's policy is.
|
||||
*
|
||||
* Pure module (no React, no fetch) so the decision table is unit-testable.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Banks where prod evidence shows one active AIS session per PSU: authorizing
|
||||
* company B kills company A's session bank-side. Matched against the Enable
|
||||
* Banking ASPSP name stored in bank_connections.bank_name.
|
||||
*/
|
||||
export const ONE_SESSION_BANKS = ['SEB']
|
||||
|
||||
/**
|
||||
* Banks VERIFIED to tolerate several concurrent AIS sessions for one PSU.
|
||||
* Evidence bar: distinct session_ids observed syncing concurrently in prod.
|
||||
* Handelsbanken verified 2026-08-28: three users with 2-4 concurrent business
|
||||
* connections each, all on DISTINCT session_ids, all syncing daily.
|
||||
* Extend only with the same standard of evidence; connection count alone is
|
||||
* not enough, since several connections can share one session.
|
||||
*/
|
||||
export const VERIFIED_MULTI_SESSION_BANKS = ['Handelsbanken']
|
||||
|
||||
function matches(list: readonly string[], bankName: string | null | undefined): boolean {
|
||||
const normalized = (bankName ?? '').trim().toUpperCase()
|
||||
if (!normalized) return false
|
||||
return list.some(known => normalized === known.toUpperCase())
|
||||
}
|
||||
|
||||
export function isOneSessionBank(bankName: string | null | undefined): boolean {
|
||||
return matches(ONE_SESSION_BANKS, bankName)
|
||||
}
|
||||
|
||||
export function isVerifiedMultiSessionBank(bankName: string | null | undefined): boolean {
|
||||
return matches(VERIFIED_MULTI_SESSION_BANKS, bankName)
|
||||
}
|
||||
|
||||
/** One clashing connection in another of the user's companies. */
|
||||
export interface SameBankClash {
|
||||
/** Identity: the company holding the connection. Names are display-only. */
|
||||
companyId: string
|
||||
companyName: string | null
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
export interface SameBankWarningInput {
|
||||
bankName: string | null | undefined
|
||||
/** The user's OTHER companies' live connections to this bank. */
|
||||
clashes: SameBankClash[]
|
||||
/** True when renewing an existing connection, false for a fresh connect. */
|
||||
isReconnect: boolean
|
||||
/** session_id of the connection being renewed, to exempt shared-session
|
||||
* siblings (the renewal is fanned out to them, they never break). */
|
||||
currentSessionId?: string | null
|
||||
}
|
||||
|
||||
export interface SameBankWarning {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
variant: 'warning'
|
||||
}
|
||||
|
||||
/** The dialog to show before proceeding, or null to proceed silently. */
|
||||
export function sameBankWarning(input: SameBankWarningInput): SameBankWarning | null {
|
||||
const { bankName, clashes, isReconnect, currentSessionId } = input
|
||||
|
||||
// Siblings on the session being renewed ride along on the renewal
|
||||
// (fanOutSessionRenewal): they are not at risk and must not inflate the
|
||||
// warning. A null session on either side proves nothing, so it never exempts.
|
||||
const atRisk = currentSessionId
|
||||
? clashes.filter(c => c.sessionId !== currentSessionId)
|
||||
: clashes
|
||||
if (atRisk.length === 0) return null
|
||||
|
||||
const bank = (bankName ?? '').trim() || 'Banken'
|
||||
const names = [...new Set(atRisk.map(c => c.companyName).filter((n): n is string => !!n))]
|
||||
const companyList = names.length > 0 ? ` (${names.join(', ')})` : ''
|
||||
// Phrase by DISTINCT COMPANY IDS, not names: two companies can share a name
|
||||
// and a company without a resolvable name still counts. Names are only the
|
||||
// parenthetical display. One company can legitimately hold two connections
|
||||
// (privat + företag) to the same bank and stays "ett annat bolag".
|
||||
const companyCount = new Set(atRisk.map(c => c.companyId)).size
|
||||
const inOtherCompanies = companyCount === 1 ? 'ett annat bolag' : 'andra bolag'
|
||||
const count = atRisk.length
|
||||
|
||||
if (isOneSessionBank(bank)) {
|
||||
return {
|
||||
title: `Du har redan ${count} ${count === 1 ? 'anslutning' : 'anslutningar'} till ${bank}`,
|
||||
description:
|
||||
`${bank} är sedan tidigare ansluten i ${inOtherCompanies}${companyList}. ` +
|
||||
`${bank} tillåter bara en aktiv anslutning per inloggning: när du slutför den här slutar ` +
|
||||
'de andra att synka och behöver förnyas.',
|
||||
confirmLabel: 'Fortsätt ändå',
|
||||
variant: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerifiedMultiSessionBank(bank)) {
|
||||
if (isReconnect) return null
|
||||
return {
|
||||
title: `${bank} är redan ansluten i ${inOtherCompanies}`,
|
||||
description:
|
||||
`${bank} är sedan tidigare ansluten i ${inOtherCompanies}${companyList}. ` +
|
||||
'Du kan ansluta banken även för det här bolaget; bolagen får varsin koppling. ' +
|
||||
'Om du i stället ville förnya en befintlig koppling gör du det från bolaget som äger den.',
|
||||
confirmLabel: 'Anslut',
|
||||
variant: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown session policy: keep the previous hedged warning on BOTH paths.
|
||||
return {
|
||||
title: `Du har redan ${count} ${count === 1 ? 'anslutning' : 'anslutningar'} till ${bank}`,
|
||||
description:
|
||||
`${bank} är sedan tidigare ansluten i ${inOtherCompanies}${companyList}. ` +
|
||||
'Vissa banker tillåter bara en aktiv anslutning per inloggning: när du slutför den här kan de andra sluta synka ' +
|
||||
'och behöva förnyas. Fortsätt om du vet att din bank tillåter flera.',
|
||||
confirmLabel: 'Fortsätt',
|
||||
variant: 'warning',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user