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>
This commit is contained in:
co-authored by
greptile-apps[bot]
parent
6c79f21679
commit
211033410c
@@ -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<string | null>(null)
|
||||
const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | undefined>()
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([])
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [, setParsed] = useState<ParsedSIEFile | null>(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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} errorType={errorType} />}
|
||||
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} errorType={errorType} validationErrors={validationErrors} validationWarnings={validationWarnings} />}
|
||||
{step === 'preview' && preview && (
|
||||
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
|
||||
@@ -794,19 +836,19 @@ export default function ImportPage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auto-detect OAuth callback or deep-link mode from query params (disabled in sandbox)
|
||||
// Sync mode from URL search params (reacts to client-side navigation changes)
|
||||
const searchParams = useSearchParams()
|
||||
useEffect(() => {
|
||||
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')
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</label>
|
||||
<Input
|
||||
id="apiToken"
|
||||
name="apiToken_nocomplete"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
autoComplete="new-password"
|
||||
placeholder={provider === 'briox' ? 'Klistra in din applikationstoken' : 'Klistra in din API-nyckel'}
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
@@ -498,7 +499,8 @@ function ConnectStep({
|
||||
</label>
|
||||
<Input
|
||||
id="companyId"
|
||||
autoComplete="off"
|
||||
name="companyId_nocomplete"
|
||||
autoComplete="new-password"
|
||||
placeholder={isClientCredentials ? 'GUID från företagsinställningar' : 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'}
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
@@ -614,13 +616,14 @@ function PreviewStep({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-3">
|
||||
<Link href="/import?mode=sie">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Gå till SIE-importen
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href="/import?mode=sie"
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'mt-3')}
|
||||
>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Gå till SIE-importen
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -49,8 +49,10 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{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.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -109,7 +111,7 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
Fel ({result.errors.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{result.errors.map((error, i) => (
|
||||
<div key={i} className="text-sm flex gap-2">
|
||||
@@ -118,6 +120,16 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!result.success && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-3 space-y-1">
|
||||
<p className="font-medium">Vad kan du göra?</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 text-muted-foreground">
|
||||
<li>Kontrollera att SIE-filen exporterades korrekt från källsystemet</li>
|
||||
<li>Prova att exportera filen igen och ladda upp på nytt</li>
|
||||
<li>Om felet kvarstår, kontakta support med felmeddelandet ovan</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -282,31 +282,55 @@ export default function SIEPreviewStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{(errors.length > 0 || warnings.length > 0) && (
|
||||
<Card className={errors.length > 0 ? 'border-destructive/50' : 'border-warning/50'}>
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{errors.length > 0 ? (
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
)}
|
||||
{errors.length > 0 ? 'Fel' : 'Varningar'}
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
Tolkningsfel ({errors.length})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa fel hittades under tolkningen av SIE-filen och kan påverka importresultatet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{errors.map((issue, i) => (
|
||||
<div key={`error-${i}`} className="text-sm flex gap-2 text-destructive">
|
||||
<XCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<span>Rad {issue.line}: {issue.message}</span>
|
||||
<span>
|
||||
<span className="font-mono text-xs opacity-70">Rad {issue.line}</span>{' '}
|
||||
{issue.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<Card className="border-warning/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
Varningar ({warnings.length})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa varningar blockerar inte importen men bör granskas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{warnings.map((issue, i) => (
|
||||
<div key={`warning-${i}`} className="text-sm flex gap-2 text-warning">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<span>Rad {issue.line}: {issue.message}</span>
|
||||
<span>
|
||||
<span className="font-mono text-xs opacity-70">Rad {issue.line}</span>{' '}
|
||||
{issue.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<File | null>(null)
|
||||
const [loadingPhase, setLoadingPhase] = useState(0)
|
||||
@@ -159,16 +161,78 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-destructive">
|
||||
{errorType === 'duplicate' || errorType === 'duplicate_period'
|
||||
? 'Filen har redan importerats'
|
||||
: 'Kunde inte läsa filen'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={`p-4 rounded-lg flex gap-3 ${
|
||||
errorType === 'duplicate' || errorType === 'duplicate_period'
|
||||
? 'bg-warning/10 border border-warning/20'
|
||||
: 'bg-destructive/10 border border-destructive/20'
|
||||
}`}>
|
||||
<AlertCircle className={`h-5 w-5 flex-shrink-0 mt-0.5 ${
|
||||
errorType === 'duplicate' || errorType === 'duplicate_period'
|
||||
? 'text-warning'
|
||||
: 'text-destructive'
|
||||
}`} />
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<p className={`font-medium ${
|
||||
errorType === 'duplicate' || errorType === 'duplicate_period'
|
||||
? 'text-warning'
|
||||
: 'text-destructive'
|
||||
}`}>
|
||||
{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'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
|
||||
{/* Actionable guidance */}
|
||||
<div className="text-sm text-muted-foreground pt-1 border-t border-border/50 mt-2">
|
||||
{errorType === 'duplicate' && (
|
||||
<p>Om du vill importera om filen, ta först bort den tidigare importen under Bokföring.</p>
|
||||
)}
|
||||
{errorType === 'duplicate_period' && (
|
||||
<p>Varje räkenskapsår kan bara importeras en gång. Ta bort den befintliga importen först om du vill ersätta den.</p>
|
||||
)}
|
||||
{errorType === 'validation' && (
|
||||
<p>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.</p>
|
||||
)}
|
||||
{errorType === 'parse' && (
|
||||
<p>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.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation errors list */}
|
||||
{validationErrors && validationErrors.length > 0 && (
|
||||
<div className="p-4 bg-destructive/5 border border-destructive/15 rounded-lg space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">Fel som blockerar import ({validationErrors.length})</p>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{validationErrors.map((err, i) => (
|
||||
<div key={i} className="text-sm flex gap-2">
|
||||
<XCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{err}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation warnings list */}
|
||||
{validationWarnings && validationWarnings.length > 0 && (
|
||||
<div className="p-4 bg-warning/5 border border-warning/15 rounded-lg space-y-2">
|
||||
<p className="text-sm font-medium text-warning">Varningar ({validationWarnings.length})</p>
|
||||
<div className="space-y-1.5 max-h-32 overflow-y-auto">
|
||||
{validationWarnings.map((warn, i) => (
|
||||
<div key={i} className="text-sm flex gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{warn}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const accessToken = resolved.accessToken
|
||||
const providerCompanyId = resolved.providerCompanyId
|
||||
|
||||
|
||||
|
||||
try {
|
||||
// ── Step 1: Company information ───────────────────────────────
|
||||
if (options.importCompanyInfo !== false) {
|
||||
|
||||
@@ -240,7 +240,7 @@ export async function submitProviderToken(
|
||||
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
// Store tokens — consent stays at status 0 until migration/SIE import completes
|
||||
await supabase
|
||||
.from('provider_consent_tokens')
|
||||
.upsert({
|
||||
@@ -252,11 +252,14 @@ export async function submitProviderToken(
|
||||
provider_company_id: companyId,
|
||||
})
|
||||
|
||||
// Mark consent as accepted
|
||||
return { success: true, consentId }
|
||||
}
|
||||
|
||||
/** Mark a consent as accepted (status 1) — call after migration or SIE import succeeds */
|
||||
export async function acceptConsent(consentId: string): Promise<void> {
|
||||
const supabase = createServiceClient()
|
||||
await supabase
|
||||
.from('provider_consents')
|
||||
.update({ status: 1 })
|
||||
.eq('id', consentId)
|
||||
|
||||
return { success: true, consentId }
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+35
-19
@@ -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
|
||||
|
||||
@@ -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<T> {
|
||||
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<T>(
|
||||
accessToken: string,
|
||||
@@ -94,12 +99,41 @@ export class BokioClient {
|
||||
const path = `/companies/${companyId}${relativePath}?${params.toString()}`;
|
||||
const response = await this.get<BokioPaginatedResponse<T>>(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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,11 +133,13 @@ export function mapBokioToCustomer(raw: Record<string, unknown>): 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<string, unknown>): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<T>(
|
||||
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<Record<string, unknown>>(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<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierDto);
|
||||
try {
|
||||
const items = await bokioPaginate<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
||||
try {
|
||||
const items = await bokioPaginate<Record<string, unknown>>(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') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
-- =============================================================================
|
||||
Reference in New Issue
Block a user