feat: allow replacing completed SIE imports (#227)

* feat: allow replacing completed SIE imports

Users who import a SIE file, make adjustments in the source system, and
re-export can now replace the old import instead of being permanently
blocked by the "overlapping fiscal year" guard.

The old import's entries are cancelled (posted → cancelled) and the
import is marked as 'replaced'. Nothing is deleted — full audit trail
preserved per BFL 5 kap 5§ (rättelse) and BFNAR 2013:2 kap 8.

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

* fix: address Greptile review — atomic RPC, locked_at check

- P1: Wrap entry cancellation + import status update in a single DB RPC
  (replace_sie_import) to prevent inconsistent state on partial failure
- P2: Check locked_at in addition to is_closed for fiscal period guard
- P2: Use RPC return value for accurate cancelled entry count

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-13 10:48:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 973a2b81a0
commit 258a64a849
6 changed files with 250 additions and 6 deletions
+40 -2
View File
@@ -326,6 +326,8 @@ function SIEImportWizard() {
const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | undefined>()
const [validationErrors, setValidationErrors] = useState<string[]>([])
const [validationWarnings, setValidationWarnings] = useState<string[]>([])
const [duplicatePeriodImportId, setDuplicatePeriodImportId] = useState<string | null>(null)
const [isReplacing, setIsReplacing] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [, setParsed] = useState<ParsedSIEFile | null>(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() {
</CardContent>
</Card>
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} errorType={errorType} validationErrors={validationErrors} validationWarnings={validationWarnings} />}
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} errorType={errorType} validationErrors={validationErrors} validationWarnings={validationWarnings} duplicatePeriodImportId={duplicatePeriodImportId} onReplace={handleReplace} isReplacing={isReplacing} />}
{step === 'preview' && preview && (
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
+42
View File
@@ -0,0 +1,42 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { replaceSIEImport } from '@/lib/import/sie-import'
/**
* POST /api/import/sie/[id]/replace
* Replace a completed SIE import by cancelling its entries, allowing
* the user to re-import corrected data for the same fiscal period.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
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,
})
}
+27 -3
View File
@@ -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<void>
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<File | null>(null)
const [loadingPhase, setLoadingPhase] = useState(0)
@@ -192,7 +196,27 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
<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>
<div className="space-y-2">
<p>Den befintliga importens verifikationer kommer att makuleras (status ändras till &quot;makulerad&quot;). De finns kvar som spårbar historik.</p>
{duplicatePeriodImportId && onReplace && (
<Button
variant="outline"
size="sm"
className="border-warning/50 text-warning hover:bg-warning/10"
disabled={isReplacing}
onClick={(e) => {
e.stopPropagation()
onReplace(duplicatePeriodImportId)
}}
>
{isReplacing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Ersätter...</>
) : (
<><RefreshCw className="h-4 w-4 mr-2" />Ersätt befintlig import</>
)}
</Button>
)}
</div>
)}
{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>
+60
View File
@@ -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.
+2 -1
View File
@@ -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
}
@@ -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;
$$;