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:
Mattsson
2026-08-28 15:31:38 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 57d4359d1a
commit 7993700589
4 changed files with 373 additions and 29 deletions
@@ -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
}