From 211033410c10f616d725e945c91393820fc2c751 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:17:00 +0200 Subject: [PATCH] Fix/import data (#200) * fix: enhance import data handling and consent management across components * feat: Enhance SIE import functionality with validation and error handling improvements - Added validation errors and warnings state management in SIEImportWizard. - Improved error handling for duplicate, validation, and parsing errors during SIE file import. - Enhanced user feedback with actionable guidance for common import errors. - Updated SIEUploadStep to display validation errors and warnings. - Improved error messages in API routes for better clarity and user experience. - Added file size and type validation in the SIE parse route. - Enhanced parsing logic to provide more detailed error messages for unbalanced vouchers and missing amounts. - Created a new storage bucket for SIE file archival in Supabase with appropriate policies for user access. - Updated tests to reflect changes in error messages and validation logic. * fix: Improve type assertion for response in getPage method * Update extensions/general/arcim-migration/lib/migration-orchestrator.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update supabase/migrations/20260408130000_sie_files_storage_bucket.sql Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: Add company ID verification for consent handling in accept and disconnect endpoints --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- app/(dashboard)/import/page.tsx | 74 +++++++++--- app/api/import/sie/create-accounts/route.ts | 6 +- app/api/import/sie/execute/route.ts | 15 ++- app/api/import/sie/parse/route.ts | 29 ++++- .../general/ArcimMigrationWorkspace.tsx | 32 ++++-- components/import/ImportResultStep.tsx | 18 ++- components/import/SIEPreviewStep.tsx | 46 ++++++-- components/import/SIEUploadStep.tsx | 86 ++++++++++++-- extensions/general/arcim-migration/index.ts | 108 ++++++++++++++++-- .../lib/migration-orchestrator.ts | 2 + .../arcim-migration/lib/provider-client.ts | 11 +- lib/import/__tests__/sie-import.test.ts | 2 +- lib/import/__tests__/sie-parser.test.ts | 20 ++-- lib/import/sie-import.ts | 2 +- lib/import/sie-parser.ts | 54 ++++++--- lib/providers/bokio/client.ts | 42 ++++++- lib/providers/bokio/mapper.ts | 10 +- lib/providers/provider-data-fetcher.ts | 43 +++++-- lib/providers/resolve-consent.ts | 5 +- ...0260408130000_sie_files_storage_bucket.sql | 50 ++++++++ 20 files changed, 529 insertions(+), 126 deletions(-) create mode 100644 supabase/migrations/20260408130000_sie_files_storage_bucket.sql diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 851835ce..588ed7e4 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' @@ -323,6 +324,8 @@ function SIEImportWizard() { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | undefined>() + const [validationErrors, setValidationErrors] = useState([]) + const [validationWarnings, setValidationWarnings] = useState([]) const [file, setFile] = useState(null) const [, setParsed] = useState(null) @@ -347,6 +350,8 @@ function SIEImportWizard() { setFile(selectedFile) setError(null) setErrorType(undefined) + setValidationErrors([]) + setValidationWarnings([]) setIsLoading(true) try { @@ -361,15 +366,21 @@ function SIEImportWizard() { const data = await res.json() if (!res.ok) { - if (data.error === 'duplicate' || data.error === 'duplicate_period') { - setErrorType(data.error) + const type = data.error as typeof errorType + if (type === 'duplicate' || type === 'duplicate_period') { + setErrorType(type) setError(data.message) - } else if (data.error === 'validation') { + toast({ title: type === 'duplicate' ? 'Filen har redan importerats' : 'Överlappande räkenskapsår', description: data.message, variant: 'destructive' }) + } else if (type === 'validation') { setErrorType('validation') - setError(`${data.message}: ${data.errors?.join(', ') || 'Unknown validation error'}`) + setError(data.message || 'SIE-filen innehåller valideringsfel.') + setValidationErrors(data.errors || []) + setValidationWarnings(data.warnings || []) + toast({ title: 'Valideringsfel i SIE-filen', description: `${(data.errors || []).length} fel hittades som måste åtgärdas.`, variant: 'destructive' }) } else { setErrorType('parse') - setError(data.error || 'Failed to parse file') + setError(data.message || data.error || 'Kunde inte tolka filen.') + toast({ title: 'Kunde inte läsa filen', description: data.message || data.error || 'Kontrollera att filen är en giltig SIE-fil.', variant: 'destructive' }) } return } @@ -402,7 +413,13 @@ function SIEImportWizard() { description: `${data.parsed.stats.totalAccounts} konton och ${data.parsed.stats.totalVouchers} verifikationer hittades`, }) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to parse file') + const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError')) + const message = isNetworkError + ? 'Kunde inte nå servern. Kontrollera din internetanslutning och försök igen.' + : err instanceof Error ? err.message : 'Ett oväntat fel uppstod.' + setErrorType('parse') + setError(message) + toast({ title: 'Anslutningsfel', description: message, variant: 'destructive' }) } finally { setIsLoading(false) } @@ -506,9 +523,17 @@ function SIEImportWizard() { if (!res.ok) { if (data.error === 'duplicate') { setError(data.message || 'Denna fil har redan importerats') + toast({ title: 'Filen har redan importerats', description: data.message, variant: 'destructive' }) + return + } + if (data.result) { + setImportResult(data.result) + } else { + const msg = data.message || data.error || 'Importen misslyckades.' + setError(msg) + toast({ title: 'Import misslyckades', description: msg, variant: 'destructive' }) return } - if (data.result) { setImportResult(data.result) } else { setError(data.error || 'Import failed'); return } } else { setImportResult(data.result) } @@ -516,21 +541,38 @@ function SIEImportWizard() { setStep('result') if (data.result?.success) { - toast({ title: 'Import genomförd', description: `${data.result.journalEntriesCreated} verifikationer skapades` }) + const created = data.result.journalEntriesCreated + const skipped = data.result.details?.skippedVouchers?.total || 0 + toast({ + title: 'Import genomförd', + description: `${created} verifikationer skapades${skipped > 0 ? ` (${skipped} hoppades över)` : ''}`, + }) + } else if (data.result && !data.result.success) { + toast({ + title: 'Import slutförd med problem', + description: `${data.result.errors?.length || 0} fel uppstod under importen. Se resultatet för detaljer.`, + variant: 'destructive', + }) } } catch (err) { - setError(err instanceof Error ? err.message : 'Import failed') + const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError')) + const msg = isNetworkError + ? 'Tappade anslutningen till servern under importen. Kontrollera din internetanslutning och se om importen genomfördes under Bokföring.' + : err instanceof Error ? err.message : 'Ett oväntat fel uppstod.' + setError(msg) + toast({ title: 'Import avbröts', description: msg, variant: 'destructive' }) } finally { setIsLoading(false) } }, [file, mappings, toast]) - const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) } + const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null); setValidationErrors([]); setValidationWarnings([]) } const goBack = () => { const i = sieSteps.indexOf(step); if (i > 0) setStep(sieSteps[i - 1]) } const handleNewImport = () => { setStep('upload'); setFile(null); setParsed(null); setMappings([]) setPreview(null); setIssues([]); setImportResult(null); setError(null); setErrorType(undefined) + setValidationErrors([]); setValidationWarnings([]) setSieAccounts([]); setIsCreatingAccounts(false) } @@ -557,7 +599,7 @@ function SIEImportWizard() { - {step === 'upload' && } + {step === 'upload' && } {step === 'preview' && preview && ( { if (isSandbox) return - const params = new URLSearchParams(window.location.search) - if (params.get('migration')) { + if (searchParams.get('migration')) { setMode('migration') } else { - const modeParam = params.get('mode') + const modeParam = searchParams.get('mode') if (modeParam && ['psd2', 'bank', 'sie', 'migration'].includes(modeParam)) { setMode(modeParam as ImportMode) } } - }, [isSandbox]) + }, [isSandbox, searchParams]) // Extensions are active if compiled in — no runtime toggle check needed const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration') diff --git a/app/api/import/sie/create-accounts/route.ts b/app/api/import/sie/create-accounts/route.ts index e0f6ec0e..440ed3b3 100644 --- a/app/api/import/sie/create-accounts/route.ts +++ b/app/api/import/sie/create-accounts/route.ts @@ -71,7 +71,7 @@ export async function POST(request: Request) { const accounts: SIEAccount[] = body.accounts if (!accounts || !Array.isArray(accounts) || accounts.length === 0) { - return NextResponse.json({ error: 'No accounts provided' }, { status: 400 }) + return NextResponse.json({ error: 'Inga konton att skapa.' }, { status: 400 }) } // Prepare accounts for upsert (idempotent — safe to retry) @@ -117,7 +117,7 @@ export async function POST(request: Request) { if (error) { console.error('Error upserting accounts batch:', error) return NextResponse.json({ - error: `Failed to create accounts: ${error.message}`, + error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`, created: totalCreated, }, { status: 500 }) } @@ -134,7 +134,7 @@ export async function POST(request: Request) { } catch (error) { console.error('Create accounts error:', error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to create accounts' }, + { error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` }, { status: 500 } ) } diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index 2967f1ff..f441e1c0 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -37,7 +37,7 @@ export async function POST(request: Request) { const optionsJson = formData.get('options') as string | null if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + return NextResponse.json({ error: 'Ingen fil bifogad. Gå tillbaka och ladda upp filen igen.' }, { status: 400 }) } // Parse options @@ -87,9 +87,11 @@ export async function POST(request: Request) { // Validate all accounts are mapped const unmapped = mappings.filter((m) => !m.targetAccount) if (unmapped.length > 0) { + const accountList = unmapped.slice(0, 5).map((m) => `${m.sourceAccount} (${m.sourceName})`).join(', ') + const remaining = unmapped.length > 5 ? ` och ${unmapped.length - 5} till` : '' return NextResponse.json({ error: 'validation', - message: `${unmapped.length} account(s) are not mapped`, + message: `${unmapped.length} konto(n) saknar mappning: ${accountList}${remaining}. Gå tillbaka till kontomappningssteget och koppla alla konton.`, unmappedAccounts: unmapped.map((m) => ({ account: m.sourceAccount, name: m.sourceName, @@ -183,7 +185,7 @@ export async function POST(request: Request) { if (activateError) { return NextResponse.json({ - error: `Failed to activate accounts: ${activateError.message}`, + error: `Kunde inte aktivera konton i kontoplanen: ${activateError.message}. Kontrollera att kontona inte redan finns med andra inställningar.`, }, { status: 500 }) } } @@ -208,7 +210,7 @@ export async function POST(request: Request) { if (!result.success) { return NextResponse.json({ error: 'import', - message: 'Import completed with errors', + message: 'Importen slutfördes med fel. Se detaljerna nedan för att förstå vad som gick snett.', result, }, { status: 400 }) } @@ -219,8 +221,11 @@ export async function POST(request: Request) { }) } catch (error) { console.error('SIE import error:', error) + const detail = error instanceof Error ? error.message : '' return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to import SIE file' }, + { + error: `Importen avbröts oväntat. Ingen data har sparats.${detail ? ` (${detail})` : ''} Försök igen — om felet kvarstår, kontakta support.`, + }, { status: 500 } ) } diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts index e02e2155..53cda865 100644 --- a/app/api/import/sie/parse/route.ts +++ b/app/api/import/sie/parse/route.ts @@ -36,14 +36,31 @@ export async function POST(request: Request) { const file = formData.get('file') as File | null if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + return NextResponse.json({ error: 'parse', message: 'Ingen fil bifogad i förfrågan.' }, { status: 400 }) } // Validate file type const filename = file.name.toLowerCase() if (!filename.endsWith('.sie') && !filename.endsWith('.se')) { return NextResponse.json( - { error: 'Invalid file type. Please upload a .sie file' }, + { error: 'parse', message: 'Filtypen stöds inte. Ladda upp en fil med ändelsen .sie eller .se.' }, + { status: 400 } + ) + } + + // Validate file size (max 50 MB) + const MAX_FILE_SIZE = 50 * 1024 * 1024 + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: 'parse', message: `Filen är för stor (${(file.size / 1024 / 1024).toFixed(1)} MB). Maxstorlek är 50 MB.` }, + { status: 400 } + ) + } + + // Validate file is not empty + if (file.size === 0) { + return NextResponse.json( + { error: 'parse', message: 'Filen är tom (0 bytes). Kontrollera att exporten från bokföringsprogrammet genomfördes korrekt.' }, { status: 400 } ) } @@ -92,7 +109,7 @@ export async function POST(request: Request) { if (!validation.valid) { return NextResponse.json({ error: 'validation', - message: 'SIE file has validation errors', + message: 'SIE-filen innehåller valideringsfel som måste åtgärdas innan import.', errors: validation.errors, warnings: validation.warnings, }, { status: 400 }) @@ -151,8 +168,12 @@ export async function POST(request: Request) { }) } catch (error) { console.error('SIE parse error:', error) + const detail = error instanceof Error ? error.message : '' return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to parse SIE file' }, + { + error: 'parse', + message: `Kunde inte tolka SIE-filen. Filen kan vara skadad eller i ett format som inte stöds.${detail ? ` (${detail})` : ''}`, + }, { status: 500 } ) } diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index e6d741cd..8208aba0 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect } from 'react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' @@ -483,8 +483,9 @@ function ConnectStep({ setApiToken(e.target.value)} @@ -498,7 +499,8 @@ function ConnectStep({ setCompanyId(e.target.value)} @@ -614,13 +616,14 @@ function PreviewStep({

Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil innan kunder, leverantörer och fakturor kan hämtas. Exportera en SIE-fil från {ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? 'ditt bokföringssystem'} och ladda upp den i gnubok.

- + + + Gå till SIE-importen + + )} @@ -1855,6 +1858,15 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) setMigrationResults(data.results) } + // Mark consent as fully accepted now that import is complete + if (consentId) { + await fetch('/api/extensions/ext/arcim-migration/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ consentId }), + }).catch(() => { /* best-effort */ }) + } + setMigrationProgress(100) setStep('result') diff --git a/components/import/ImportResultStep.tsx b/components/import/ImportResultStep.tsx index 91265f03..6b3589d5 100644 --- a/components/import/ImportResultStep.tsx +++ b/components/import/ImportResultStep.tsx @@ -49,8 +49,10 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt {result.success - ? 'Din bokföring har importerats framgångsrikt.' - : 'Det uppstod fel under importen. Se detaljer nedan.'} + ? skipped && skipped.total > 0 + ? `Din bokföring har importerats. ${result.journalEntriesCreated} verifikationer skapades, ${skipped.total} hoppades över — se detaljer nedan.` + : 'Din bokföring har importerats framgångsrikt.' + : 'Det uppstod fel under importen. Läs felmeddelanden nedan för att förstå vad som gick snett och hur du kan åtgärda det.'} @@ -109,7 +111,7 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt Fel ({result.errors.length}) - +
{result.errors.map((error, i) => (
@@ -118,6 +120,16 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
))}
+ {!result.success && ( +
+

Vad kan du göra?

+
    +
  • Kontrollera att SIE-filen exporterades korrekt från källsystemet
  • +
  • Prova att exportera filen igen och ladda upp på nytt
  • +
  • Om felet kvarstår, kontakta support med felmeddelandet ovan
  • +
+
+ )}
)} diff --git a/components/import/SIEPreviewStep.tsx b/components/import/SIEPreviewStep.tsx index e67c6170..2b0d5202 100644 --- a/components/import/SIEPreviewStep.tsx +++ b/components/import/SIEPreviewStep.tsx @@ -282,31 +282,55 @@ export default function SIEPreviewStep({ )} - {/* Issues */} - {(errors.length > 0 || warnings.length > 0) && ( - 0 ? 'border-destructive/50' : 'border-warning/50'}> + {/* Errors */} + {errors.length > 0 && ( + - {errors.length > 0 ? ( - - ) : ( - - )} - {errors.length > 0 ? 'Fel' : 'Varningar'} + + Tolkningsfel ({errors.length}) + + Dessa fel hittades under tolkningen av SIE-filen och kan påverka importresultatet. +
{errors.map((issue, i) => (
- Rad {issue.line}: {issue.message} + + Rad {issue.line}{' '} + {issue.message} +
))} +
+
+
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( + + + + + Varningar ({warnings.length}) + + + Dessa varningar blockerar inte importen men bör granskas. + + + +
{warnings.map((issue, i) => (
- Rad {issue.line}: {issue.message} + + Rad {issue.line}{' '} + {issue.message} +
))}
diff --git a/components/import/SIEUploadStep.tsx b/components/import/SIEUploadStep.tsx index 82c61bfe..f5cbdaf1 100644 --- a/components/import/SIEUploadStep.tsx +++ b/components/import/SIEUploadStep.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' -import { Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react' +import { Upload, FileText, AlertCircle, CheckCircle, Loader2, XCircle } from 'lucide-react' const LOADING_PHASES = [ { message: 'Läser fil...', progress: 10 }, @@ -17,9 +17,11 @@ interface SIEUploadStepProps { isLoading: boolean error: string | null errorType?: 'duplicate' | 'duplicate_period' | 'validation' | 'parse' + validationErrors?: string[] + validationWarnings?: string[] } -export default function SIEUploadStep({ onFileSelect, isLoading, error, errorType }: SIEUploadStepProps) { +export default function SIEUploadStep({ onFileSelect, isLoading, error, errorType, validationErrors, validationWarnings }: SIEUploadStepProps) { const [isDragging, setIsDragging] = useState(false) const [selectedFile, setSelectedFile] = useState(null) const [loadingPhase, setLoadingPhase] = useState(0) @@ -159,16 +161,78 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp {/* Error display */} {error && ( -
- -
-

- {errorType === 'duplicate' || errorType === 'duplicate_period' - ? 'Filen har redan importerats' - : 'Kunde inte läsa filen'} -

-

{error}

+
+
+ +
+

+ {errorType === 'duplicate' && 'Filen har redan importerats'} + {errorType === 'duplicate_period' && 'Överlappande räkenskapsår'} + {errorType === 'validation' && 'Filen innehåller valideringsfel'} + {errorType === 'parse' && 'Kunde inte tolka filen'} + {!errorType && 'Ett fel uppstod'} +

+

{error}

+ + {/* Actionable guidance */} +
+ {errorType === 'duplicate' && ( +

Om du vill importera om filen, ta först bort den tidigare importen under Bokföring.

+ )} + {errorType === 'duplicate_period' && ( +

Varje räkenskapsår kan bara importeras en gång. Ta bort den befintliga importen först om du vill ersätta den.

+ )} + {errorType === 'validation' && ( +

Prova att exportera filen igen från ditt bokföringsprogram. Om felet kvarstår, kontrollera att alla verifikationer är korrekt bokförda i källsystemet.

+ )} + {errorType === 'parse' && ( +

Kontrollera att filen är en SIE4-fil exporterad från ett bokföringsprogram (Fortnox, Visma, Bokio etc). Filen kan vara skadad om den redigerats manuellt.

+ )} +
+
+ + {/* Validation errors list */} + {validationErrors && validationErrors.length > 0 && ( +
+

Fel som blockerar import ({validationErrors.length})

+
+ {validationErrors.map((err, i) => ( +
+ + {err} +
+ ))} +
+
+ )} + + {/* Validation warnings list */} + {validationWarnings && validationWarnings.length > 0 && ( +
+

Varningar ({validationWarnings.length})

+
+ {validationWarnings.map((warn, i) => ( +
+ + {warn} +
+ ))} +
+
+ )}
)} diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index bbb92337..e6d34718 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -8,6 +8,7 @@ import { getAuthUrl, exchangeAuthToken, submitProviderToken, + acceptConsent, deleteConsent, resolveConsent, fetchCompanyInfoDirect, @@ -148,28 +149,53 @@ export const arcimMigrationExtension: Extension = { } try { + const { createServiceClient: createSvc } = await import('@/lib/supabase/server') + // Reuse existing accepted consent if one exists for this provider const existingConsents = await listConsents(companyId) - const existing = existingConsents.find(c => c.provider === provider && c.status === 1) + const accepted = existingConsents.find(c => c.provider === provider && c.status === 1) - if (existing) { + if (accepted) { // Already connected — skip OAuth, go straight to preview if (ctx?.settings) { - await ctx.settings.set('consent_id', existing.id) + await ctx.settings.set('consent_id', accepted.id) await ctx.settings.set('provider', provider) } return NextResponse.json({ - consentId: existing.id, + consentId: accepted.id, authType: providerInfo.authType, alreadyConnected: true, }) } - // Clean up abandoned consents (status 0 = Created but never completed OAuth) - const abandoned = existingConsents.filter(c => c.provider === provider && c.status === 0) - for (const a of abandoned) { - await deleteConsent(a.id) + // Check for status 0 consents that already have tokens stored (credentials submitted but migration not completed) + const pending = existingConsents.filter(c => c.provider === provider && c.status === 0) + if (pending.length > 0) { + const svc = createSvc() + for (const p of pending) { + const { data: tokens } = await svc + .from('provider_consent_tokens') + .select('id') + .eq('consent_id', p.id) + .limit(1) + if (tokens && tokens.length > 0) { + // Tokens exist — reuse this consent, skip credential entry + if (ctx?.settings) { + await ctx.settings.set('consent_id', p.id) + await ctx.settings.set('provider', provider) + } + return NextResponse.json({ + consentId: p.id, + authType: providerInfo.authType, + alreadyConnected: true, + }) + } + } + // No tokens found — clean up abandoned consents + for (const p of pending) { + await deleteConsent(p.id) + } } // Create new consent @@ -385,9 +411,9 @@ export const arcimMigrationExtension: Extension = { try { const consent = await getConsent(consentId) - if (consent.status !== 1) { + if (consent.status !== 0 && consent.status !== 1) { return NextResponse.json( - { error: 'Consent is not accepted. Complete OAuth first.' }, + { error: 'Consent is not ready. Complete authentication first.' }, { status: 400 } ) } @@ -859,9 +885,9 @@ export const arcimMigrationExtension: Extension = { try { const consent = await getConsent(consentId) - if (consent.status !== 1) { + if (consent.status !== 0 && consent.status !== 1) { return NextResponse.json( - { error: 'Consent is not accepted' }, + { error: 'Consent is not ready' }, { status: 400 } ) } @@ -882,6 +908,9 @@ export const arcimMigrationExtension: Extension = { log.info('Migration completed:', results) + // Mark consent as fully accepted now that data has been imported + await acceptConsent(consentId) + return NextResponse.json({ success: true, results }) } catch (error) { log.error('Migration failed:', error) @@ -893,6 +922,48 @@ export const arcimMigrationExtension: Extension = { }, }, + // ── Accept consent (mark as fully connected after import) ───── + { + method: 'POST', + path: '/accept', + handler: async (request: Request, ctx?: ExtensionContext) => { + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = ctx?.companyId ?? user.id + const { consentId } = await request.json() as { consentId: string } + if (!consentId) { + return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) + } + + // Verify consent belongs to this company before mutating + const { data: consent } = await supabase + .from('provider_consents') + .select('id') + .eq('id', consentId) + .eq('company_id', companyId) + .single() + + if (!consent) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + try { + await acceptConsent(consentId) + return NextResponse.json({ success: true }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to accept consent' }, + { status: 500 } + ) + } + }, + }, + // ── Disconnect / revoke consent ─────────────────────────────── { method: 'DELETE', @@ -906,12 +977,25 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = ctx?.companyId ?? user.id const { consentId } = await request.json() as { consentId: string } if (!consentId) { return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) } + // Verify consent belongs to this company before mutating + const { data: consent } = await supabase + .from('provider_consents') + .select('id') + .eq('id', consentId) + .eq('company_id', companyId) + .single() + + if (!consent) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + try { await deleteConsent(consentId) diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 81537414..df3489a7 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -61,6 +61,8 @@ export async function executeMigration(options: MigrationOptions): Promise { + const supabase = createServiceClient() await supabase .from('provider_consents') .update({ status: 1 }) .eq('id', consentId) - - return { success: true, consentId } } diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 7639daf9..31b79aba 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -210,7 +210,7 @@ describe('generateImportPreview', () => { it('passes parse issues to preview', () => { const parsed = makeParsedFile({ issues: [ - { severity: 'warning', line: 5, message: 'Unknown tag: #FOO', tag: 'FOO' }, + { severity: 'warning', line: 5, message: 'Okänd tagg: #FOO — ignoreras', tag: 'FOO' }, { severity: 'error', line: 10, message: 'Invalid voucher', tag: 'VER' }, ], }) diff --git a/lib/import/__tests__/sie-parser.test.ts b/lib/import/__tests__/sie-parser.test.ts index f0c62585..2ba43e4a 100644 --- a/lib/import/__tests__/sie-parser.test.ts +++ b/lib/import/__tests__/sie-parser.test.ts @@ -328,7 +328,7 @@ describe('parseSIEFile', () => { const errors = result.issues.filter((i) => i.severity === 'error') expect(errors.length).toBeGreaterThanOrEqual(1) - expect(errors.some((e) => e.message.includes('not balanced'))).toBe(true) + expect(errors.some((e) => e.message.includes('balanserar inte'))).toBe(true) }) }) @@ -380,7 +380,7 @@ describe('validateSIEFile', () => { const validation = validateSIEFile(parsed) expect(validation.valid).toBe(false) - expect(validation.errors.some((e) => e.includes('not balanced'))).toBe(true) + expect(validation.errors.some((e) => e.includes('balanserar inte'))).toBe(true) }) it('no longer warns about accounts referenced in #IB since parser auto-adds them', () => { @@ -424,7 +424,7 @@ describe('validateSIEFile', () => { const parsed = parseSIEFile(content) const validation = validateSIEFile(parsed) - expect(validation.warnings.some((w) => w.includes('Opening balances not balanced'))).toBe(true) + expect(validation.warnings.some((w) => w.includes('Ingående balanser balanserar inte'))).toBe(true) }) it('passes with balanced opening balances', () => { @@ -432,7 +432,7 @@ describe('validateSIEFile', () => { const validation = validateSIEFile(parsed) // IB: 50000 + 100000 + (-150000) = 0 → balanced - const ibWarning = validation.warnings.find((w) => w.includes('Opening balances not balanced')) + const ibWarning = validation.warnings.find((w) => w.includes('Ingående balanser balanserar inte')) expect(ibWarning).toBeUndefined() }) }) @@ -592,7 +592,7 @@ describe('parseSIEFile — invalid date handling', () => { const result = parseSIEFile(content) // Voucher should not be created because date is invalid expect(result.vouchers).toHaveLength(0) - expect(result.issues.some((i) => i.severity === 'error' && i.message.includes('Invalid voucher definition'))).toBe(true) + expect(result.issues.some((i) => i.severity === 'error' && i.message.includes('Ogiltig verifikationsdefinition'))).toBe(true) }) it('accepts valid leap year date Feb 29', () => { @@ -630,7 +630,7 @@ describe('parseSIEFile — invalid date handling', () => { const result = parseSIEFile(content) expect(result.vouchers).toHaveLength(0) - expect(result.issues.some((i) => i.message.includes('Invalid voucher definition'))).toBe(true) + expect(result.issues.some((i) => i.message.includes('Ogiltig verifikationsdefinition'))).toBe(true) }) }) @@ -649,7 +649,7 @@ describe('parseSIEFile — missing amount handling', () => { const result = parseSIEFile(content) expect(result.openingBalances).toHaveLength(0) - expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #IB'))).toBe(true) + expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #IB'))).toBe(true) }) it('skips #UB with missing amount and adds warning', () => { @@ -664,7 +664,7 @@ describe('parseSIEFile — missing amount handling', () => { const result = parseSIEFile(content) expect(result.closingBalances).toHaveLength(0) - expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #UB'))).toBe(true) + expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #UB'))).toBe(true) }) it('skips #RES with missing amount and adds warning', () => { @@ -679,7 +679,7 @@ describe('parseSIEFile — missing amount handling', () => { const result = parseSIEFile(content) expect(result.resultBalances).toHaveLength(0) - expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #RES'))).toBe(true) + expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #RES'))).toBe(true) }) it('skips #TRANS with missing amount and adds warning', () => { @@ -698,7 +698,7 @@ describe('parseSIEFile — missing amount handling', () => { const result = parseSIEFile(content) expect(result.vouchers).toHaveLength(1) expect(result.vouchers[0].lines).toHaveLength(0) - expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #TRANS'))).toBe(true) + expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #TRANS'))).toBe(true) }) it('still parses valid #IB lines alongside missing-amount ones', () => { diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 8d4f1166..6bed34bc 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -1160,7 +1160,7 @@ export async function saveMappings( await supabase .from('sie_account_mappings') .upsert(batch, { - onConflict: 'user_id,source_account', + onConflict: 'company_id,source_account', }) } } diff --git a/lib/import/sie-parser.ts b/lib/import/sie-parser.ts index 5f4e6c03..73f701b0 100644 --- a/lib/import/sie-parser.ts +++ b/lib/import/sie-parser.ts @@ -388,7 +388,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { issues, 'error', lineNum, - `Voucher ${currentVoucher.series}${currentVoucher.number} is not balanced (diff: ${total.toFixed(2)})`, + `Verifikation ${currentVoucher.series}${currentVoucher.number} balanserar inte (differens: ${total.toFixed(2)} kr)`, 'VER' ) } @@ -425,7 +425,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { case 'SIETYP': header.sieType = parseInt(fields[1], 10) as SIEType if (![1, 2, 3, 4].includes(header.sieType)) { - addIssue(issues, 'warning', lineNum, `Unknown SIE type: ${fields[1]}`, tag) + addIssue(issues, 'warning', lineNum, `Okänd SIE-typ: ${fields[1]}. Filen tolkas som SIE4.`, tag) header.sieType = 4 } break @@ -520,7 +520,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { const amountStr = fields[3] if (!amountStr || amountStr.trim() === '') { - addIssue(issues, 'warning', lineNum, 'Missing amount in #IB, skipping line', tag) + addIssue(issues, 'warning', lineNum, 'Belopp saknas i #IB — raden hoppas över', tag) break } @@ -540,7 +540,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { const amountStr = fields[3] if (!amountStr || amountStr.trim() === '') { - addIssue(issues, 'warning', lineNum, 'Missing amount in #UB, skipping line', tag) + addIssue(issues, 'warning', lineNum, 'Belopp saknas i #UB — raden hoppas över', tag) break } @@ -560,7 +560,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { const amountStr = fields[3] if (!amountStr || amountStr.trim() === '') { - addIssue(issues, 'warning', lineNum, 'Missing amount in #RES, skipping line', tag) + addIssue(issues, 'warning', lineNum, 'Belopp saknas i #RES — raden hoppas över', tag) break } @@ -598,7 +598,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { currentVoucher.signature = parseStringField(fields[6]) } } else { - addIssue(issues, 'error', lineNum, 'Invalid voucher definition', tag) + addIssue(issues, 'error', lineNum, 'Ogiltig verifikationsdefinition — nummer eller datum kunde inte tolkas', tag) } break } @@ -615,7 +615,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { // supplementary history. We skip RTRANS/BTRANS to avoid double-counting // which would make balanced vouchers appear unbalanced. if (!currentVoucher) { - addIssue(issues, 'error', lineNum, `${tag} outside of VER block`, tag) + addIssue(issues, 'error', lineNum, `#${tag} utanför verifikationsblock (#VER) — filen kan vara skadad`, tag) break } @@ -635,7 +635,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { const transAmountStr = fields[fieldIndex] if (!transAmountStr || transAmountStr.trim() === '') { - addIssue(issues, 'warning', lineNum, `Missing amount in #${tag}, skipping line`, tag) + addIssue(issues, 'warning', lineNum, `Belopp saknas i #${tag} — raden hoppas över`, tag) break } @@ -667,7 +667,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { default: // Unknown tag - add info issue for notable ones if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) { - addIssue(issues, 'info', lineNum, `Unknown tag: #${tag}`, tag) + addIssue(issues, 'info', lineNum, `Okänd tagg: #${tag} — ignoreras`, tag) } } } catch (error) { @@ -675,7 +675,7 @@ export function parseSIEFile(content: string): ParsedSIEFile { issues, 'error', lineNum, - `Error parsing ${tag}: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Fel vid tolkning av #${tag}: ${error instanceof Error ? error.message : 'Okänt fel'}`, tag ) } @@ -734,22 +734,22 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult { // Check for SIE type if (!parsed.header.sieType) { - errors.push('Missing SIE type (#SIETYP)') + errors.push('SIE-typ saknas (#SIETYP). Filen kanske inte är en giltig SIE-fil — kontrollera att du exporterat i rätt format.') } // Check for company info if (!parsed.header.companyName) { - warnings.push('No company name found (#FNAMN)') + warnings.push('Företagsnamn saknas (#FNAMN) — vanligtvis ofarligt men bör kontrolleras') } // Check for fiscal year if (parsed.header.fiscalYears.length === 0) { - errors.push('No fiscal year defined (#RAR)') + errors.push('Inget räkenskapsår definierat (#RAR). Filen saknar information om vilken period bokföringen gäller — kontrollera att exporten inkluderar räkenskapsårsdata.') } // Check for accounts if (parsed.accounts.length === 0) { - warnings.push('No accounts found (#KONTO)') + warnings.push('Inga konton hittades (#KONTO). Om filen bara innehåller saldon (SIE1) är detta normalt.') } // Warn if non-BAS kontoplan declared — mapping logic assumes BAS number ranges @@ -758,20 +758,28 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult { const isBAS = planType.startsWith('BAS') || planType === 'EUBAS' || planType === 'EU-BAS' if (!isBAS) { warnings.push( - `Kontoplanstyp "${parsed.header.kontoPlanType}" är inte BAS-baserad. Alla kontomappningar bör granskas manuellt.` + `Kontoplanstyp "${parsed.header.kontoPlanType}" är inte BAS-baserad. Automatisk kontomappning kan bli felaktig — granska alla mappningar manuellt i nästa steg.` ) } } // Check for unbalanced vouchers + const unbalancedVouchers: string[] = [] for (const voucher of parsed.vouchers) { const total = voucher.lines.reduce((sum, l) => sum + l.amount, 0) if (Math.abs(total) > 0.01) { - errors.push( - `Voucher ${voucher.series}${voucher.number} on ${voucher.date.toISOString().split('T')[0]} is not balanced (diff: ${total.toFixed(2)})` + unbalancedVouchers.push( + `${voucher.series}${voucher.number} (${voucher.date.toISOString().split('T')[0]}, diff: ${total.toFixed(2)} kr)` ) } } + if (unbalancedVouchers.length > 0) { + const shown = unbalancedVouchers.slice(0, 5) + const remaining = unbalancedVouchers.length - shown.length + errors.push( + `${unbalancedVouchers.length} verifikation(er) balanserar inte (debet ≠ kredit): ${shown.join(', ')}${remaining > 0 ? ` och ${remaining} till` : ''}. Kontrollera att exporten från källsystemet är komplett.` + ) + } // Check for accounts referenced but not defined const definedAccounts = new Set(parsed.accounts.map((a) => a.number)) @@ -787,11 +795,19 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult { } } + const undefinedAccounts: string[] = [] for (const account of referencedAccounts) { if (!definedAccounts.has(account)) { - warnings.push(`Account ${account} referenced but not defined in #KONTO`) + undefinedAccounts.push(account) } } + if (undefinedAccounts.length > 0) { + const shown = undefinedAccounts.slice(0, 10) + const remaining = undefinedAccounts.length - shown.length + warnings.push( + `${undefinedAccounts.length} konto(n) används i verifikationer men definieras inte i #KONTO: ${shown.join(', ')}${remaining > 0 ? ` och ${remaining} till` : ''}. Kontona skapas automatiskt vid import.` + ) + } // Check opening balance is balanced (for balance sheet accounts) const ibTotal = parsed.openingBalances @@ -799,7 +815,7 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult { .reduce((sum, b) => sum + b.amount, 0) if (Math.abs(ibTotal) > 0.01) { - warnings.push(`Opening balances not balanced (diff: ${ibTotal.toFixed(2)})`) + warnings.push(`Ingående balanser balanserar inte (differens: ${ibTotal.toFixed(2)} kr). En automatisk justeringspost mot konto 2099 skapas vid import.`) } // Add parse issues as errors/warnings diff --git a/lib/providers/bokio/client.ts b/lib/providers/bokio/client.ts index b58fdb6a..5dd53a32 100644 --- a/lib/providers/bokio/client.ts +++ b/lib/providers/bokio/client.ts @@ -1,6 +1,9 @@ import { TokenBucketRateLimiter } from '../rate-limiter'; import { withRetry } from '../retry'; import { BOKIO_BASE_URL, BOKIO_RATE_LIMIT } from './config'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('bokio-client'); export class BokioApiError extends Error { constructor( @@ -24,7 +27,8 @@ function isRetryableError(error: unknown): boolean { } interface BokioPaginatedResponse { - items: T[]; + items?: T[]; + data?: T[]; // Some Bokio endpoints use 'data' instead of 'items' totalItems: number; totalPages: number; currentPage: number; @@ -72,7 +76,8 @@ export class BokioClient { /** * Fetch a paginated list endpoint. - * Bokio returns `{ data: [...], pagination: { page, pageSize, totalPages, totalCount } }`. + * Bokio returns `{ items: [...], totalItems, totalPages, currentPage }`. + * Some endpoints may use `data` instead of `items`. */ async getPage( accessToken: string, @@ -94,12 +99,41 @@ export class BokioClient { const path = `/companies/${companyId}${relativePath}?${params.toString()}`; const response = await this.get>(accessToken, path); - return { - items: Array.isArray(response.items) ? response.items : [], + // Bokio uses 'items' for most endpoints but 'data' for some (e.g., credit notes) + const items = Array.isArray(response.items) + ? response.items + : Array.isArray(response.data) + ? response.data + : []; + + const result = { + items, page: response.currentPage ?? (options?.page ?? 1), totalPages: response.totalPages ?? 1, totalCount: response.totalItems ?? 0, }; + + log.info( + `getPage ${relativePath} page=${result.page}/${result.totalPages}: ` + + `${result.items.length} items (totalCount=${result.totalCount})` + + (result.items.length === 0 && result.totalCount > 0 + ? ` — WARNING: 0 items despite totalCount=${result.totalCount}, raw keys: ${Object.keys(response).join(', ')}` + : ''), + ); + + // Extra diagnostic: if no items found and response has unexpected keys, log them + if (result.items.length === 0) { + const rawObj = response as unknown as Record; + const keys = Object.keys(rawObj).filter(k => !['totalItems', 'totalPages', 'currentPage', 'items', 'data'].includes(k)); + if (keys.length > 0) { + log.warn( + `Unexpected response keys for ${relativePath}: ${keys.join(', ')}. ` + + `Values: ${keys.map(k => `${k}=${typeof rawObj[k] === 'object' ? JSON.stringify(rawObj[k]).slice(0, 200) : rawObj[k]}`).join(', ')}`, + ); + } + } + + return result; } /** diff --git a/lib/providers/bokio/mapper.ts b/lib/providers/bokio/mapper.ts index 9e019695..55493bc1 100644 --- a/lib/providers/bokio/mapper.ts +++ b/lib/providers/bokio/mapper.ts @@ -133,11 +133,13 @@ export function mapBokioToCustomer(raw: Record): CustomerDto { return { id: String(raw['id'] ?? ''), customerNumber: String(raw['id'] ?? ''), - type: raw['type'] === 'individual' ? 'private' : 'company', + type: (raw['type'] === 'individual' || raw['type'] === 'person') ? 'private' : 'company', party, active: true, vatNumber: raw['vatNumber'] as string | undefined, - defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined, + defaultPaymentTermsDays: raw['paymentTerms'] != null && !isNaN(Number(raw['paymentTerms'])) + ? Number(raw['paymentTerms']) + : undefined, _raw: raw, }; } @@ -231,7 +233,9 @@ export function mapBokioToSupplier(raw: Record): SupplierDto { bankAccount: raw['bankAccount'] as string | undefined, bankGiro: raw['bankgiro'] as string | undefined, plusGiro: raw['plusgiro'] as string | undefined, - defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined, + defaultPaymentTermsDays: raw['paymentTerms'] != null && !isNaN(Number(raw['paymentTerms'])) + ? Number(raw['paymentTerms']) + : undefined, _raw: raw, }; } diff --git a/lib/providers/provider-data-fetcher.ts b/lib/providers/provider-data-fetcher.ts index 301bb8b6..aa01179d 100644 --- a/lib/providers/provider-data-fetcher.ts +++ b/lib/providers/provider-data-fetcher.ts @@ -13,7 +13,7 @@ import { VismaClient } from './visma/client'; import { VISMA_RESOURCE_CONFIGS } from './visma/config'; import { BrioxClient } from './briox/client'; import { BRIOX_RESOURCE_CONFIGS } from './briox/config'; -import { BokioClient } from './bokio/client'; +import { BokioClient, BokioApiError } from './bokio/client'; import { BOKIO_RESOURCE_CONFIGS } from './bokio/config'; import { BjornLundenClient } from './bjornlunden/client'; import { BL_RESOURCE_CONFIGS } from './bjornlunden/config'; @@ -44,6 +44,7 @@ async function bokioPaginate( page++; } while (page <= totalPages); + console.log(`[bokio-paginate] ${path}: fetched ${allItems.length} total items across ${totalPages} page(s)`); return allItems; } @@ -143,8 +144,14 @@ export async function fetchCustomersDirect( if (provider === 'bokio') { const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Customers]; - if (!config || !providerCompanyId) return []; + if (!config || !providerCompanyId) { + console.warn(`[provider-data-fetcher] Bokio customers: skipped — config=${!!config}, providerCompanyId=${providerCompanyId ?? 'undefined'}`); + return []; + } const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + if (items.length > 0) { + console.log(`[provider-data-fetcher] Bokio customers: first item keys: ${Object.keys(items[0]).join(', ')}`); + } return items.map((item) => config.mapper(item) as CustomerDto); } @@ -186,8 +193,16 @@ export async function fetchSuppliersDirect( if (provider === 'bokio') { const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Suppliers]; if (!config || !providerCompanyId) return []; - const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); - return items.map((item) => config.mapper(item) as SupplierDto); + try { + const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierDto); + } catch (err) { + if (err instanceof BokioApiError && err.statusCode === 404) { + console.log('[provider-data-fetcher] Bokio suppliers endpoint not available (404) — skipping'); + return []; + } + throw err; + } } if (provider === 'bjornlunden') { @@ -227,8 +242,14 @@ export async function fetchSalesInvoicesDirect( if (provider === 'bokio') { const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SalesInvoices]; - if (!config || !providerCompanyId) return []; + if (!config || !providerCompanyId) { + console.warn(`[provider-data-fetcher] Bokio invoices: skipped — config=${!!config}, providerCompanyId=${providerCompanyId ?? 'undefined'}`); + return []; + } const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + if (items.length > 0) { + console.log(`[provider-data-fetcher] Bokio invoices: first item keys: ${Object.keys(items[0]).join(', ')}`); + } return items.map((item) => config.mapper(item) as SalesInvoiceDto); } @@ -270,8 +291,16 @@ export async function fetchSupplierInvoicesDirect( if (provider === 'bokio') { const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]; if (!config || !providerCompanyId) return []; - const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); - return items.map((item) => config.mapper(item) as SupplierInvoiceDto); + try { + const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierInvoiceDto); + } catch (err) { + if (err instanceof BokioApiError && err.statusCode === 404) { + console.log('[provider-data-fetcher] Bokio supplier-invoices endpoint not available (404) — skipping'); + return []; + } + throw err; + } } if (provider === 'bjornlunden') { diff --git a/lib/providers/resolve-consent.ts b/lib/providers/resolve-consent.ts index 4c68c85c..f5ddb670 100644 --- a/lib/providers/resolve-consent.ts +++ b/lib/providers/resolve-consent.ts @@ -28,8 +28,9 @@ export async function resolveConsent(companyId: string, consentId: string): Prom } const consent = consentRows[0]!; - if (consent.status !== 1) { - throw { status: 403, message: 'Consent is not in Accepted status' }; + // Accept status 0 (token submitted, migration pending) and 1 (fully accepted) + if (consent.status !== 0 && consent.status !== 1) { + throw { status: 403, message: 'Consent is not in a valid status' }; } if (!consent.provider) { diff --git a/supabase/migrations/20260408130000_sie_files_storage_bucket.sql b/supabase/migrations/20260408130000_sie_files_storage_bucket.sql new file mode 100644 index 00000000..34efc296 --- /dev/null +++ b/supabase/migrations/20260408130000_sie_files_storage_bucket.sql @@ -0,0 +1,50 @@ +-- Migration: Create 'sie-files' storage bucket for SIE file archival +-- The SIE import flow archives imported files to Supabase Storage for +-- BFL 7 kap 1-2§ retention compliance, but the bucket was never created. +-- Path convention: {company_id}/{import_id}.se + +-- ============================================================================= +-- 1. Create the 'sie-files' bucket (private, 10MB limit, text only) +-- ============================================================================= + +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'sie-files', + 'sie-files', + false, + 52428800, -- 50 MB, matches MAX_FILE_SIZE in the parse route + ARRAY['text/plain'] +) +ON CONFLICT (id) DO NOTHING; +ON CONFLICT (id) DO NOTHING; + +-- ============================================================================= +-- 2. INSERT policy: Users can upload to companies they belong to +-- ============================================================================= + +CREATE POLICY "sie_files_insert" + ON storage.objects + FOR INSERT + TO authenticated + WITH CHECK ( + bucket_id = 'sie-files' + AND (storage.foldername(name))[1]::uuid IN (SELECT public.user_company_ids()) + ); + +-- ============================================================================= +-- 3. SELECT policy: Users can read files from companies they belong to +-- ============================================================================= + +CREATE POLICY "sie_files_select" + ON storage.objects + FOR SELECT + TO authenticated + USING ( + bucket_id = 'sie-files' + AND (storage.foldername(name))[1]::uuid IN (SELECT public.user_company_ids()) + ); + +-- ============================================================================= +-- No UPDATE or DELETE policies — WORM compliance for BFL retention +-- Service role bypasses RLS for admin/cron access +-- =============================================================================