Files
accounted/extensions/general/enable-banking/lib/connection-warning.ts
T
Mattsson 7993700589 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>
2026-08-28 15:31:38 +02:00

142 lines
6.1 KiB
TypeScript

/**
* 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',
}
}