* 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>
402 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|