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
+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
}