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:
+28
-200
@@ -4,7 +4,7 @@ import { useState, useCallback, useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -14,11 +14,8 @@ import { cn, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
|
||||
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import type { BankConnection } from '@/types'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
|
||||
// Bank file import components
|
||||
import BankFileUploadStep from '@/components/import/BankFileUploadStep'
|
||||
@@ -1904,201 +1901,14 @@ function CSVDataImportWizard() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PSD2 Bank Connection (inline, from Enable Banking extension)
|
||||
// Banking (PSD2) connect UI
|
||||
// ============================================================
|
||||
|
||||
function PSD2ConnectWizard() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
const { company } = useCompany()
|
||||
const hasBankSync = useCapability(CAPABILITY.bank_sync)
|
||||
|
||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections()
|
||||
}, [])
|
||||
|
||||
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 || [])
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleConnectBank(bank: Bank) {
|
||||
setIsConnecting(true)
|
||||
setConnectingBankName(bank.name)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
window.location.href = data.authorization_url
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte ansluta bank',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsConnecting(false)
|
||||
setConnectingBankName(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncTransactions(connectionId: string) {
|
||||
setSyncingConnectionId(connectionId)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Synkronisering klar',
|
||||
description: `${data.imported} nya transaktioner importerade`,
|
||||
})
|
||||
|
||||
fetchConnections()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Synkronisering misslyckades',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setSyncingConnectionId(null)
|
||||
}
|
||||
|
||||
async function handleDisconnectBank(connectionId: string) {
|
||||
const ok = await confirm({
|
||||
title: 'Koppla bort bank?',
|
||||
description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.',
|
||||
confirmLabel: 'Koppla bort',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/disconnect', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Disconnect failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Bank bortkopplad',
|
||||
description: 'Bankanslutningen och PSD2-samtycket har återkallats',
|
||||
})
|
||||
fetchConnections()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte koppla bort bank',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeConnections = bankConnections.filter((c) => c.status === 'active')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
|
||||
{/* Connected banks */}
|
||||
{activeConnections.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslutna banker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeConnections.map((connection) => (
|
||||
<BankConnectionStatus
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connect new bank. Non-payers see the card but the bank list is
|
||||
replaced by an upgrade note: the server gate would 403 the connect. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslut din bank</CardTitle>
|
||||
<CardDescription>
|
||||
Välj din bank nedan för att koppla ditt konto via PSD2. Transaktioner synkas automatiskt varje dag.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasBankSync ? (
|
||||
<UpgradeNote>
|
||||
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera
|
||||
transaktioner manuellt via bankfiler nedan.
|
||||
</UpgradeNote>
|
||||
) : (
|
||||
<BankSelector
|
||||
onConnect={handleConnectBank}
|
||||
isConnecting={isConnecting}
|
||||
connectingBankName={connectingBankName}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Provided by the enable-banking extension and loaded through the settings
|
||||
// panel registry (dynamic import), so this core page never imports from
|
||||
// @/extensions directly. The shared panel renders every connection state
|
||||
// (pending account selection, active, expiring, and expired/error with the
|
||||
// reconnect entry point), which the old inline wizard here did not.
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
|
||||
// ============================================================
|
||||
// Import Page with Selection Cards
|
||||
@@ -2475,7 +2285,25 @@ export default function ImportPage() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mode === 'psd2' && <PSD2ConnectWizard />}
|
||||
{mode === 'psd2' && (
|
||||
hasBankingExtension && BankingPanel ? (
|
||||
<BankingPanel />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Landmark className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="mb-1 font-medium">Bankintegration (PSD2) är inte aktiverad</p>
|
||||
<p className="mb-4 max-w-md text-sm text-muted-foreground">
|
||||
Aktivera tillägget Enable Banking för att koppla ditt bankkonto, eller importera
|
||||
transaktioner manuellt via bankfil.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setMode('bank')}>
|
||||
Importera bankfil istället
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
{mode === 'bank' && <BankFileImportWizard />}
|
||||
{mode === 'sie' && <SIEImportWizard />}
|
||||
{mode === 'csv_data' && <CSVDataImportWizard />}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Cross-component signal that a bank sync (or reconnect) just changed
|
||||
* `bank_connections` rows or imported new transactions.
|
||||
*
|
||||
* Several surfaces render bank-sync status from a client-side fetch on mount
|
||||
* (the transactions-page status chip, the "since last visit" pill, the banking
|
||||
* settings panel). Without a shared signal they show stale data until a hard
|
||||
* reload: e.g. a manual "Sync now" pulls fresh rows but the neighbouring chip
|
||||
* keeps showing the old "synced 2d ago" until the page is reloaded.
|
||||
*
|
||||
* Sync entry points call `notifyBankSyncUpdated()` on success; status surfaces
|
||||
* subscribe with `onBankSyncUpdated()` and refetch. This is a browser-only
|
||||
* CustomEvent, so it is a no-op during SSR.
|
||||
*/
|
||||
export const BANK_SYNC_UPDATED_EVENT = 'gnubok:bank-sync-updated'
|
||||
|
||||
export function notifyBankSyncUpdated(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new Event(BANK_SYNC_UPDATED_EVENT))
|
||||
}
|
||||
|
||||
export function onBankSyncUpdated(handler: () => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
window.addEventListener(BANK_SYNC_UPDATED_EVENT, handler)
|
||||
return () => window.removeEventListener(BANK_SYNC_UPDATED_EVENT, handler)
|
||||
}
|
||||
@@ -1280,15 +1280,6 @@
|
||||
},
|
||||
"settings_api": {},
|
||||
"settings_banking": {
|
||||
"sync_start_title": "Syncing transactions...",
|
||||
"sync_start_description": "Fetching transactions from your bank in the background.",
|
||||
"sync_success_title": "Bank connected!",
|
||||
"sync_success_description": "{count} transactions imported",
|
||||
"sync_success_no_id_description": "Your bank is now linked.",
|
||||
"sync_timeout_title": "Sync took too long",
|
||||
"sync_timeout_description": "Transactions are being fetched in the background. Reload the page in a moment.",
|
||||
"sync_failed_title": "Sync failed",
|
||||
"sync_failed_default": "Could not fetch transactions",
|
||||
"connect_failed_title": "Connection failed",
|
||||
"bank_server_error": "The bank rejected the connection.",
|
||||
"hb_business_poa_hint": "Handelsbanken requires a power of attorney before business accounts can be connected: log in to the corporate online bank, go to Powers of attorney → Self-service services and enable \"Internet Företag – tilläggstjänst API Företag\". Then link the power of attorney to the person who will approve the connection and try again.",
|
||||
|
||||
@@ -1280,15 +1280,6 @@
|
||||
},
|
||||
"settings_api": {},
|
||||
"settings_banking": {
|
||||
"sync_start_title": "Synkroniserar transaktioner...",
|
||||
"sync_start_description": "Hämtar transaktioner från din bank i bakgrunden.",
|
||||
"sync_success_title": "Bank ansluten!",
|
||||
"sync_success_description": "{count} transaktioner importerade",
|
||||
"sync_success_no_id_description": "Din bank är nu kopplad.",
|
||||
"sync_timeout_title": "Synkronisering tog för lång tid",
|
||||
"sync_timeout_description": "Transaktionerna hämtas i bakgrunden. Ladda om sidan om en stund.",
|
||||
"sync_failed_title": "Synkronisering misslyckades",
|
||||
"sync_failed_default": "Kunde inte hämta transaktioner",
|
||||
"connect_failed_title": "Anslutning misslyckades",
|
||||
"bank_server_error": "Banken avvisade anslutningen.",
|
||||
"hb_business_poa_hint": "Handelsbanken kräver en fullmakt innan företagskonton kan kopplas: logga in i internetbanken för företag, gå till Fullmakter → Självbetjäningstjänster och aktivera \"Internet Företag – tilläggstjänst API Företag\". Koppla sedan fullmakten till den person som ska godkänna anslutningen och försök igen.",
|
||||
|
||||
Reference in New Issue
Block a user