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:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
@@ -23,92 +23,36 @@ export function BankingSettingsContent() {
|
||||
const [failedBankName, setFailedBankName] = useState<string | null>(null)
|
||||
const [isAccessDenied, setIsAccessDenied] = useState(false)
|
||||
const [showHbPoaHint, setShowHbPoaHint] = useState(false)
|
||||
const syncInitiatedRef = useRef(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const unmountedRef = useRef(false)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
|
||||
// Surface a bank connection/authorization failure that the OAuth callback
|
||||
// bounced back as `?bank_error=...`. The success path is handled by the
|
||||
// callback redirecting to `?select_accounts=<id>`, which the banking panel
|
||||
// picks up to open account selection: there is no `bank_connected` param.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unmountedRef.current = true
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
if (!bankError) return
|
||||
|
||||
if (bankConnected === 'true' && !syncInitiatedRef.current) {
|
||||
syncInitiatedRef.current = true
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
router.replace('/settings/banking')
|
||||
|
||||
if (connectionId) {
|
||||
toast({
|
||||
title: t('sync_start_title'),
|
||||
description: t('sync_start_description'),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
const syncTimeout = setTimeout(() => controller.abort(), 120_000)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 120 }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(syncTimeout)
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (!unmountedRef.current) {
|
||||
toast({
|
||||
title: t('sync_success_title'),
|
||||
description: t('sync_success_description', { count: data.imported ?? 0 }),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'Sync failed')
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(syncTimeout)
|
||||
if (unmountedRef.current) return
|
||||
if (controller.signal.aborted) {
|
||||
toast({
|
||||
title: t('sync_timeout_title'),
|
||||
description: t('sync_timeout_description'),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: err instanceof Error ? err.message : t('sync_failed_default'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
} else {
|
||||
toast({
|
||||
title: t('sync_success_title'),
|
||||
description: t('sync_success_no_id_description'),
|
||||
})
|
||||
}
|
||||
let errorMsg: string
|
||||
try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError }
|
||||
const bankName = searchParams.get('bank_name')
|
||||
const errorCode = searchParams.get('bank_error_code')
|
||||
const psuType = searchParams.get('psu_type')
|
||||
// The bank often returns a bare "server_error" with no description: show a
|
||||
// human message instead of the raw OAuth error code.
|
||||
if (errorCode === 'server_error' && errorMsg === 'server_error') {
|
||||
errorMsg = t('bank_server_error')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
let errorMsg: string
|
||||
try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError }
|
||||
const bankName = searchParams.get('bank_name')
|
||||
const errorCode = searchParams.get('bank_error_code')
|
||||
const psuType = searchParams.get('psu_type')
|
||||
// The bank often returns a bare "server_error" with no description —
|
||||
// show a human message instead of the raw OAuth error code.
|
||||
if (errorCode === 'server_error' && errorMsg === 'server_error') {
|
||||
errorMsg = t('bank_server_error')
|
||||
}
|
||||
// Consume the one-shot ?bank_error= param off the render path: a microtask
|
||||
// defers these updates out of the effect body (react-hooks/set-state-in-
|
||||
// effect) without a user-visible delay, since the param appears at most
|
||||
// once per OAuth bounce-back. The cancellation flag drops the deferred work
|
||||
// if the effect re-runs or the component unmounts before it flushes (also
|
||||
// suppresses a duplicate toast under StrictMode's dev double-invoke).
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
toast({
|
||||
title: t('connect_failed_title'),
|
||||
description: errorMsg,
|
||||
@@ -119,12 +63,13 @@ export function BankingSettingsContent() {
|
||||
if (errorCode === 'access_denied') setIsAccessDenied(true)
|
||||
// Handelsbanken rejects business connects with server_error when the
|
||||
// company hasn't registered the open banking fullmakt ("Internet
|
||||
// Företag – tilläggstjänst API Företag") — surface the fix steps.
|
||||
// Företag – tilläggstjänst API Företag"): surface the fix steps.
|
||||
if (bankName === 'Handelsbanken' && psuType === 'business' && errorCode === 'server_error') {
|
||||
setShowHbPoaHint(true)
|
||||
}
|
||||
router.replace('/settings/banking')
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [searchParams, router, toast, t])
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
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'
|
||||
|
||||
@@ -129,6 +130,9 @@ export default function BankSyncNowButton() {
|
||||
? t('bank_sync_new_since_last_visit_one')
|
||||
: t('bank_sync_new_since_last_visit_many', { count: data.imported ?? 0 }),
|
||||
})
|
||||
// Tell the neighbouring status chip to refetch so it doesn't keep showing
|
||||
// the pre-sync "synced Nd ago" until a hard reload.
|
||||
notifyBankSyncUpdated()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast({
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { onBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -72,17 +73,31 @@ export default function BankSyncStatusChip() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
const companyId = company.id
|
||||
let cancelled = false
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('bank_connections')
|
||||
.select('id, status, last_synced_at')
|
||||
.eq('company_id', company.id)
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setRows(data ?? [])
|
||||
})
|
||||
|
||||
const load = () => {
|
||||
supabase
|
||||
.from('bank_connections')
|
||||
.select('id, status, last_synced_at')
|
||||
.eq('company_id', companyId)
|
||||
.then(({ data, error }) => {
|
||||
if (cancelled) return
|
||||
// On error keep the chip hidden (empty rows) rather than rendering a
|
||||
// false "healthy" state from stale data.
|
||||
setRows(error ? [] : (data ?? []))
|
||||
})
|
||||
}
|
||||
|
||||
load()
|
||||
// Refetch when a manual "Sync now" / reconnect elsewhere on the page
|
||||
// changes the connections, so the chip doesn't keep showing "synced 2d ago".
|
||||
const unsubscribe = onBankSyncUpdated(load)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [company?.id])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user