feat: resilience fallbacks, Arcim retry logic, and client tests (#52)

* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2)

Reversed entries (storno) must appear alongside their original posted entries
in reports for a complete audit trail. Previously, filtering by status='posted'
excluded them, causing discrepancies when corrections had been made.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: semi-manual invoice payment booking with editable journal lines

When marking an invoice as paid, users now see a dialog where they can:
- Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.)
- Review and edit the proposed journal entry lines before committing
- The happy path remains fast — lines are pre-filled correctly

Implementation:
- Pure proposePaymentLines() function for line computation (accrual + cash)
- PaymentBookingDialog with AccountCombobox, balance validation, date picker
- API accepts optional custom lines, falls back to auto-generation without them
- 18 tests (8 unit + 10 API) all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — validation fallback, balance check, error handling

- P1: Return 400 on invalid body instead of silently falling back to
  auto-generated lines (split JSON parse from schema validation)
- P1: Add server-side balance check for custom lines before committing
  (debit must equal credit, totalDebit > 0)
- P2: Wrap PaymentBookingDialog init() in try/catch with toast on
  failure and auto-close instead of silent empty state
- Add 2 new tests: unbalanced lines → 400, invalid schema → 400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: OAuth callback redirect for local dev and timeout resilience

- Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth
  callbacks work on localhost (not just production)
- Encode consentId/provider in OAuth state (base64url JSON) so the
  callback doesn't depend on session storage
- Add skipAuth flag to extension API routes for OAuth callbacks
  (external provider redirects have no user session cookie)
- Wrap AbortError in descriptive timeout messages in arcim-client
- Make preview endpoint resilient to partial failures (company info
  and SIE fetch are individually non-blocking)
- Simplify login page (remove unused magic link auth mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: create journal entry before marking invoice as paid

Move journal entry creation before the invoice status update so that
if accounting fails, the invoice is not permanently marked paid without
a corresponding entry. Previously the error was silently swallowed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update mark-paid tests for journal-first ordering

Reorder mock queue to match new flow (settings before update), update
failure test to expect 500 instead of silent success, add try-catch
with proper error response in route handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard

Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration:
- Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT
- Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type
- Include ruta 30-32 in ruta 49 formula and totalOutputVat summary
- Display reverse charge section in reports UI and composition chart
- Add comprehensive test coverage for all reverse charge scenarios

Improve mobile UX across the app:
- Convert nav drawer to bottom sheet with drag handle and safe area padding
- Add mobile card layout for PaymentBookingDialog journal lines
- Replace settings tab pills with dropdown selector on mobile
- Make wizard step indicators responsive (collapsed on mobile)
- Ensure all dialog footers stack buttons full-width on mobile
- Add 44px minimum touch targets throughout
- Make onboarding buttons full-width on mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — indentation, query efficiency, tab dedup

- Fix misleading try-block indentation in mark-paid route
- Filter reversed entries at DB level (.eq('status', 'posted')) instead
  of fetching then discarding in memory
- Extract shared settingsTabs array so mobile Select and desktop
  TabsList stay in sync automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add resilience fallbacks, Arcim retry logic, and client tests

Add FallbackPrompt component and integrate it across banking and migration
error states so users always have a manual import escape hatch. Add retry
with exponential backoff to Arcim API client for transient failures (429,
502, 503, 504) and timeouts. Expand import page deep-linking with ?mode=
parameter. Add persistent error banner on settings page for bank connection
failures. Include 18 new tests for the Arcim client covering retry, backoff,
pagination, timeout, env validation, and singleton resource unwrapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — setActiveTab, test cleanup, redundant clearTimeout

- Add missing setActiveTab('banking') when handling bank_error query
  param so the error banner is actually visible (P1)
- Guard env-var cleanup with try/finally in arcim-client tests to
  prevent state leakage on assertion failure (P2)
- Only mock retry-range setTimeout delays in backoff test, letting
  AbortController timers pass through real setTimeout (P2)
- Remove redundant clearTimeout in catch block — finally handles it (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-18 14:15:08 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ceec8c02a8
commit cdb40e7af3
8 changed files with 547 additions and 59 deletions
+8 -2
View File
@@ -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
+24 -2
View File
@@ -54,6 +54,7 @@ export default function SettingsPage() {
bankingCompiledIn ? null : false
)
const [hasCalendarExtension, setHasCalendarExtension] = useState(false)
const [bankConnectionError, setBankConnectionError] = useState<string | null>(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 && (
<TabsContent value="banking" className="space-y-6">
{bankConnectionError && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
<p className="mt-1 text-sm text-muted-foreground">
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
</p>
</div>
<button
onClick={() => setBankConnectionError(null)}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
aria-label="Stäng"
>
<span className="text-lg leading-none">&times;</span>
</button>
</div>
)}
{hasBankingExtension === null ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -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 && (
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="font-medium text-destructive">Anslutning misslyckades</p>
<p className="text-sm text-muted-foreground">{error}</p>
{provider === 'fortnox' && (
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
)}
<>
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="font-medium text-destructive">Anslutning misslyckades</p>
<p className="text-sm text-muted-foreground">{error}</p>
{provider === 'fortnox' && (
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
)}
</div>
</div>
</div>
<FallbackPrompt
message="Du kan också importera din bokföringsdata manuellt via en SIE-fil."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
</>
)}
{/* OAuth flow */}
@@ -381,10 +389,17 @@ function PreviewStep({
)}
{error && (
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<p className="text-sm text-muted-foreground">{error}</p>
</div>
<>
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<p className="text-sm text-muted-foreground">{error}</p>
</div>
<FallbackPrompt
message="Du kan också importera din bokföringsdata manuellt via en SIE-fil."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
</>
)}
{/* SIE stats summary */}
@@ -411,7 +426,7 @@ function PreviewStep({
<div>
<p className="text-sm font-medium text-warning-foreground">SIE-hämtning inte tillgänglig</p>
<p className="text-xs text-muted-foreground">
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 <Link href="/import?mode=sie" className="underline hover:text-foreground">SIE-importen</Link>.
</p>
</div>
</div>
@@ -486,6 +501,11 @@ function MappingStep({
</div>
</CardContent>
</Card>
<FallbackPrompt
message="Om problemet kvarstår kan du importera din SIE-fil manuellt istället."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
@@ -948,6 +968,11 @@ function ResultStep({
</div>
</CardContent>
</Card>
<FallbackPrompt
message="Du kan istället importera din bokföringsdata manuellt via en SIE-fil."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onDone}>Klar</Button>
<Button className="min-h-11" onClick={onRetry}>
+21
View File
@@ -0,0 +1,21 @@
import { Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
interface FallbackPromptProps {
message: string
linkHref: string
linkLabel: string
}
export function FallbackPrompt({ message, linkHref, linkLabel }: FallbackPromptProps) {
return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
<Upload className="h-5 w-5 shrink-0 text-muted-foreground" />
<p className="flex-1 text-sm text-muted-foreground">{message}</p>
<Button variant="outline" size="sm" asChild>
<Link href={linkHref}>{linkLabel}</Link>
</Button>
</div>
)
}
@@ -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<typeof vi.spyOn>
let warnSpy: ReturnType<typeof vi.spyOn>
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<typeof setTimeout>
}
// 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
}
})
})
})
@@ -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<T>(
path: string,
options: RequestInit = {},
timeoutMs: number = 120_000
): Promise<T> {
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 ───────────────────────────────────────────────
@@ -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 && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 rounded-lg">
<XCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<span className="text-sm text-destructive">
{errorMessage}
</span>
</div>
<>
<div className="flex items-center gap-2 p-3 bg-destructive/10 rounded-lg">
<XCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<span className="text-sm text-destructive">
{errorMessage}
</span>
</div>
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
<Upload className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link>
</span>
</div>
</>
)}
{/* Expired consent notice */}
{isConnectionExpired && (
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning flex-shrink-0" />
<span className="text-sm">
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
</span>
</div>
<>
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning flex-shrink-0" />
<span className="text-sm">
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
</span>
</div>
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
<Upload className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Medan du väntar kan du <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link>
</span>
</div>
</>
)}
{/* Consent expiry warning (for active connections) */}
@@ -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<string | null>(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() {
<div className="space-y-6">
<DestructiveConfirmDialog {...dialogProps} />
{/* Persistent CSV fallback after connection/sync failure */}
{showCsvFallback && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
<Upload className="h-5 w-5 shrink-0 text-muted-foreground" />
<p className="flex-1 text-sm text-muted-foreground">
Har du problem med bankanslutningen? Du kan importera transaktioner manuellt via bankfil.
</p>
<Button variant="outline" size="sm" asChild>
<Link href="/import?mode=bank">Importera bankfil</Link>
</Button>
</div>
)}
{/* Action required — expired/error connections */}
{actionRequiredConnections.length > 0 && (
<Card className="border-warning/30">