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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
e1805125af
commit
1ded1af8fe
@@ -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.`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user