Files
MattssonandClaude Fable 5.1 158108ec01 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>
2026-09-04 08:54:50 +02:00

402 lines
16 KiB
TypeScript

'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn, formatDate } from '@/lib/utils'
import { ChevronRight, Loader2, MoreHorizontal } from 'lucide-react'
import { getConnectionUiState } from '../lib/connection-state'
import { describeClaimedElsewhere, partitionByClaim } from '../lib/claimed-accounts'
import type { BankConnection } from '@/types'
interface BankConnectionStatusProps {
connection: BankConnection
onSync: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
onReconnect?: (connection: BankConnection, psuType?: 'personal' | 'business') => void
onManageAccounts?: (connectionId: string) => void
isSyncing?: boolean
}
/**
* One bank connection as a flat hairline row (Fönster settings language):
* identity + state on one line, exactly ONE primary action decided by the
* derived UI state, every secondary action behind a "..." menu, and the
* details (accounts, IBAN, balances, initial backfill) behind a collapsed
* disclosure. The page-level attention sentence lives in the panel, not
* here (design convention 6: one .attn per page).
*/
export function BankConnectionStatus({
connection,
onSync,
onDisconnect,
onReconnect,
onManageAccounts,
isSyncing = false,
}: BankConnectionStatusProps) {
const [now] = useState(() => Date.now())
const [detailsOpen, setDetailsOpen] = useState(false)
const uiState = getConnectionUiState(connection, now)
// Parse accounts from connection
const accounts = (connection.accounts_data as Array<{
uid: string
iban?: string
name?: string
currency: string
balance?: number
balance_updated_at?: string
enabled?: boolean
claimed_by_company_id?: string
claimed_by_company_name?: string
}>) || []
// Accounts another of the user's companies books (one SEB consent covers
// every company the signer represents) are not this company's accounts:
// they are left out of the list and the count, and summarised in one line.
const { own: ownAccounts, claimedElsewhere } = partitionByClaim(accounts)
const enabledCount = ownAccounts.filter((a) => a.enabled !== false).length
function formatBalanceAge(updatedAt: string): string {
const hoursAgo = Math.floor((now - new Date(updatedAt).getTime()) / (1000 * 60 * 60))
if (hoursAgo < 1) return 'Nyss uppdaterat'
if (hoursAgo < 24) return `${hoursAgo}h sedan`
const daysAgo = Math.floor(hoursAgo / 24)
return `${daysAgo}d sedan`
}
// In-flight authorization: the row exists but the user is still at the
// bank. Render it as a quiet spinner row instead of hiding it (the connect
// button lock alone made this state invisible).
if (uiState === 'pending') {
return (
<div className="flex min-h-10 flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-1 py-3">
<span className="text-sm font-medium">{connection.bank_name}</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Väntar på banken…
</span>
<span className="ml-auto shrink-0">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onDisconnect(connection.id)}
>
Avbryt
</Button>
</span>
</div>
)
}
// Status display: muted text for the normal state, Badge only when the row
// deviates (design convention 5).
type StatusEntry =
| { kind: 'text'; label: string }
| { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' }
const statusDisplay: StatusEntry = (() => {
switch (uiState) {
case 'pending_selection':
return { kind: 'badge', label: 'Välj konton', variant: 'warning' }
case 'error':
return { kind: 'badge', label: 'Fel', variant: 'destructive' }
case 'expired':
return { kind: 'badge', label: 'Utgånget samtycke', variant: 'warning' }
case 'expiring':
return { kind: 'badge', label: 'Går ut snart', variant: 'warning' }
default:
return { kind: 'text', label: 'Aktiv' }
}
})()
const isExpired = uiState === 'expired'
const canReconnect = !!onReconnect
const canSync = connection.status === 'active' || connection.status === 'error'
// Exactly ONE primary action per state; everything else goes in the menu.
function renderPrimaryAction() {
switch (uiState) {
case 'pending_selection':
return onManageAccounts ? (
<Button size="sm" onClick={() => onManageAccounts(connection.id)}>
Välj konton
</Button>
) : null
case 'error':
return (
<Button size="sm" onClick={() => onSync(connection.id)} disabled={isSyncing}>
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Försök igen
</Button>
)
case 'expired':
case 'expiring':
// No psu override: the server reuses the stored psu_type, so renewal
// is one click. Switching account type lives in the menu.
return canReconnect ? (
<Button size="sm" onClick={() => onReconnect!(connection)}>
Förnya samtycke
</Button>
) : null
case 'stale':
case 'never_synced':
return (
<Button size="sm" onClick={() => onSync(connection.id)} disabled={isSyncing}>
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Synka nu
</Button>
)
default:
// Healthy active row: no primary needed; sync stays reachable as a
// quiet ghost button.
return (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)}
disabled={isSyncing}
>
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Synka
</Button>
)
}
}
const primaryIsSync = uiState === 'stale' || uiState === 'never_synced' || uiState === 'active' || uiState === 'error'
const primaryIsReconnect = uiState === 'expired' || uiState === 'expiring'
return (
<div className="border-b border-border px-1 py-3">
{/* Main line: identity + state left, one primary action + menu right */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium">{connection.bank_name}</span>
{statusDisplay.kind === 'badge' ? (
<Badge variant={statusDisplay.variant}>{statusDisplay.label}</Badge>
) : (
<span className="text-xs text-muted-foreground">{statusDisplay.label}</span>
)}
{uiState === 'pending_selection' ? (
<span className="text-xs text-muted-foreground">
{ownAccounts.length > 0
? `${ownAccounts.length} konton tillgängliga: inga transaktioner synkas ännu`
: claimedElsewhere.length > 0
? 'Alla konton i kopplingen synkas redan i andra bolag'
: 'Inga konton i kopplingen: inga transaktioner synkas ännu'}
</span>
) : (
<>
{connection.last_synced_at && (
<span className="text-xs text-muted-foreground tabular-nums">
Synkad {formatDate(connection.last_synced_at)}
</span>
)}
{connection.consent_expires && !isExpired && (
<span className="text-xs text-muted-foreground tabular-nums">
Samtycke till {formatDate(connection.consent_expires)}
</span>
)}
</>
)}
<span className="ml-auto flex shrink-0 items-center gap-1">
{renderPrimaryAction()}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label={`Fler åtgärder för ${connection.bank_name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{uiState === 'pending_selection' ? (
<DropdownMenuItem onSelect={() => onDisconnect(connection.id)}>
Avbryt
</DropdownMenuItem>
) : (
<>
{onManageAccounts && (
<DropdownMenuItem onSelect={() => onManageAccounts(connection.id)}>
Välj konton
</DropdownMenuItem>
)}
{canSync && !primaryIsSync && (
<DropdownMenuItem onSelect={() => onSync(connection.id)}>
Synka
</DropdownMenuItem>
)}
{canReconnect && !primaryIsReconnect && (
<DropdownMenuItem onSelect={() => onReconnect!(connection)}>
Förnya samtycke
</DropdownMenuItem>
)}
{canReconnect && (
<>
<DropdownMenuSeparator />
{/* Some banks (notably Handelsbanken) only sign with one
account type: keep the explicit choice reachable even
though the primary renew reuses the stored type. */}
<DropdownMenuLabel className="text-xs font-normal normal-case tracking-normal">
Förnya och logga in som
</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => onReconnect!(connection, 'business')}>
Företagskonto
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onReconnect!(connection, 'personal')}>
Privatkonto
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/import?mode=bank">Importera bankfil</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => onDisconnect(connection.id)}
>
Koppla från
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
{/* Error detail: the page-level .attn owns the ochre sentence; the
row's own message stays quiet. */}
{uiState === 'error' && connection.error_message && (
<p className="mt-1 text-[12.5px] leading-relaxed text-muted-foreground">
{connection.error_message}
</p>
)}
{/* Details behind a collapsed disclosure: accounts, IBAN, balances,
initial backfill. Expired rows never show balances (stale numbers
would read as current). */}
{accounts.length > 0 && uiState !== 'pending_selection' && (
<div className="mt-1">
<button
type="button"
onClick={() => setDetailsOpen((v) => !v)}
aria-expanded={detailsOpen}
className="flex min-h-9 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronRight
className={cn('h-3.5 w-3.5 transition-transform duration-150', detailsOpen && 'rotate-90')}
/>
<span className="tabular-nums">
{enabledCount} av {ownAccounts.length} konton synkas
</span>
</button>
{detailsOpen && (
<div className="ml-3 border-l border-border pl-4">
{/* Initial backfill summary: what the bank actually returned vs
what we asked for. Diagnostics, so it lives in the details. */}
{!isExpired &&
connection.initial_sync_completed_at &&
connection.initial_sync_requested_from &&
(() => {
const requested = connection.initial_sync_requested_from
const min = connection.initial_sync_returned_min_date
const max = connection.initial_sync_returned_max_date
// Truncation = bank returned less history than requested.
// 7-day grace for off-by-one + weekend posting differences.
let truncated = false
if (min && requested) {
const requestedTime = new Date(requested).getTime()
const minTime = new Date(min).getTime()
truncated = minTime - requestedTime > 7 * 24 * 60 * 60 * 1000
}
return (
<div className="flex flex-wrap items-center gap-2 py-2 text-xs text-muted-foreground">
<span>
Initial historik:{' '}
<span className="tabular-nums">
{min ? formatDate(min) : '-'} → {max ? formatDate(max) : '-'}
</span>{' '}
(begärde <span className="tabular-nums">{formatDate(requested)}</span>)
</span>
{truncated && (
<Badge variant="outline">
Bankens API returnerade kortare period än begärt: använd SIE-import för äldre data
</Badge>
)}
</div>
)
})()}
{ownAccounts.map((account) => {
const isDisabled = account.enabled === false
return (
<div
key={account.uid}
className={cn(
'flex flex-wrap items-center gap-x-3 gap-y-1 py-2',
isDisabled && 'opacity-60',
)}
>
<span className="text-sm">
{account.name || account.iban || 'Okänt konto'}
</span>
{isDisabled && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground">
Synkas ej
</Badge>
)}
{account.iban && (
<span className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</span>
)}
{!isExpired && account.balance !== undefined && (
<span className="ml-auto inline-flex shrink-0 items-baseline gap-2">
{account.balance_updated_at && (
<span className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</span>
)}
<span className="text-sm tabular-nums">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</span>
</span>
)}
</div>
)
})}
{/* Sibling companies' accounts carried by this consent: one
muted line, never rows. Moving one here is done in the
account picker ("Hantera konton"), where it is a deliberate
tick inside a disclosure. */}
{claimedElsewhere.length > 0 && (
<div className="py-2 text-xs text-muted-foreground tabular-nums" data-ph-mask="">
{describeClaimedElsewhere(claimedElsewhere)}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}