From 1ded1af8fe3d47a5c07a3532a97718554db618e4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 20 Aug 2026 10:07:04 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 1 + .../sections/BankingSettingsContent.tsx | 13 +- .../components/BankConnectionStatus.tsx | 554 ++++++++++-------- .../components/BankingSettingsPanel.tsx | 210 +++++-- .../lib/__tests__/connection-state.test.ts | 149 +++++ .../enable-banking/lib/connection-state.ts | 157 +++++ 6 files changed, 759 insertions(+), 325 deletions(-) create mode 100644 extensions/general/enable-banking/lib/__tests__/connection-state.test.ts create mode 100644 extensions/general/enable-banking/lib/connection-state.ts diff --git a/DECISIONS.md b/DECISIONS.md index 9416661c..3b5086a3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1090,3 +1090,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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:) 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. diff --git a/components/settings/sections/BankingSettingsContent.tsx b/components/settings/sections/BankingSettingsContent.tsx index b4e7c5e8..9a4aa775 100644 --- a/components/settings/sections/BankingSettingsContent.tsx +++ b/components/settings/sections/BankingSettingsContent.tsx @@ -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. */} -
- -
- - + // 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. + ) : (
Date.now()) + const [detailsOpen, setDetailsOpen] = useState(false) - type StatusEntry = - | { kind: 'text'; label: string } - | { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' } - - const statusConfig: Record = { - 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 ( -
- {/* Main line: identity + state left, quiet actions right */} -
+ // 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 ( +
{connection.bank_name} - {status.kind === 'badge' ? ( - {status.label} - ) : ( - {status.label} - )} - {connection.last_synced_at && ( - - Synkad {formatDate(connection.last_synced_at)} - - )} - {/* Consent renewal date as quiet metadata; the expired state already - carries its own warning line below. */} - {connection.consent_expires && !isConnectionExpired && ( - - Samtycke till {formatDate(connection.consent_expires)} - - )} - - {(isConnectionExpired || isConnectionError) && onReconnect && ( - - - - - - {/* 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". */} - - Logga in på banken som - - onReconnect(connection, 'business')}> - Företagskonto - - onReconnect(connection, 'personal')}> - Privatkonto - - - - )} - {isConnectionError && ( - - )} - {connection.status === 'active' && ( - - )} - {onManageAccounts && ( - - )} + + + Väntar på banken… + +
+ ) + } - {/* Error message: live warning, compact warning-tone lines */} - {isConnectionError && errorMessage && ( - <> -

{errorMessage}

-

- Du kan också{' '} - - importera transaktioner via bankfil - -

- - )} + // 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 && ( - <> -

- PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen. -

-

- Medan du väntar kan du{' '} - - importera transaktioner via bankfil - -

- - )} + 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 && ( -

- Ingen synkning på {daysSinceSync} dagar. Saldon och transaktioner kan vara inaktuella: - kör Synka för att kontrollera att anslutningen fortfarande fungerar. -

- )} - {neverSynced && ( -

- Anslutningen har aldrig synkat. Kör Synka för att hämta transaktioner. -

- )} - - {/* Consent expiry warning (for active connections) */} - {!isConnectionExpired && isExpiring && daysUntilExpiry !== null && ( -

- Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}. - Förnya genom att ansluta igen. -

- )} - - {/* 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 ? ( + + ) : null + case 'error': return ( -
- - Initial historik:{' '} - - {min ? formatDate(min) : '-'} → {max ? formatDate(max) : '-'} - - {' '}(begärde {formatDate(requested)}) - - {truncated && ( - - Bankens API returnerade kortare period än begärt: använd SIE-import för äldre data - - )} -
+ ) - })()} + 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 ? ( + + ) : null + case 'stale': + case 'never_synced': + return ( + + ) + default: + // Healthy active row: no primary needed; sync stays reachable as a + // quiet ghost button. + return ( + + ) + } + } - {/* Accounts: indented flat sub-list instead of boxed rows */} - {accounts.length > 0 && ( -
-
-

- Konton -

-

- {enabledCount} av {accounts.length} synkas -

-
- {accounts.map((account) => { - const isDisabled = account.enabled === false - return ( -
+ {/* Main line: identity + state left, one primary action + menu right */} +
+ {connection.bank_name} + {statusDisplay.kind === 'badge' ? ( + {statusDisplay.label} + ) : ( + {statusDisplay.label} + )} + {uiState === 'pending_selection' ? ( + + {accounts.length} konton tillgängliga: inga transaktioner synkas ännu + + ) : ( + <> + {connection.last_synced_at && ( + + Synkad {formatDate(connection.last_synced_at)} + + )} + {connection.consent_expires && !isExpired && ( + + Samtycke till {formatDate(connection.consent_expires)} + + )} + + )} + + {renderPrimaryAction()} + + + + + + {uiState === 'pending_selection' ? ( + onDisconnect(connection.id)}> + Avbryt + + ) : ( + <> + {onManageAccounts && ( + onManageAccounts(connection.id)}> + Välj konton + + )} + {canSync && !primaryIsSync && ( + onSync(connection.id)}> + Synka + + )} + {canReconnect && !primaryIsReconnect && ( + onReconnect!(connection)}> + Förnya samtycke + + )} + {canReconnect && ( + <> + + {/* Some banks (notably Handelsbanken) only sign with one + account type: keep the explicit choice reachable even + though the primary renew reuses the stored type. */} + + Förnya och logga in som + + onReconnect!(connection, 'business')}> + Företagskonto + + onReconnect!(connection, 'personal')}> + Privatkonto + + + )} + + + Importera bankfil + + onDisconnect(connection.id)} + > + Koppla från + + + )} + + + +
+ + {/* Error detail: the page-level .attn owns the ochre sentence; the + row's own message stays quiet. */} + {uiState === 'error' && connection.error_message && ( +

+ {connection.error_message} +

+ )} + + {/* 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' && ( +
+ + + {detailsOpen && ( +
+ {/* 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 ( +
+ + Initial historik:{' '} + + {min ? formatDate(min) : '-'} → {max ? formatDate(max) : '-'} + {' '} + (begärde {formatDate(requested)}) + + {truncated && ( + + Bankens API returnerade kortare period än begärt: använd SIE-import för äldre data + + )} +
+ ) + })()} + + {accounts.map((account) => { + const isDisabled = account.enabled === false + return ( +
+ + {account.name || account.iban || 'Okänt konto'} + + {isDisabled && ( + + Synkas ej + + )} + {account.iban && ( + + {account.iban.replace(/(.{4})/g, '$1 ').trim()} )} - - {new Intl.NumberFormat('sv-SE', { - style: 'currency', - currency: account.currency, - }).format(account.balance)} - - - )} -
- ) - })} + {!isExpired && account.balance !== undefined && ( + + {account.balance_updated_at && ( + + {formatBalanceAge(account.balance_updated_at)} + + )} + + {new Intl.NumberFormat('sv-SE', { + style: 'currency', + currency: account.currency, + }).format(account.balance)} + + + )} +
+ ) + })} +
+ )}
)}
diff --git a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx index b7d2deef..4f61008d 100644 --- a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx +++ b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx @@ -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(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 = { aspsp_name: bank.name, aspsp_country: bank.country } + const body: Record = { 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() {
+ {/* Same-bank intercept: renew the existing connection (primary) or + deliberately connect a second one (e.g. another login at the same + bank). */} + { + if (!open) setSameBankIntercept(null) + }} + > + + + + Du har redan en koppling till {sameBankIntercept?.bank.name} + + + 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. + + + + + + + + + {pickerConnection && ( )} - {/* Pending account selection: new connections waiting for the user to pick accounts */} - {pendingSelectionConnections.length > 0 && ( - - {pendingSelectionConnections.map((connection) => { - const accountsList = (connection.accounts_data as StoredAccount[] | null) || [] - return ( -
- {connection.bank_name} - - {accountsList.length} konton tillgängliga: inga transaktioner synkas ännu - - - - - -
- ) - })} -
+ {/* The page's one attention sentence: the worst connection state, or + nothing (convention 6). The rows themselves stay quiet. */} + {pageAttention && ( +

+ {buildPageAttentionSentence(pageAttention, stateNow)} +

)} - {/* Action required: expired/error connections */} - {actionRequiredConnections.length > 0 && ( - - {actionRequiredConnections.map((connection) => ( + {/* All connections in one group, worst state first. Each row carries + its own state badge and exactly one primary action. */} + {hasVisibleConnections && ( + + {visibleConnections.map((connection) => ( setPickerConnectionId(connection.id)} - isSyncing={syncingConnectionId === connection.id} - /> - ))} - - )} - - {/* Connected banks */} - {activeConnections.length > 0 && ( - - {activeConnections.map((connection) => ( - 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 && ( )} - {/* 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 ? ( +
+ +
+ ) : ( )} + )}
) } diff --git a/extensions/general/enable-banking/lib/__tests__/connection-state.test.ts b/extensions/general/enable-banking/lib/__tests__/connection-state.test.ts new file mode 100644 index 00000000..ddc47dbf --- /dev/null +++ b/extensions/general/enable-banking/lib/__tests__/connection-state.test.ts @@ -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') + }) +}) diff --git a/extensions/general/enable-banking/lib/connection-state.ts b/extensions/general/enable-banking/lib/connection-state.ts new file mode 100644 index 00000000..52206fb5 --- /dev/null +++ b/extensions/general/enable-banking/lib/connection-state.ts @@ -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 = { + 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 { + 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( + connections: T[], + now: number = Date.now(), +): PageAttention | 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, + 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.` + } +}