fix(enable-banking): one primary action per connection state on /settings/banking (#1727)

Restructure the banking settings page so every connection state has a
clear hierarchy:

- One "Dina bankkopplingar" group sorted by state precedence
  (pending_selection, pending, error, expired, expiring soon, active),
  replacing the three-way group split. State derivation, sorting and
  worst-state selection live in a pure, unit-tested helper
  (lib/connection-state.ts).
- Exactly one page-level .attn sentence for the worst state, or none;
  the BankSyncStatusChip is removed from this page (it linked to
  itself; it stays on /transactions and /import).
- Each row shows one primary action per state (Valj konton, Forsok
  igen, Fornya samtycke, Synka nu); everything else moves into a "..."
  menu, and details (accounts, IBAN, balances, initial historik) sit
  behind a collapsed disclosure. Expired rows never show balances.
- Expiring-soon active rows get a "Fornya samtycke" primary that
  reconnects without a psu-type override (the server reuses the stored
  psu_type); the explicit account-type choice stays in the menu.
- "Anslut ny bank" collapses behind one outline "Anslut en bank till"
  button whenever a non-revoked connection exists; the reuse-session
  group only shows while the connect-new surface is visible.
- Fresh connects to an already-connected bank are intercepted with a
  renew-instead dialog; "Anslut som ny" proceeds with force_new: true
  for the upcoming server-side 409 guard.
- In-flight 'pending' rows render as a spinner row ("Vantar pa banken")
  instead of being invisible.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:07:04 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent e1805125af
commit 1ded1af8fe
6 changed files with 759 additions and 325 deletions
+1
View File
@@ -1090,3 +1090,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-19] Removed PeriodiseringAutoDetectToggle (settings > Automatik) instead of wiring it: the localStorage key it wrote (periodisering_autodetect_enabled) had no reader anywhere, so it advertised "automatisk periodiseringsdetektering" while changing nothing; auto-detect is already best-effort and review-gated in the wizard, so the row is now a plain link to the periodisering wizard. Deleting the key is safe: it was write-only.
[2026-08-19] Periodisering auto-detect materiality floor uses entity_type as a K1 proxy: no stored flag distinguishes förenklat årsbokslut (K1, BFNAR 2006:1) from full årsbokslut (BFNAR 2017:3) for enskild firma, so every EF gets K1 wording and every AB gets K2, always advisory ("behöver normalt inte"), never prohibitive. Suggestions under 5 000 kr are tagged low-confidence (unticked) rather than dropped because the relief is a MAY, not a MUST; personnel-cost lines (7xxx) are exempt from the floor since K1/K2 require personnel costs to always be accrued.
[2026-08-19] Hem build-assistant hero downgraded to the quiet-sentence pattern (AgentPromo, matches SkatteverketPromoCard; founder direction 2026-08-18 'redesign first, maybe remove later'): dismissal is per-company localStorage (erp_agent_promo_dismissed:<companyId>) like the SKV promo, gate and hasAi/billing routing unchanged.
[2026-08-19] Banking settings UI state derives from a pure helper (extensions/general/enable-banking/lib/connection-state.ts), not inline JSX conditions: sort precedence, the single page-level .attn sentence, and each row's one primary action must agree on which state a connection is in, and only a pure module can unit-test that. The same-bank connect intercept excludes 'pending' rows (an in-flight authorization is not a renewable connection) and the fresh-connect body sends force_new: true after the intercept so the parallel 409 server guard can distinguish deliberate second connections; 'pending' rows now render as a spinner row ("Väntar på banken") for their whole lifetime instead of only locking the connect button for 30 s, since an invisible in-flight row was the confusion.
@@ -10,7 +10,6 @@ import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
const BankingPanel = getSettingsPanel('enable-banking')
@@ -124,14 +123,10 @@ export function BankingSettingsContent() {
)}
{hasBankingExtension && BankingPanel ? (
<>
{/* The chip renders null when there are no connections; empty:hidden
keeps its margin from leaving a stray gap in that case. */}
<div className="mt-6 empty:hidden">
<BankSyncStatusChip />
</div>
<BankingPanel />
</>
// No BankSyncStatusChip here: on this page the chip links to itself,
// and the panel now carries its own single attention sentence. The
// chip stays on /transactions.
<BankingPanel />
) : (
<div className="pt-8">
<EmptyState
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -8,12 +9,12 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn, formatDate } from '@/lib/utils'
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
import Link from 'next/link'
import { ChevronDown, Loader2 } from 'lucide-react'
import { ChevronRight, Loader2, MoreHorizontal } from 'lucide-react'
import { getConnectionUiState } from '../lib/connection-state'
import type { BankConnection } from '@/types'
interface BankConnectionStatusProps {
@@ -27,10 +28,11 @@ interface BankConnectionStatusProps {
/**
* One bank connection as a flat hairline row (Fönster settings language):
* bank name + state on one line with quiet actions on the right, live
* warnings as compact warning-tone lines underneath, and the accounts as an
* indented sub-list. Normal state (Aktiv) is muted text; a Badge appears
* only when the row deviates (expired/error/pending).
* 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,
@@ -40,22 +42,10 @@ export function BankConnectionStatus({
onManageAccounts,
isSyncing = false,
}: BankConnectionStatusProps) {
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
const [now] = useState(() => Date.now())
const [detailsOpen, setDetailsOpen] = useState(false)
type StatusEntry =
| { kind: 'text'; label: string }
| { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' }
const statusConfig: Record<string, StatusEntry> = {
active: { kind: 'text', label: 'Aktiv' },
pending: { kind: 'badge', label: 'Väntar', variant: 'warning' },
expired: { kind: 'badge', label: 'Utgånget samtycke', variant: 'warning' },
error: { kind: 'badge', label: 'Fel', variant: 'destructive' },
revoked: { kind: 'badge', label: 'Bortkopplad', variant: 'secondary' },
}
const status = statusConfig[connection.status] || statusConfig.error
const uiState = getConnectionUiState(connection, now)
// Parse accounts from connection
const accounts = (connection.accounts_data as Array<{
@@ -67,11 +57,8 @@ export function BankConnectionStatus({
balance_updated_at?: string
enabled?: boolean
}>) || []
const enabledCount = accounts.filter((a) => a.enabled !== false).length
const [now] = useState(() => Date.now())
function formatBalanceAge(updatedAt: string): string {
const hoursAgo = Math.floor((now - new Date(updatedAt).getTime()) / (1000 * 60 * 60))
if (hoursAgo < 1) return 'Nyss uppdaterat'
@@ -80,250 +67,313 @@ export function BankConnectionStatus({
return `${daysAgo}d sedan`
}
const isConnectionExpired = connection.status === 'expired'
const isConnectionError = connection.status === 'error'
const errorMessage = connection.error_message ?? ''
// "Aktiv" is a stored status, not a live fact: a session killed bank-side
// keeps the row at 'active' until something tries to use it. The nightly
// health probe catches most of those, but a connection that has gone quiet
// for days is worth saying out loud rather than presenting old balances as
// current. The cron runs daily, so 3 days is several missed runs.
const STALE_SYNC_DAYS = 3
const daysSinceSync = connection.last_synced_at
? Math.floor((now - new Date(connection.last_synced_at).getTime()) / (1000 * 60 * 60 * 24))
: null
const isStale =
connection.status === 'active' && daysSinceSync !== null && daysSinceSync >= STALE_SYNC_DAYS
const neverSynced = connection.status === 'active' && !connection.last_synced_at
return (
<div className="border-b border-border px-1 py-3">
{/* Main line: identity + state left, quiet actions right */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
// 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>
{status.kind === 'badge' ? (
<Badge variant={status.variant}>{status.label}</Badge>
) : (
<span className="text-xs text-muted-foreground">{status.label}</span>
)}
{connection.last_synced_at && (
<span className="text-xs text-muted-foreground tabular-nums">
Synkad {formatDate(connection.last_synced_at)}
</span>
)}
{/* Consent renewal date as quiet metadata; the expired state already
carries its own warning line below. */}
{connection.consent_expires && !isConnectionExpired && (
<span className="text-xs text-muted-foreground tabular-nums">
Samtycke till {formatDate(connection.consent_expires)}
</span>
)}
<span className="ml-auto flex shrink-0 flex-wrap items-center gap-1">
{(isConnectionExpired || isConnectionError) && onReconnect && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1 text-muted-foreground hover:text-foreground">
Förnya anslutning
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Let the user pick the account type for the bank login. The
server reuses the last-used type by default, but some banks
(notably Handelsbanken) only sign with one of them: e.g. an
AB owner who signs with a personal Mobile BankID needs
"Privatkonto", not the company default "Företagskonto". */}
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Logga in på banken som
</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => onReconnect(connection, 'business')}>
Företagskonto
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onReconnect(connection, 'personal')}>
Privatkonto
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isConnectionError && (
<Button
variant="outline"
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}
Försök igen
</Button>
)}
{connection.status === 'active' && (
<Button
variant="outline"
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>
)}
{onManageAccounts && (
<Button
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onManageAccounts(connection.id)}
>
Välj konton
</Button>
)}
<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="outline"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-foreground"
onClick={() => onDisconnect(connection.id)}
>
Koppla från
Avbryt
</Button>
</span>
</div>
)
}
{/* Error message: live warning, compact warning-tone lines */}
{isConnectionError && errorMessage && (
<>
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">{errorMessage}</p>
<p className="mt-1 text-xs text-muted-foreground">
Du kan också{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner via bankfil
</Link>
</p>
</>
)}
// 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' }
}
})()
{/* Expired consent notice */}
{isConnectionExpired && (
<>
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Medan du väntar kan du{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner via bankfil
</Link>
</p>
</>
)}
const isExpired = uiState === 'expired'
const canReconnect = !!onReconnect
const canSync = connection.status === 'active' || connection.status === 'error'
{/* Gone quiet: the row still says Aktiv, but nothing has confirmed the
session is alive for days. */}
{isStale && (
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
Ingen synkning på {daysSinceSync} dagar. Saldon och transaktioner kan vara inaktuella:
kör Synka för att kontrollera att anslutningen fortfarande fungerar.
</p>
)}
{neverSynced && (
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
Anslutningen har aldrig synkat. Kör Synka för att hämta transaktioner.
</p>
)}
{/* Consent expiry warning (for active connections) */}
{!isConnectionExpired && isExpiring && daysUntilExpiry !== null && (
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
Förnya genom att ansluta igen.
</p>
)}
{/* Initial backfill summary: shows what the bank actually returned vs what we asked for. */}
{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
}
// 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 (
<div className="mt-1 flex flex-wrap items-center gap-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>
<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>
)
}
}
{/* Accounts: indented flat sub-list instead of boxed rows */}
{accounts.length > 0 && (
<div className="ml-3 mt-3 border-l border-border pl-4">
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Konton
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{enabledCount} av {accounts.length} synkas
</p>
</div>
{accounts.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',
)}
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">
{accounts.length} konton tillgängliga: 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}`}
>
<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>
)}
{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)}
<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 {accounts.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>
)
})()}
{accounts.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>
)}
<span className="text-sm tabular-nums">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</span>
</span>
)}
</div>
)
})}
{!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>
)
})}
</div>
)}
</div>
)}
</div>
@@ -6,6 +6,14 @@ import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { CheckCircle, Loader2, Upload } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
@@ -22,6 +30,11 @@ import {
import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus'
import { AccountPickerDialog } from './AccountPickerDialog'
import {
buildPageAttentionSentence,
selectPageAttention,
sortConnectionsByPrecedence,
} from '../lib/connection-state'
import type { BankConnection } from '@/types'
import type { StoredAccount } from '../types'
@@ -84,6 +97,18 @@ export default function BankingSettingsPanel() {
// different company than the active one: without this the picker simply
// never opens and the connection looks like it vanished.
const [pickerCompanyMismatch, setPickerCompanyMismatch] = useState<string | null>(null)
// "Anslut ny bank" is collapsed behind one button whenever the company
// already has a connection: renewing the existing row is almost always the
// right move, so the fresh-connect surface must not compete with it.
const [connectNewOpen, setConnectNewOpen] = useState(false)
// Same-bank intercept: a fresh connect to an already-connected bank pauses
// here so the user can renew the existing row instead of creating a
// duplicate.
const [sameBankIntercept, setSameBankIntercept] = useState<{
bank: Bank
psuTypeOverride?: 'personal' | 'business'
existing: BankConnection
} | null>(null)
// Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts
const PENDING_LOCK_MS = 30 * 1000
@@ -320,6 +345,26 @@ export default function BankingSettingsPanel() {
}
async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') {
if (connectingRef.current) return
// Same-bank intercept: when this company already holds a non-revoked
// connection to the bank, renewing that row is almost always what the
// user means. A second fresh row leaves the old one stuck in "Åtgärd
// krävs" and risks duplicate transactions on the re-import.
const existing = bankConnections.find(
(c) => c.status !== 'revoked' && c.status !== 'pending' && c.bank_name === bank.name,
)
if (existing) {
setSameBankIntercept({ bank, psuTypeOverride, existing })
return
}
await startFreshConnect(bank, psuTypeOverride)
}
async function startFreshConnect(
bank: Bank,
psuTypeOverride?: 'personal' | 'business',
forceNew = false,
) {
if (connectingRef.current) return
// Claim the lock BEFORE the confirm await. The dialog can sit open
// indefinitely, and a second click in that window would otherwise sail
@@ -337,10 +382,15 @@ export default function BankingSettingsPanel() {
bankName: bank.name,
bankCountry: bank.country,
psuTypeOverride,
forceNew,
})
const body: Record<string, string> = { aspsp_name: bank.name, aspsp_country: bank.country }
const body: Record<string, string | boolean> = { aspsp_name: bank.name, aspsp_country: bank.country }
if (psuTypeOverride) body.psu_type = psuTypeOverride
// Deliberate second connection to a bank this company is already
// connected to (past the intercept dialog). The server ignores the flag
// today; a parallel change adds a 409 guard that force_new bypasses.
if (forceNew) body.force_new = true
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
method: 'POST',
@@ -610,9 +660,21 @@ export default function BankingSettingsPanel() {
)
}
const activeConnections = bankConnections.filter((c) => c.status === 'active')
const pendingSelectionConnections = bankConnections.filter((c) => c.status === 'pending_selection')
const actionRequiredConnections = bankConnections.filter((c) => ['expired', 'error'].includes(c.status))
// One group, sorted so the row that needs the user sits first
// (pending_selection, pending, error, expired, expiring soon, active).
// Revoked rows stay hidden, exactly as before.
const stateNow = Date.now()
const visibleConnections = sortConnectionsByPrecedence(
bankConnections.filter((c) => c.status !== 'revoked'),
stateNow,
)
const hasVisibleConnections = visibleConnections.length > 0
// Exactly one page-level attention sentence for the worst state, or none
// (design convention 6).
const pageAttention = selectPageAttention(visibleConnections, stateNow)
// With zero connections the bank list IS the page; with any connection it
// collapses behind one button.
const connectNewExpanded = connectNewOpen || !hasVisibleConnections
const pickerConnection = pickerConnectionId
? bankConnections.find(c => c.id === pickerConnectionId)
@@ -625,6 +687,55 @@ export default function BankingSettingsPanel() {
<div>
<DestructiveConfirmDialog {...dialogProps} />
{/* Same-bank intercept: renew the existing connection (primary) or
deliberately connect a second one (e.g. another login at the same
bank). */}
<Dialog
open={!!sameBankIntercept}
onOpenChange={(open) => {
if (!open) setSameBankIntercept(null)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle data-ph-mask="">
Du har redan en koppling till {sameBankIntercept?.bank.name}
</DialogTitle>
<DialogDescription data-ph-mask="">
Förnya den i stället? Då behåller kontona sin historik och du undviker dubbletter
av transaktioner. Anslut som ny bara om det gäller en annan inloggning på samma
bank.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
className="min-h-11 w-full sm:w-auto"
onClick={() => {
const intercept = sameBankIntercept
setSameBankIntercept(null)
if (!intercept) return
void startFreshConnect(intercept.bank, intercept.psuTypeOverride, true)
}}
>
Anslut som ny
</Button>
<Button
className="min-h-11 w-full sm:w-auto"
onClick={() => {
const intercept = sameBankIntercept
setSameBankIntercept(null)
if (!intercept) return
// No psu override: the server reuses the stored psu_type.
void handleReconnect(intercept.existing)
}}
>
Förnya kopplingen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{pickerConnection && (
<AccountPickerDialog
open={!!pickerConnection}
@@ -663,69 +774,26 @@ export default function BankingSettingsPanel() {
</div>
)}
{/* Pending account selection: new connections waiting for the user to pick accounts */}
{pendingSelectionConnections.length > 0 && (
<SettingsGroup
label="Välj konton att synka"
help="Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas."
>
{pendingSelectionConnections.map((connection) => {
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
return (
<div
key={connection.id}
className="flex 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="text-xs text-muted-foreground">
{accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<Button size="sm" onClick={() => setPickerConnectionId(connection.id)}>
Välj konton
</Button>
<Button
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDisconnectBank(connection.id)}
>
Avbryt
</Button>
</span>
</div>
)
})}
</SettingsGroup>
{/* The page's one attention sentence: the worst connection state, or
nothing (convention 6). The rows themselves stay quiet. */}
{pageAttention && (
<p className="px-1 pt-6 text-[12.5px] leading-relaxed text-attn">
{buildPageAttentionSentence(pageAttention, stateNow)}
</p>
)}
{/* Action required: expired/error connections */}
{actionRequiredConnections.length > 0 && (
<SettingsGroup label="Åtgärd krävs" help="Dessa anslutningar behöver uppmärksamhet.">
{actionRequiredConnections.map((connection) => (
{/* All connections in one group, worst state first. Each row carries
its own state badge and exactly one primary action. */}
{hasVisibleConnections && (
<SettingsGroup label="Dina bankkopplingar">
{visibleConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onReconnect={handleReconnect}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</SettingsGroup>
)}
{/* Connected banks */}
{activeConnections.length > 0 && (
<SettingsGroup label="Anslutna banker">
{activeConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onManageAccounts={() => setPickerConnectionId(connection.id)}
onManageAccounts={(connectionId) => setPickerConnectionId(connectionId)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
@@ -736,8 +804,11 @@ export default function BankingSettingsPanel() {
ABOVE the bank list deliberately: at a one-session-per-login bank,
choosing the bank below is the very action that kills the other
company's feed, so the cheaper and safer path has to be seen first.
Renders only when a live session actually has unclaimed accounts. */}
{hasBankSync && reusableSessions.length > 0 && (
Renders only when a live session actually has unclaimed accounts,
and only while the connect-new surface is visible: it is an
alternative to a fresh connect, not a state of this company's
connections. */}
{connectNewExpanded && hasBankSync && reusableSessions.length > 0 && (
<SettingsGroup
label="Återanvänd befintlig anslutning"
help={
@@ -795,10 +866,20 @@ export default function BankingSettingsPanel() {
</SettingsGroup>
)}
{/* Connect new bank. Non-payers keep seeing the group (conversion
surface) but the bank list is replaced by an upgrade note: the
server gate would 403 the connect anyway. The former "Om
{/* Connect new bank. Collapsed behind one outline button whenever the
company already has a connection (renewing the existing row is the
primary path); the full group is the page's main content only when
nothing is connected yet. Non-payers keep seeing the group
(conversion surface) but the bank list is replaced by an upgrade
note: the server gate would 403 the connect anyway. The former "Om
bankintegration (PSD2)" card lives on as group-level help. */}
{!connectNewExpanded ? (
<div className="px-1 pt-8">
<Button variant="outline" size="sm" onClick={() => setConnectNewOpen(true)}>
Anslut en bank till
</Button>
</div>
) : (
<SettingsGroup
label="Anslut ny bank"
help={
@@ -864,6 +945,7 @@ export default function BankingSettingsPanel() {
</>
)}
</SettingsGroup>
)}
</div>
)
}
@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest'
import {
buildPageAttentionSentence,
getConnectionUiState,
selectPageAttention,
sortConnectionsByPrecedence,
EXPIRY_WARNING_DAYS,
STALE_SYNC_DAYS,
} from '../connection-state'
const NOW = new Date('2026-08-19T12:00:00Z').getTime()
const DAY_MS = 24 * 60 * 60 * 1000
function iso(offsetDays: number): string {
return new Date(NOW + offsetDays * DAY_MS).toISOString()
}
function conn(overrides: {
status?: string
consent_expires?: string | null
last_synced_at?: string | null
created_at?: string
bank_name?: string
}) {
return {
status: 'active',
consent_expires: iso(60),
last_synced_at: iso(-1),
created_at: iso(-30),
bank_name: 'SEB',
...overrides,
}
}
describe('getConnectionUiState', () => {
it('maps DB statuses straight through', () => {
expect(getConnectionUiState(conn({ status: 'pending_selection' }), NOW)).toBe('pending_selection')
expect(getConnectionUiState(conn({ status: 'pending' }), NOW)).toBe('pending')
expect(getConnectionUiState(conn({ status: 'error' }), NOW)).toBe('error')
expect(getConnectionUiState(conn({ status: 'expired' }), NOW)).toBe('expired')
})
it('classifies a healthy active row as active', () => {
expect(getConnectionUiState(conn({}), NOW)).toBe('active')
})
it('flags consent expiring within the warning window', () => {
expect(
getConnectionUiState(conn({ consent_expires: iso(EXPIRY_WARNING_DAYS - 1) }), NOW),
).toBe('expiring')
expect(
getConnectionUiState(conn({ consent_expires: iso(EXPIRY_WARNING_DAYS + 1) }), NOW),
).toBe('active')
})
it('expiring beats stale on the same row', () => {
expect(
getConnectionUiState(
conn({ consent_expires: iso(2), last_synced_at: iso(-10) }),
NOW,
),
).toBe('expiring')
})
it('flags stale and never-synced active rows', () => {
expect(
getConnectionUiState(conn({ last_synced_at: iso(-STALE_SYNC_DAYS) }), NOW),
).toBe('stale')
expect(
getConnectionUiState(conn({ last_synced_at: iso(-(STALE_SYNC_DAYS - 1)) }), NOW),
).toBe('active')
expect(getConnectionUiState(conn({ last_synced_at: null }), NOW)).toBe('never_synced')
})
it('handles a row with no consent date', () => {
expect(getConnectionUiState(conn({ consent_expires: null }), NOW)).toBe('active')
})
})
describe('sortConnectionsByPrecedence', () => {
it('orders pending_selection, pending, error, expired, expiring, active', () => {
const rows = [
conn({ bank_name: 'Healthy' }),
conn({ bank_name: 'Expiring', consent_expires: iso(2) }),
conn({ bank_name: 'Expired', status: 'expired' }),
conn({ bank_name: 'Errored', status: 'error' }),
conn({ bank_name: 'InFlight', status: 'pending' }),
conn({ bank_name: 'PickAccounts', status: 'pending_selection' }),
]
expect(sortConnectionsByPrecedence(rows, NOW).map((r) => r.bank_name)).toEqual([
'PickAccounts',
'InFlight',
'Errored',
'Expired',
'Expiring',
'Healthy',
])
})
it('breaks ties by newest created_at first and does not mutate the input', () => {
const older = conn({ bank_name: 'Older', created_at: iso(-100) })
const newer = conn({ bank_name: 'Newer', created_at: iso(-1) })
const rows = [older, newer]
const sorted = sortConnectionsByPrecedence(rows, NOW)
expect(sorted.map((r) => r.bank_name)).toEqual(['Newer', 'Older'])
expect(rows[0]).toBe(older)
})
})
describe('selectPageAttention', () => {
it('returns null when every connection is healthy or has no attention state', () => {
expect(selectPageAttention([conn({})], NOW)).toBeNull()
expect(selectPageAttention([conn({ status: 'pending_selection' })], NOW)).toBeNull()
expect(selectPageAttention([], NOW)).toBeNull()
})
it('picks the worst state: error beats expired beats expiring beats stale', () => {
const errored = conn({ bank_name: 'Errored', status: 'error' })
const expired = conn({ bank_name: 'Expired', status: 'expired' })
const expiring = conn({ bank_name: 'Expiring', consent_expires: iso(2) })
const stale = conn({ bank_name: 'Stale', last_synced_at: iso(-10) })
expect(selectPageAttention([stale, expiring, expired, errored], NOW)?.connection.bank_name).toBe('Errored')
expect(selectPageAttention([stale, expiring, expired], NOW)?.connection.bank_name).toBe('Expired')
expect(selectPageAttention([stale, expiring], NOW)?.connection.bank_name).toBe('Expiring')
expect(selectPageAttention([stale], NOW)?.state).toBe('stale')
})
})
describe('buildPageAttentionSentence', () => {
it('names the bank and the state', () => {
const attention = selectPageAttention([conn({ status: 'expired' })], NOW)!
expect(buildPageAttentionSentence(attention, NOW)).toBe(
'SEB: PSD2-samtycket har löpt ut. Förnya samtycket för att återuppta synkroniseringen.',
)
})
it('counts days for the expiring state with singular/plural', () => {
const one = selectPageAttention([conn({ consent_expires: iso(1) })], NOW)!
expect(buildPageAttentionSentence(one, NOW)).toContain('går ut om 1 dag.')
const five = selectPageAttention([conn({ consent_expires: iso(5) })], NOW)!
expect(buildPageAttentionSentence(five, NOW)).toContain('går ut om 5 dagar.')
})
it('counts days since sync for the stale state', () => {
const attention = selectPageAttention([conn({ last_synced_at: iso(-10) })], NOW)!
expect(buildPageAttentionSentence(attention, NOW)).toContain('ingen synkning på 10 dagar')
})
})
@@ -0,0 +1,157 @@
/**
* UI state model for /settings/banking.
*
* The DB status ('pending', 'pending_selection', 'active', 'expired',
* 'error', 'revoked') is not the same thing as what the page should say:
* an 'active' row whose consent runs out in three days needs renewal, and
* an 'active' row that has not synced for days needs a sync check. This
* module derives that presentation state once, so the panel's sort order,
* the single page-level attention sentence, and the per-row primary action
* all agree on which state a connection is in.
*
* Pure functions only (no React, no fetch): unit-tested in
* lib/__tests__/connection-state.test.ts.
*/
/** Days before consent expiry at which renewal becomes the primary action.
* Must match isConsentExpiringSoon in api-client.ts. */
export const EXPIRY_WARNING_DAYS = 7
/** Days without a completed sync before an 'active' row is treated as stale.
* The nightly cron runs daily, so 3 days is several missed runs. */
export const STALE_SYNC_DAYS = 3
const DAY_MS = 24 * 60 * 60 * 1000
/** Presentation state, from most to least urgent (see STATE_PRECEDENCE). */
export type ConnectionUiState =
| 'pending_selection'
| 'pending'
| 'error'
| 'expired'
| 'expiring'
| 'stale'
| 'never_synced'
| 'active'
/** The fields the state derivation reads; structural so tests and callers
* don't have to build full BankConnection rows. */
export interface ConnectionStateInput {
status: string
consent_expires: string | null
last_synced_at: string | null
}
export function getConnectionUiState(
connection: ConnectionStateInput,
now: number = Date.now(),
): ConnectionUiState {
switch (connection.status) {
case 'pending_selection':
return 'pending_selection'
case 'pending':
return 'pending'
case 'error':
return 'error'
case 'expired':
return 'expired'
default: {
// 'active' (and, defensively, any unknown status): refine by liveness.
if (connection.consent_expires) {
const expires = new Date(connection.consent_expires).getTime()
if (expires <= now + EXPIRY_WARNING_DAYS * DAY_MS) return 'expiring'
}
if (!connection.last_synced_at) return 'never_synced'
const daysSinceSync = Math.floor(
(now - new Date(connection.last_synced_at).getTime()) / DAY_MS,
)
if (daysSinceSync >= STALE_SYNC_DAYS) return 'stale'
return 'active'
}
}
}
/** Sort order for the single "Dina bankkopplingar" group: the row that needs
* the user first sits first. */
const STATE_PRECEDENCE: Record<ConnectionUiState, number> = {
pending_selection: 0,
pending: 1,
error: 2,
expired: 3,
expiring: 4,
stale: 5,
never_synced: 6,
active: 7,
}
export function sortConnectionsByPrecedence<
T extends ConnectionStateInput & { created_at: string },
>(connections: T[], now: number = Date.now()): T[] {
return [...connections].sort((a, b) => {
const diff =
STATE_PRECEDENCE[getConnectionUiState(a, now)] -
STATE_PRECEDENCE[getConnectionUiState(b, now)]
if (diff !== 0) return diff
// Within a state, newest first (matches the previous created_at desc order).
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
}
/** States that earn the page's one .attn sentence (design convention 6:
* attention is ONE ochre sentence per page). Worst first. */
export type PageAttentionState = 'error' | 'expired' | 'expiring' | 'stale' | 'never_synced'
const ATTENTION_PRECEDENCE: PageAttentionState[] = [
'error',
'expired',
'expiring',
'stale',
'never_synced',
]
export interface PageAttention<T> {
state: PageAttentionState
connection: T
}
/** Pick the single worst-state connection the page should call out, or null
* when every connection is healthy (or there are none). */
export function selectPageAttention<T extends ConnectionStateInput>(
connections: T[],
now: number = Date.now(),
): PageAttention<T> | null {
for (const state of ATTENTION_PRECEDENCE) {
const match = connections.find((c) => getConnectionUiState(c, now) === state)
if (match) return { state, connection: match }
}
return null
}
/** The one page-level attention sentence. Swedish by the enable-banking
* component convention (extension UI is hardcoded Swedish). */
export function buildPageAttentionSentence(
attention: PageAttention<ConnectionStateInput & { bank_name: string }>,
now: number = Date.now(),
): string {
const bank = attention.connection.bank_name
switch (attention.state) {
case 'error':
return `${bank}: anslutningen har ett fel. Försök igen eller förnya samtycket.`
case 'expired':
return `${bank}: PSD2-samtycket har löpt ut. Förnya samtycket för att återuppta synkroniseringen.`
case 'expiring': {
const expires = attention.connection.consent_expires
const days = expires
? Math.max(0, Math.ceil((new Date(expires).getTime() - now) / DAY_MS))
: 0
return `${bank}: samtycket går ut om ${days} ${days === 1 ? 'dag' : 'dagar'}. Förnya det för att undvika avbrott i synkroniseringen.`
}
case 'stale': {
const last = attention.connection.last_synced_at
const days = last ? Math.floor((now - new Date(last).getTime()) / DAY_MS) : 0
return `${bank}: ingen synkning på ${days} dagar. Kör Synka för att kontrollera att anslutningen fortfarande fungerar.`
}
case 'never_synced':
return `${bank}: anslutningen har aldrig synkat. Kör Synka för att hämta transaktioner.`
}
}