diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 588ed7e4..233c93d9 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -326,6 +326,8 @@ function SIEImportWizard() { const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | undefined>() const [validationErrors, setValidationErrors] = useState([]) const [validationWarnings, setValidationWarnings] = useState([]) + const [duplicatePeriodImportId, setDuplicatePeriodImportId] = useState(null) + const [isReplacing, setIsReplacing] = useState(false) const [file, setFile] = useState(null) const [, setParsed] = useState(null) @@ -370,6 +372,9 @@ function SIEImportWizard() { if (type === 'duplicate' || type === 'duplicate_period') { setErrorType(type) setError(data.message) + if (type === 'duplicate_period' && data.importId) { + setDuplicatePeriodImportId(data.importId) + } toast({ title: type === 'duplicate' ? 'Filen har redan importerats' : 'Överlappande räkenskapsår', description: data.message, variant: 'destructive' }) } else if (type === 'validation') { setErrorType('validation') @@ -425,6 +430,39 @@ function SIEImportWizard() { } }, [toast]) + const handleReplace = useCallback(async (importId: string) => { + if (!file) return + + setIsReplacing(true) + try { + const res = await fetch(`/api/import/sie/${importId}/replace`, { method: 'POST' }) + const data = await res.json() + + if (!res.ok) { + toast({ title: 'Kunde inte ersätta import', description: data.error || 'Ett fel uppstod', variant: 'destructive' }) + return + } + + toast({ + title: 'Import ersatt', + description: `${data.cancelledEntries} verifikation${data.cancelledEntries === 1 ? '' : 'er'} makulerades. Importerar ny fil...`, + }) + + // Clear error state and re-trigger the file upload + setError(null) + setErrorType(undefined) + setDuplicatePeriodImportId(null) + + // Small delay so the user sees the success toast before re-upload starts + await new Promise(resolve => setTimeout(resolve, 500)) + handleFileSelect(file) + } catch { + toast({ title: 'Anslutningsfel', description: 'Kunde inte nå servern.', variant: 'destructive' }) + } finally { + setIsReplacing(false) + } + }, [file, handleFileSelect, toast]) + const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => { setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName)) @@ -572,7 +610,7 @@ function SIEImportWizard() { const handleNewImport = () => { setStep('upload'); setFile(null); setParsed(null); setMappings([]) setPreview(null); setIssues([]); setImportResult(null); setError(null); setErrorType(undefined) - setValidationErrors([]); setValidationWarnings([]) + setValidationErrors([]); setValidationWarnings([]); setDuplicatePeriodImportId(null) setSieAccounts([]); setIsCreatingAccounts(false) } @@ -599,7 +637,7 @@ function SIEImportWizard() { - {step === 'upload' && } + {step === 'upload' && } {step === 'preview' && preview && ( } +) { + const supabase = await createClient() + const { id } = await params + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const result = await replaceSIEImport(supabase, companyId, id) + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + + return NextResponse.json({ + success: true, + cancelledEntries: result.cancelledEntries, + }) +} diff --git a/components/import/SIEUploadStep.tsx b/components/import/SIEUploadStep.tsx index f5cbdaf1..9c9998d1 100644 --- a/components/import/SIEUploadStep.tsx +++ b/components/import/SIEUploadStep.tsx @@ -3,7 +3,8 @@ 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, XCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Upload, FileText, AlertCircle, CheckCircle, Loader2, XCircle, RefreshCw } from 'lucide-react' const LOADING_PHASES = [ { message: 'Läser fil...', progress: 10 }, @@ -19,9 +20,12 @@ interface SIEUploadStepProps { errorType?: 'duplicate' | 'duplicate_period' | 'validation' | 'parse' validationErrors?: string[] validationWarnings?: string[] + duplicatePeriodImportId?: string | null + onReplace?: (importId: string) => Promise + isReplacing?: boolean } -export default function SIEUploadStep({ onFileSelect, isLoading, error, errorType, validationErrors, validationWarnings }: SIEUploadStepProps) { +export default function SIEUploadStep({ onFileSelect, isLoading, error, errorType, validationErrors, validationWarnings, duplicatePeriodImportId, onReplace, isReplacing }: SIEUploadStepProps) { const [isDragging, setIsDragging] = useState(false) const [selectedFile, setSelectedFile] = useState(null) const [loadingPhase, setLoadingPhase] = useState(0) @@ -192,7 +196,27 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp

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.

+
+

Den befintliga importens verifikationer kommer att makuleras (status ändras till "makulerad"). De finns kvar som spårbar historik.

+ {duplicatePeriodImportId && onReplace && ( + + )} +
)} {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.

diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 6bed34bc..e8daf213 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -129,6 +129,66 @@ export async function checkDuplicatePeriodImport( return data as SIEImport | null } +/** + * Replace (cancel) a completed SIE import so the user can re-import corrected + * data for the same fiscal period. + * + * Per BFL 5 kap 5§ (rättelse), the original entries are preserved with + * status='cancelled'. The import record is marked as 'replaced' with a + * timestamp for audit trail (BFNAR 2013:2 kap 8 behandlingshistorik). + * Nothing is deleted. + * + * The actual cancellation + status update is atomic via the replace_sie_import + * DB RPC to prevent inconsistent state. + */ +export async function replaceSIEImport( + supabase: SupabaseClient, + companyId: string, + importId: string +): Promise<{ success: boolean; cancelledEntries: number; error?: string }> { + // 1. Fetch and validate the import record + const { data: importRecord } = await supabase + .from('sie_imports') + .select('status, fiscal_period_id') + .eq('id', importId) + .eq('company_id', companyId) + .single() + + if (!importRecord) { + return { success: false, cancelledEntries: 0, error: 'Import hittades inte' } + } + + if (importRecord.status !== 'completed') { + return { success: false, cancelledEntries: 0, error: `Kan bara ersätta slutförda importer (status: ${importRecord.status})` } + } + + // 2. Check that the fiscal period is not closed or locked + if (importRecord.fiscal_period_id) { + const { data: period } = await supabase + .from('fiscal_periods') + .select('is_closed, locked_at') + .eq('id', importRecord.fiscal_period_id) + .eq('company_id', companyId) + .single() + + if (period?.is_closed || period?.locked_at) { + return { success: false, cancelledEntries: 0, error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Öppna perioden först.' } + } + } + + // 3. Atomically cancel entries and mark import as replaced via DB RPC + const { data: cancelledCount, error: rpcError } = await supabase.rpc('replace_sie_import', { + p_company_id: companyId, + p_import_id: importId, + }) + + if (rpcError) { + return { success: false, cancelledEntries: 0, error: `Kunde inte ersätta import: ${rpcError.message}` } + } + + return { success: true, cancelledEntries: cancelledCount as number } +} + /** * Clean up stale pending/failed import records for a given file hash. * Prevents UNIQUE constraint conflicts when re-importing after a failure. diff --git a/lib/import/types.ts b/lib/import/types.ts index 7d9bf5de..2747a3dd 100644 --- a/lib/import/types.ts +++ b/lib/import/types.ts @@ -12,7 +12,7 @@ export type SIEType = 1 | 2 | 3 | 4 export type SIEEncoding = 'cp437' | 'utf8' | 'windows1252' // Import status -export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed' +export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed' | 'replaced' // Match type for account mapping export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual' | 'bas_range' @@ -193,6 +193,7 @@ export interface SIEImport { imported_at: string | null migration_documentation: MigrationDocumentation | null file_storage_path: string | null + replaced_at: string | null created_at: string updated_at: string } diff --git a/supabase/migrations/20260413120000_sie_imports_replaced_status.sql b/supabase/migrations/20260413120000_sie_imports_replaced_status.sql new file mode 100644 index 00000000..88bc21d4 --- /dev/null +++ b/supabase/migrations/20260413120000_sie_imports_replaced_status.sql @@ -0,0 +1,79 @@ +-- Allow completed SIE imports to be marked as 'replaced' when a user wants to +-- re-import corrected data for the same fiscal period. +-- +-- Compliance: replaced imports and their cancelled journal entries remain in the +-- database as audit trail per BFL 5 kap 5§ (rättelse) and BFNAR 2013:2 kap 8 +-- (behandlingshistorik). Nothing is deleted. + +-- 1. Expand status CHECK to include 'replaced' +ALTER TABLE public.sie_imports + DROP CONSTRAINT IF EXISTS sie_imports_status_check; +ALTER TABLE public.sie_imports + ADD CONSTRAINT sie_imports_status_check + CHECK (status IN ('pending', 'mapped', 'completed', 'failed', 'replaced')); + +-- 2. Add audit column for tracking when the import was replaced +ALTER TABLE public.sie_imports + ADD COLUMN IF NOT EXISTS replaced_at timestamptz; + +-- 3. Convert UNIQUE (company_id, file_hash) to a partial unique index that +-- excludes replaced/failed imports. This allows re-importing the same file +-- after a previous import has been replaced. +ALTER TABLE public.sie_imports + DROP CONSTRAINT IF EXISTS sie_imports_company_id_file_hash_key; + +CREATE UNIQUE INDEX IF NOT EXISTS sie_imports_company_id_file_hash_active_idx + ON public.sie_imports (company_id, file_hash) + WHERE status NOT IN ('replaced', 'failed'); + +-- 4. Atomic RPC to cancel entries and mark import as replaced in one transaction. +-- Prevents inconsistent state where entries are cancelled but import stays 'completed'. +CREATE OR REPLACE FUNCTION public.replace_sie_import( + p_company_id uuid, + p_import_id uuid +) RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_cancelled integer; + v_fiscal_period_id uuid; + v_opening_balance_entry_id uuid; +BEGIN + -- Look up the import record (caller must have verified status/permissions) + SELECT fiscal_period_id, opening_balance_entry_id + INTO v_fiscal_period_id, v_opening_balance_entry_id + FROM public.sie_imports + WHERE id = p_import_id AND company_id = p_company_id AND status = 'completed'; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id; + END IF; + + -- Cancel all journal entries belonging to this import + UPDATE public.journal_entries + SET status = 'cancelled' + WHERE company_id = p_company_id + AND status = 'posted' + AND id IN ( + -- Opening balance entry + SELECT v_opening_balance_entry_id WHERE v_opening_balance_entry_id IS NOT NULL + UNION ALL + -- Imported vouchers + migration adjustment + SELECT je.id FROM public.journal_entries je + WHERE je.company_id = p_company_id + AND je.fiscal_period_id = v_fiscal_period_id + AND je.source_type = 'import' + AND je.status = 'posted' + ); + GET DIAGNOSTICS v_cancelled = ROW_COUNT; + + -- Mark import as replaced + UPDATE public.sie_imports + SET status = 'replaced', replaced_at = now() + WHERE id = p_import_id AND company_id = p_company_id; + + RETURN v_cancelled; +END; +$$;