fix(banking): harden PSD2 loading, error, and refresh states (#934)

Fixes loading/error/refresh-state gaps across the Enable Banking (PSD2) surfaces:

- Delete the dead post-OAuth bank_connected sync flow + 9 orphaned i18n keys.
- Surface fetch failures instead of empty/false-healthy states (settings panel error card, BankSelector non-OK guard, chip hidden on error).
- Stop the full-panel spinner flash on refetch; clear the spinner in finally.
- Client-side sync/backfill timeouts + live elapsed counter with a grace-period unlock so a slow bank can't trap the modal.
- Broadcast a bank-sync signal so the transactions chip refetches after a manual sync.
- Consolidate the import page onto the shared banking panel (also removes the core -> @/extensions import-rule violation) and standardize spinners.
- Surface previously silent chart/fiscal-year fetch failures in the account picker.
- Review fixes: cancellation guard on the deferred bank_error microtask; key the sync-progress dialog per attempt to reset its elapsed timer.
This commit is contained in:
Jakob Wennberg
2026-07-08 16:55:29 +02:00
committed by GitHub
parent c3764e8987
commit b10cf2ec23
11 changed files with 329 additions and 373 deletions
@@ -88,8 +88,10 @@ export function AccountPickerDialog({
const [isSaving, setIsSaving] = useState(false)
const [sieLastDate, setSieLastDate] = useState<string | null>(null)
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
const [chartError, setChartError] = useState(false)
const [ledgerByUid, setLedgerByUid] = useState<Record<string, string>>({})
const [companySettings, setCompanySettings] = useState<Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null>(null)
const [settingsLoaded, setSettingsLoaded] = useState(false)
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('date')
@@ -97,9 +99,17 @@ export function AccountPickerDialog({
const [progressOpen, setProgressOpen] = useState(false)
const [progressState, setProgressState] = useState<SyncProgressState>({ kind: 'syncing' })
// Bumped on each new backfill so the progress dialog is keyed per attempt and
// remounts fresh: the dialog stays mounted across attempts, so without this a
// second sync would inherit the previous run's elapsed timer for a frame and
// briefly compute overGrace/blockClose from stale state.
const [syncAttempt, setSyncAttempt] = useState(0)
useEffect(() => {
if (open) {
// Re-arm the "settings loaded" gate each open so the fiscal-year label
// doesn't flash last-open's resolved date before this open's fetch lands.
setSettingsLoaded(false)
const initial = new Set<string>(
accounts.filter(a => a.enabled !== false).map(a => a.uid)
)
@@ -140,6 +150,7 @@ export function AccountPickerDialog({
.maybeSingle()
if (cancelled) return
setCompanySettings((data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null)
setSettingsLoaded(true)
})()
return () => { cancelled = true }
}, [open, company?.id, supabase])
@@ -174,13 +185,20 @@ export function AccountPickerDialog({
if (!open || !company?.id) return
let cancelled = false
;(async () => {
const { data } = await supabase
const { data, error } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('company_id', company.id)
.like('account_number', '19%')
.order('account_number', { ascending: true })
if (cancelled) return
if (error) {
// Surface the failure: without the 19xx chart the ledger picker is
// silently empty, which reads as "no bank accounts exist".
setChartError(true)
return
}
setChartError(false)
setChartAccounts((data as ChartAccount[] | null) || [])
})()
return () => { cancelled = true }
@@ -268,10 +286,18 @@ export function AccountPickerDialog({
setIsSaving(true)
// Cap the client wait at the route's 300s budget so a hung backfill can't
// leave the progress modal in 'syncing' forever. The save+backfill is one
// request; on abort we don't know if it finished, so the message stays
// neutral and the parent refetch reflects the true state.
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 300_000)
// For the initial-selection path, open the progress modal up-front so the
// user has visible feedback during the 30-60s backfill. Selection edits
// (no backfill) keep the existing toast-only feedback.
if (isInitialSelection) {
setSyncAttempt((n) => n + 1)
setProgressState({ kind: 'syncing' })
setProgressOpen(true)
onOpenChange(false)
@@ -294,6 +320,7 @@ export function AccountPickerDialog({
account_mappings,
...(isInitialSelection && lookback.body ? lookback.body : {}),
}),
signal: controller.signal,
})
const data = await response.json()
@@ -319,17 +346,21 @@ export function AccountPickerDialog({
onSaved()
} catch (error) {
const message = error instanceof Error ? error.message : 'Kunde inte spara kontoval'
const aborted = controller.signal.aborted
const message = aborted
? 'Det tar längre tid än vanligt. Vi slutför i bakgrunden: uppdatera sidan om en stund.'
: (error instanceof Error ? error.message : 'Kunde inte spara kontoval')
if (isInitialSelection) {
setProgressState({ kind: 'failed', error: { message } })
} else {
toast({
title: 'Fel',
title: aborted ? 'Tar längre tid än vanligt' : 'Fel',
description: message,
variant: 'destructive',
variant: aborted ? undefined : 'destructive',
})
}
} finally {
clearTimeout(timeout)
setIsSaving(false)
}
}
@@ -372,6 +403,7 @@ export function AccountPickerDialog({
return (
<>
<BankSyncProgressDialog
key={syncAttempt}
open={progressOpen}
onOpenChange={(next) => {
setProgressOpen(next)
@@ -459,7 +491,7 @@ export function AccountPickerDialog({
<span>
<span className="block">Sedan räkenskapsårets början</span>
<span className="text-xs text-muted-foreground tabular-nums">
från {fiscalYearStart}
från {settingsLoaded ? fiscalYearStart : '…'}
</span>
</span>
</label>
@@ -489,7 +521,7 @@ export function AccountPickerDialog({
<SelectContent>
<SelectItem value="date">Specifikt datum</SelectItem>
<SelectItem value="previous-fiscal-year">
Föregående räkenskapsårets start ({previousFiscalYearStart})
Föregående räkenskapsårets start ({settingsLoaded ? previousFiscalYearStart : '…'})
</SelectItem>
</SelectContent>
</Select>
@@ -558,6 +590,12 @@ export function AccountPickerDialog({
</div>
)}
{chartError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
Kunde inte ladda bokföringskonton (19xx). Ladda om sidan och försök igen innan du sparar kontoval.
</div>
)}
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border divide-y divide-border">
{sortedAccounts.map(account => {
const isChecked = selected.has(account.uid)
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface Bank {
@@ -49,7 +50,7 @@ function BankCard({ bank, isConnecting, connectingBankName, onConnect }: {
>
<div className="flex-shrink-0 w-12 h-12 rounded-lg border border-border bg-white dark:bg-gray-300 flex items-center justify-center overflow-hidden">
{connecting ? (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<Loader2 className="h-6 w-6 animate-spin text-primary" />
) : bank.logo ? (
<img
src={bank.logo}
@@ -106,6 +107,14 @@ export function BankSelector({
async function fetchBanks() {
try {
const res = await fetch('/api/extensions/ext/enable-banking/banks')
if (!res.ok) {
// A non-OK response (500, or the 403 capability gate) has no `banks`
// array; without this guard it falls through to the "no banks
// available" empty state, which misreads as a successful-but-empty
// load rather than a failure.
setError('Kunde inte ladda banker')
return
}
const data = await res.json()
if (data.banks) {
setBanks(data.banks as Bank[])
@@ -166,7 +175,7 @@ export function BankSelector({
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" />
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
@@ -221,7 +230,7 @@ export function BankSelector({
{/* Connecting overlay */}
{isConnecting && connectingBankName && (
<div className="flex items-center justify-center gap-2 p-3 rounded-lg bg-primary/10 border border-primary/30">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm font-medium text-foreground">Ansluter till {connectingBankName}...</span>
</div>
)}
@@ -1,5 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
Dialog,
@@ -39,6 +40,16 @@ interface BankSyncProgressDialogProps {
state: SyncProgressState
}
// Past this point the sync has run longer than the promised "up to a minute".
// We stop hard-locking the modal so the user isn't trapped: the request keeps
// running server-side (idempotent) and completion still resolves the state.
const GRACE_SEC = 75
function formatElapsed(sec: number): string {
if (sec < 60) return `${sec}s`
return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`
}
export function BankSyncProgressDialog({
open,
onOpenChange,
@@ -46,27 +57,47 @@ export function BankSyncProgressDialog({
accounts,
state,
}: BankSyncProgressDialogProps) {
// Close-prevention while sync is in flight is handled inline below via the
// onOpenChange guard + onPointerDownOutside + onEscapeKeyDown handlers.
const enabledAccounts = accounts.filter((a) => a.enabled !== false)
// Tick a visible elapsed counter while syncing so a slow bank doesn't look
// frozen, and so we know when to release the close-lock (GRACE_SEC). State is
// only ever set from the timer callbacks (never synchronously in the effect
// body), and elapsed is never computed from Date.now() during render, so this
// stays clear of the react-hooks purity rules.
const [elapsedSec, setElapsedSec] = useState(0)
useEffect(() => {
if (!open || state.kind !== 'syncing') return
const started = Date.now()
const tick = () => setElapsedSec(Math.max(0, Math.floor((Date.now() - started) / 1000)))
// Reset to ~0 on the next tick (async, so not a synchronous effect setState).
const reset = setTimeout(tick, 0)
const id = setInterval(tick, 1000)
return () => {
clearTimeout(reset)
clearInterval(id)
}
}, [open, state.kind])
const overGrace = state.kind === 'syncing' && elapsedSec >= GRACE_SEC
// Only hard-block the close affordances during the expected window; after the
// grace period the user may background the (still-running) sync.
const blockClose = state.kind === 'syncing' && !overGrace
return (
<Dialog
open={open}
onOpenChange={(next) => {
// Block manual close mid-sync
if (!next && state.kind === 'syncing') return
if (!next && blockClose) return
onOpenChange(next)
}}
>
<DialogContent
className="max-w-md"
onPointerDownOutside={(e) => {
if (state.kind === 'syncing') e.preventDefault()
if (blockClose) e.preventDefault()
}}
onEscapeKeyDown={(e) => {
if (state.kind === 'syncing') e.preventDefault()
if (blockClose) e.preventDefault()
}}
>
<DialogHeader>
@@ -77,10 +108,17 @@ export function BankSyncProgressDialog({
</DialogTitle>
<DialogDescription>
{state.kind === 'syncing' && (
<>
Vi hämtar transaktioner från {enabledAccounts.length}{' '}
{enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret.
</>
overGrace ? (
<>
Det tar längre tid än vanligt. Vi fortsätter i bakgrunden: du kan
stänga rutan och komma tillbaka senare.
</>
) : (
<>
Vi hämtar transaktioner från {enabledAccounts.length}{' '}
{enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret.
</>
)
)}
{state.kind === 'done' && (
<>
@@ -96,8 +134,11 @@ export function BankSyncProgressDialog({
{state.kind === 'syncing' && (
<div className="space-y-3 py-2">
<div className="flex items-center justify-center py-6">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
{formatElapsed(elapsedSec)}
</span>
</div>
<ul className="rounded-lg border border-border divide-y divide-border text-sm">
{enabledAccounts.map((a) => (
@@ -124,9 +165,11 @@ export function BankSyncProgressDialog({
<Button
type="button"
onClick={() => onOpenChange(false)}
disabled={state.kind === 'syncing'}
disabled={blockClose}
>
{state.kind === 'syncing' ? 'Hämtar…' : 'Klar'}
{state.kind === 'syncing'
? (overGrace ? 'Fortsätt i bakgrunden' : 'Hämtar…')
: 'Klar'}
</Button>
</DialogFooter>
</DialogContent>
@@ -9,6 +9,7 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui
import { AlertTriangle, Loader2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
@@ -37,6 +38,8 @@ export default function BankingSettingsPanel() {
const connectingRef = useRef(false)
const releaseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
const hasLoadedRef = useRef(false)
const [showCsvFallback, setShowCsvFallback] = useState(false)
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
const [pickerConnectionId, setPickerConnectionId] = useState<string | null>(null)
@@ -80,35 +83,55 @@ export default function BankingSettingsPanel() {
}
async function fetchConnections() {
setIsLoading(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
if (!company) return
const { data: connections } = await supabase
.from('bank_connections')
.select('*')
.eq('company_id', company.id)
.order('created_at', { ascending: false })
setBankConnections(connections || [])
// If a pending connection exists from a recent attempt (e.g. user bounced back from
// the bank's auth page), keep the connect button disabled until the server-side lock expires.
const freshPending = (connections || []).find((c) => c.status === 'pending')
if (freshPending) {
const age = Date.now() - new Date(freshPending.created_at).getTime()
const remaining = PENDING_LOCK_MS - age
if (remaining > 0) {
connectingRef.current = true
setIsConnecting(true)
setConnectingBankName(freshPending.bank_name)
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining)
// Only the first load blanks the panel to a spinner. Later refetches (after
// a sync, disconnect, or account save) refresh in the background so the
// panel doesn't flash back to a full-height spinner and lose scroll
// position on every action.
if (!hasLoadedRef.current) setIsLoading(true)
setLoadError(false)
try {
const { data: { user } } = await supabase.auth.getUser()
if (!user || !company) {
setBankConnections([])
return
}
}
setIsLoading(false)
const { data: connections, error } = await supabase
.from('bank_connections')
.select('*')
.eq('company_id', company.id)
.order('created_at', { ascending: false })
if (error) {
// Surface the failure instead of rendering an empty panel: an empty
// panel reads as "your bank got disconnected" when it's really a
// transient fetch/RLS error.
setLoadError(true)
return
}
setBankConnections(connections || [])
// If a pending connection exists from a recent attempt (e.g. user bounced back from
// the bank's auth page), keep the connect button disabled until the server-side lock expires.
const freshPending = (connections || []).find((c) => c.status === 'pending')
if (freshPending) {
const age = Date.now() - new Date(freshPending.created_at).getTime()
const remaining = PENDING_LOCK_MS - age
if (remaining > 0) {
connectingRef.current = true
setIsConnecting(true)
setConnectingBankName(freshPending.bank_name)
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining)
}
}
} finally {
// Always clear the spinner, even on the early `!user || !company` return,
// so an expired session can't leave the panel spinning forever.
hasLoadedRef.current = true
setIsLoading(false)
}
}
async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') {
@@ -220,6 +243,13 @@ export default function BankingSettingsPanel() {
async function handleSyncTransactions(connectionId: string) {
setSyncingConnectionId(connectionId)
// A slow bank can hold the request open up to the route's 300s budget.
// Cap the client wait so the spinner can't hang indefinitely; the sync is
// idempotent (imports dedup), so a background completion or manual retry is
// safe.
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 180_000)
try {
console.log('[enable-banking] Starting sync', { connectionId })
@@ -227,6 +257,7 @@ export default function BankingSettingsPanel() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
signal: controller.signal,
})
const data = await response.json()
@@ -253,25 +284,35 @@ export default function BankingSettingsPanel() {
})
setShowCsvFallback(false)
notifyBankSyncUpdated()
fetchConnections()
} catch (error) {
console.error('[enable-banking] Sync flow failed', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
connectionId,
})
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
variant: 'destructive',
})
setShowCsvFallback(true)
// Refresh so a now-expired connection (e.g. closed PSD2 session) moves
// into "Åtgärd krävs" and surfaces the "Förnya anslutning" button.
fetchConnections()
if (controller.signal.aborted) {
toast({
title: 'Synkronisering tar längre tid än vanligt',
description: 'Transaktionerna hämtas i bakgrunden. Uppdatera sidan om en stund.',
})
fetchConnections()
} else {
console.error('[enable-banking] Sync flow failed', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
connectionId,
})
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
variant: 'destructive',
})
setShowCsvFallback(true)
// Refresh so a now-expired connection (e.g. closed PSD2 session) moves
// into "Åtgärd krävs" and surfaces the "Förnya anslutning" button.
fetchConnections()
}
} finally {
clearTimeout(timeout)
setSyncingConnectionId(null)
}
setSyncingConnectionId(null)
}
async function handleDisconnectBank(connectionId: string) {
@@ -331,6 +372,31 @@ export default function BankingSettingsPanel() {
)
}
// First-load failure: show a recoverable error instead of an empty panel (a
// blank panel misreads as "no banks connected"). A background-refetch failure
// keeps the already-loaded connections visible instead of wiping them.
if (loadError && bankConnections.length === 0) {
return (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle>Kunde inte ladda bankanslutningar</CardTitle>
<CardDescription>
Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar
och transaktioner är oförändrade.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" onClick={() => fetchConnections()}>
Försök igen
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/import?mode=bank">Importera bankfil istället</Link>
</Button>
</CardContent>
</Card>
)
}
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))