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:
co-authored by
Claude Opus 4.6
parent
973a2b81a0
commit
258a64a849
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 "makulerad"). 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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user