diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 157d1c1c..d39dda93 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -761,10 +761,16 @@ export default function ImportPage() { }) }, []) - // Auto-detect OAuth callback from migration extension + // Auto-detect OAuth callback or deep-link mode from query params useEffect(() => { - if (new URLSearchParams(window.location.search).get('migration')) { + const params = new URLSearchParams(window.location.search) + if (params.get('migration')) { setMode('migration') + } else { + const modeParam = params.get('mode') + if (modeParam && ['psd2', 'bank', 'sie', 'migration'].includes(modeParam)) { + setMode(modeParam as ImportMode) + } } }, []) // If extension isn't compiled in, we know synchronously it's unavailable diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 4e97cf58..d51e56c9 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -54,6 +54,7 @@ export default function SettingsPage() { bankingCompiledIn ? null : false ) const [hasCalendarExtension, setHasCalendarExtension] = useState(false) + const [bankConnectionError, setBankConnectionError] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [deleteConfirmText, setDeleteConfirmText] = useState('') const [isDeleting, setIsDeleting] = useState(false) @@ -153,12 +154,15 @@ export default function SettingsPage() { } if (bankError) { + const errorMsg = decodeURIComponent(bankError) toast({ title: 'Anslutning misslyckades', - description: decodeURIComponent(bankError), + description: errorMsg, variant: 'destructive', }) - router.replace('/settings') + setBankConnectionError(errorMsg) + setActiveTab('banking') + router.replace('/settings?tab=banking') } }, [searchParams]) @@ -548,6 +552,24 @@ export default function SettingsPage() { {/* Banking settings — loaded dynamically from extension, hidden for sandbox */} {!settings?.is_sandbox && ( + {bankConnectionError && ( +
+ +
+

{bankConnectionError}

+

+ Du kan också importera transaktioner via bankfil istället. +

+
+ +
+ )} {hasBankingExtension === null ? (
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 976f3ad4..0a13c961 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast' import { cn } from '@/lib/utils' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import Link from 'next/link' +import { FallbackPrompt } from '@/components/ui/fallback-prompt' import { ArrowLeft, ArrowRight, @@ -259,18 +260,25 @@ function ConnectStep({ )} {error && ( -
- -
-

Anslutning misslyckades

-

{error}

- {provider === 'fortnox' && ( -

- Obs: Fortnox kräver ett aktivt integrationstillägg (tillkostnadsbelagd tilläggstjänst) för att kunna använda integrationer. Kontrollera att detta är aktiverat i ditt Fortnox-konto. -

- )} + <> +
+ +
+

Anslutning misslyckades

+

{error}

+ {provider === 'fortnox' && ( +

+ Obs: Fortnox kräver ett aktivt integrationstillägg (tillkostnadsbelagd tilläggstjänst) för att kunna använda integrationer. Kontrollera att detta är aktiverat i ditt Fortnox-konto. +

+ )} +
-
+ + )} {/* OAuth flow */} @@ -381,10 +389,17 @@ function PreviewStep({ )} {error && ( -
- -

{error}

-
+ <> +
+ +

{error}

+
+ + )} {/* SIE stats summary */} @@ -411,7 +426,7 @@ function PreviewStep({

SIE-hämtning inte tillgänglig

- SIE-hämtning är inte tillgänglig för denna leverantör ännu. Du kan importera SIE-filen manuellt via SIE-importen. + SIE-hämtning är inte tillgänglig för denna leverantör ännu. Du kan importera SIE-filen manuellt via SIE-importen.

@@ -486,6 +501,11 @@ function MappingStep({
+ + + ) +} diff --git a/extensions/general/arcim-migration/lib/__tests__/arcim-client.test.ts b/extensions/general/arcim-migration/lib/__tests__/arcim-client.test.ts new file mode 100644 index 00000000..c9038d93 --- /dev/null +++ b/extensions/general/arcim-migration/lib/__tests__/arcim-client.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.stubEnv('ARCIM_SYNC_GATEWAY_URL', 'https://arcim.test.com') +vi.stubEnv('ARCIM_SYNC_API_KEY', 'test-api-key') + +import { getConsent, createConsent, fetchCompanyInfo, fetchCustomers } from '../arcim-client' + +describe('arcim-client', () => { + let fetchSpy: ReturnType + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + fetchSpy = vi.spyOn(globalThis, 'fetch') + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + fetchSpy.mockRestore() + warnSpy.mockRestore() + }) + + // ------------------------------------------------------------------------- + // Auth & headers + // ------------------------------------------------------------------------- + describe('request headers', () => { + it('sends Authorization and Content-Type headers', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'c1', status: 'active' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + await getConsent('c1') + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [, opts] = fetchSpy.mock.calls[0] + expect(opts?.headers).toMatchObject({ + Authorization: 'Bearer test-api-key', + 'Content-Type': 'application/json', + }) + }) + }) + + // ------------------------------------------------------------------------- + // Retry on retryable HTTP status + // ------------------------------------------------------------------------- + describe('retry', () => { + it('retries on 503 and succeeds', async () => { + const fail = new Response('Service Unavailable', { status: 503 }) + const success = new Response( + JSON.stringify({ id: 'c1', status: 'active' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + fetchSpy + .mockResolvedValueOnce(fail) + .mockResolvedValueOnce(success) + + const result = await getConsent('c1') + expect(result).toEqual({ id: 'c1', status: 'active' }) + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('returned 503, retrying') + ) + }) + + it('retries on 429 (rate limit) and succeeds', async () => { + const rateLimit = new Response('Too Many Requests', { status: 429 }) + const success = new Response( + JSON.stringify({ id: 'c1', status: 'active' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + fetchSpy + .mockResolvedValueOnce(rateLimit) + .mockResolvedValueOnce(success) + + const result = await getConsent('c1') + expect(result).toEqual({ id: 'c1', status: 'active' }) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it('retries on 502 and 504 as well', async () => { + const bad502 = new Response('Bad Gateway', { status: 502 }) + const bad504 = new Response('Gateway Timeout', { status: 504 }) + const success = new Response( + JSON.stringify({ id: 'c1', status: 'active' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + fetchSpy + .mockResolvedValueOnce(bad502) + .mockResolvedValueOnce(bad504) + .mockResolvedValueOnce(success) + + const result = await getConsent('c1') + expect(result).toEqual({ id: 'c1', status: 'active' }) + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) + + it('throws after exhausting all retries on retryable status', async () => { + const fail = new Response('Service Unavailable', { status: 503 }) + + fetchSpy + .mockResolvedValueOnce(fail.clone()) + .mockResolvedValueOnce(fail.clone()) + .mockResolvedValueOnce(fail.clone()) + + await expect(getConsent('c1')).rejects.toThrow('Arcim API 503') + expect(fetchSpy).toHaveBeenCalledTimes(3) // 1 original + 2 retries + }) + + it('does not retry on 400 errors', async () => { + const badRequest = new Response('Bad Request', { status: 400 }) + fetchSpy.mockResolvedValueOnce(badRequest) + + await expect(getConsent('c1')).rejects.toThrow('Arcim API 400') + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it('does not retry on 404 errors', async () => { + const notFound = new Response('Not Found', { status: 404 }) + fetchSpy.mockResolvedValueOnce(notFound) + + await expect(getConsent('c1')).rejects.toThrow('Arcim API 404') + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + }) + + // ------------------------------------------------------------------------- + // Retry on timeout (AbortError) + // ------------------------------------------------------------------------- + describe('timeout retry', () => { + it('retries on AbortError and succeeds', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + const success = new Response( + JSON.stringify({ id: 'c1', status: 'active' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + fetchSpy + .mockRejectedValueOnce(abortError) + .mockResolvedValueOnce(success) + + const result = await getConsent('c1') + expect(result).toEqual({ id: 'c1', status: 'active' }) + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('timed out, retrying') + ) + }) + + it('throws timeout error after exhausting retries on AbortError', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + + fetchSpy + .mockRejectedValueOnce(abortError) + .mockRejectedValueOnce(abortError) + .mockRejectedValueOnce(abortError) + + await expect(getConsent('c1')).rejects.toThrow('Arcim API timeout') + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) + + it('does not retry on non-abort network errors', async () => { + const networkError = new TypeError('fetch failed') + fetchSpy.mockRejectedValueOnce(networkError) + + await expect(getConsent('c1')).rejects.toThrow('fetch failed') + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + }) + + // ------------------------------------------------------------------------- + // Exponential backoff + // ------------------------------------------------------------------------- + describe('backoff', () => { + it('increases delay on successive retries', async () => { + const delays: number[] = [] + const realSetTimeout = globalThis.setTimeout + // Only intercept retry delays (1000–10000ms range), pass abort timers through + vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => { + if (ms && ms >= 1000 && ms < 120_000) { + delays.push(ms as number) + // Execute retry delay callback immediately + if (typeof fn === 'function') fn() + return 0 as unknown as ReturnType + } + // Let abort controller timers run through real setTimeout + return realSetTimeout(fn, ms) + }) + + const fail = new Response('Service Unavailable', { status: 503 }) + const success = new Response( + JSON.stringify({ id: 'c1', status: 'active' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + fetchSpy + .mockResolvedValueOnce(fail.clone()) + .mockResolvedValueOnce(fail.clone()) + .mockResolvedValueOnce(success) + + await getConsent('c1') + + // attempt 0 → delay = 1000 * (0+1) = 1000 + // attempt 1 → delay = 1000 * (1+1) = 2000 + expect(delays).toEqual([1000, 2000]) + + vi.restoreAllMocks() + // Re-set our spies since restoreAllMocks clears them + fetchSpy = vi.spyOn(globalThis, 'fetch') + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + }) + + // ------------------------------------------------------------------------- + // POST body + // ------------------------------------------------------------------------- + describe('request body', () => { + it('sends JSON body for POST requests', async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ id: 'c1', status: 'pending', provider: 'fortnox' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + await createConsent('fortnox' as any, 'Test', '5591234567', 'Test AB') + + const [url, opts] = fetchSpy.mock.calls[0] + expect(url).toBe('https://arcim.test.com/api/v1/consents') + expect(opts?.method).toBe('POST') + expect(JSON.parse(opts?.body as string)).toEqual({ + name: 'Test', + provider: 'fortnox', + orgNumber: '5591234567', + companyName: 'Test AB', + }) + }) + }) + + // ------------------------------------------------------------------------- + // Pagination (fetchAllPages) + // ------------------------------------------------------------------------- + describe('pagination', () => { + it('fetches all pages until hasMore is false', async () => { + const page1 = { data: [{ id: 'c1' }, { id: 'c2' }], hasMore: true } + const page2 = { data: [{ id: 'c3' }], hasMore: false } + + fetchSpy + .mockResolvedValueOnce( + new Response(JSON.stringify(page1), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(page2), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + const result = await fetchCustomers('consent-1') + expect(result).toHaveLength(3) + expect(result.map((c: any) => c.id)).toEqual(['c1', 'c2', 'c3']) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it('stops when page returns empty data', async () => { + const page1 = { data: [{ id: 'c1' }], hasMore: true } + const page2 = { data: [], hasMore: true } + + fetchSpy + .mockResolvedValueOnce( + new Response(JSON.stringify(page1), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(page2), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + const result = await fetchCustomers('consent-1') + expect(result).toHaveLength(1) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + }) + + // ------------------------------------------------------------------------- + // Singleton resource (fetchCompanyInfo) + // ------------------------------------------------------------------------- + describe('singleton resource', () => { + it('unwraps { data } envelope for company info', async () => { + const company = { orgNumber: '5591234567', name: 'Test AB' } + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ data: company }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + const result = await fetchCompanyInfo('consent-1') + expect(result).toEqual(company) + }) + + it('returns null when data is undefined', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + const result = await fetchCompanyInfo('consent-1') + expect(result).toBeNull() + }) + }) + + // ------------------------------------------------------------------------- + // Missing env vars + // ------------------------------------------------------------------------- + describe('environment validation', () => { + it('throws when ARCIM_SYNC_GATEWAY_URL is missing', async () => { + const orig = process.env.ARCIM_SYNC_GATEWAY_URL + delete process.env.ARCIM_SYNC_GATEWAY_URL + + try { + await expect(getConsent('c1')).rejects.toThrow( + 'ARCIM_SYNC_GATEWAY_URL is not configured' + ) + } finally { + process.env.ARCIM_SYNC_GATEWAY_URL = orig + } + }) + + it('throws when ARCIM_SYNC_API_KEY is missing', async () => { + const orig = process.env.ARCIM_SYNC_API_KEY + delete process.env.ARCIM_SYNC_API_KEY + + try { + await expect(getConsent('c1')).rejects.toThrow( + 'ARCIM_SYNC_API_KEY is not configured' + ) + } finally { + process.env.ARCIM_SYNC_API_KEY = orig + } + }) + }) +}) diff --git a/extensions/general/arcim-migration/lib/arcim-client.ts b/extensions/general/arcim-migration/lib/arcim-client.ts index dc1a8dd6..3567cbcf 100644 --- a/extensions/general/arcim-migration/lib/arcim-client.ts +++ b/extensions/general/arcim-migration/lib/arcim-client.ts @@ -29,42 +29,62 @@ function getApiKey(): string { return key } +const MAX_RETRIES = 2 +const RETRY_DELAY_MS = 1_000 +const RETRYABLE_STATUSES = [429, 502, 503, 504] + async function request( path: string, options: RequestInit = {}, timeoutMs: number = 120_000 ): Promise { const url = `${getBaseUrl()}${path}` - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - let response: Response - try { - response = await fetch(url, { - ...options, - signal: controller.signal, - headers: { - 'Authorization': `Bearer ${getApiKey()}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }) - } catch (err) { - clearTimeout(timer) - if (err instanceof DOMException || (err instanceof Error && err.name === 'AbortError')) { - throw new Error(`Arcim API timeout after ${Math.round(timeoutMs / 1000)}s: ${path}`) + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + let response: Response + try { + response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Authorization': `Bearer ${getApiKey()}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + } catch (err) { + const isAbort = err instanceof DOMException || (err instanceof Error && err.name === 'AbortError') + if (attempt < MAX_RETRIES && isAbort) { + console.warn(`[arcim] ${path} timed out, retrying (attempt ${attempt + 1})`) + await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1))) + continue + } + if (isAbort) { + throw new Error(`Arcim API timeout after ${Math.round(timeoutMs / 1000)}s: ${path}`) + } + throw err + } finally { + clearTimeout(timer) } - throw err - } finally { - clearTimeout(timer) + + if (attempt < MAX_RETRIES && RETRYABLE_STATUSES.includes(response.status)) { + console.warn(`[arcim] ${path} returned ${response.status}, retrying (attempt ${attempt + 1})`) + await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1))) + continue + } + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`) + } + + return response.json() } - if (!response.ok) { - const body = await response.text().catch(() => '') - throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`) - } - - return response.json() + throw new Error(`Arcim API failed after ${MAX_RETRIES + 1} attempts: ${path}`) } // ── Consent lifecycle ─────────────────────────────────────────────── diff --git a/extensions/general/enable-banking/components/BankConnectionStatus.tsx b/extensions/general/enable-banking/components/BankConnectionStatus.tsx index bdb7c6d9..52e6bef4 100644 --- a/extensions/general/enable-banking/components/BankConnectionStatus.tsx +++ b/extensions/general/enable-banking/components/BankConnectionStatus.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { formatDate } from '@/lib/utils' import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client' +import Link from 'next/link' import { CreditCard, AlertTriangle, @@ -12,6 +13,7 @@ import { Loader2, CheckCircle, XCircle, + Upload, } from 'lucide-react' import type { BankConnection } from '@/types' @@ -178,22 +180,38 @@ export function BankConnectionStatus({ {/* Error message */} {isConnectionError && errorMessage && ( -
- - - {errorMessage} - -
+ <> +
+ + + {errorMessage} + +
+
+ + + Du kan också importera transaktioner via bankfil + +
+ )} {/* Expired consent notice */} {isConnectionExpired && ( -
- - - PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen. - -
+ <> +
+ + + PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen. + +
+
+ + + Medan du väntar kan du importera transaktioner via bankfil + +
+ )} {/* Consent expiry warning (for active connections) */} diff --git a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx index 75f93340..0f9367d2 100644 --- a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx +++ b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx @@ -1,10 +1,12 @@ 'use client' import { useState, useEffect } from 'react' +import Link from 'next/link' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' -import { Loader2 } from 'lucide-react' +import { Loader2, Upload } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { BankSelector, type Bank } from './BankSelector' import { BankConnectionStatus } from './BankConnectionStatus' @@ -25,6 +27,7 @@ export default function BankingSettingsPanel() { const [isConnecting, setIsConnecting] = useState(false) const [connectingBankName, setConnectingBankName] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [showCsvFallback, setShowCsvFallback] = useState(false) useEffect(() => { fetchConnections() @@ -71,6 +74,7 @@ export default function BankingSettingsPanel() { }) setIsConnecting(false) setConnectingBankName(null) + setShowCsvFallback(true) } } @@ -95,6 +99,7 @@ export default function BankingSettingsPanel() { description: `${data.imported} nya transaktioner importerade`, }) + setShowCsvFallback(false) fetchConnections() } catch (error) { toast({ @@ -102,6 +107,7 @@ export default function BankingSettingsPanel() { description: error instanceof Error ? error.message : 'Synkronisering misslyckades', variant: 'destructive', }) + setShowCsvFallback(true) } setSyncingConnectionId(null) @@ -157,6 +163,19 @@ export default function BankingSettingsPanel() {
+ {/* Persistent CSV fallback after connection/sync failure */} + {showCsvFallback && ( +
+ +

+ Har du problem med bankanslutningen? Du kan importera transaktioner manuellt via bankfil. +

+ +
+ )} + {/* Action required — expired/error connections */} {actionRequiredConnections.length > 0 && (