- {fs.alreadyImported ? (
+ {fs.previousImport ? (
<>
-
+
- Räkenskapsår {fs.fiscalYear} — importerad {fs.importedAt ? new Date(fs.importedAt).toLocaleDateString('sv-SE') : ''}
+ Räkenskapsår {fs.fiscalYear} — ersätter tidigare import
+ {fs.previousImport.importedAt
+ ? ` från ${new Date(fs.previousImport.importedAt).toLocaleDateString('sv-SE')}`
+ : ''}
>
) : (
@@ -842,7 +861,7 @@ function OptionsStep({
))}
)}
- {options.importSIEData && !allSieImported && (
+ {options.importSIEData && (
@@ -918,16 +937,38 @@ function OptionsStep({
warningText={`Bokföringsdata, kunder, leverantörer och fakturor importeras till ${branding.appName.toLowerCase()}. Se till att ingen annan import pågår.`}
confirmLabel="Starta migrering"
>
-
-
Följande importeras:
-
- {selectedItems.map((item) => (
- -
-
- {item}
-
- ))}
-
+
+
+
Följande importeras:
+
+ {selectedItems.map((item) => (
+ -
+
+ {item}
+
+ ))}
+
+
+
+ {options.importSIEData && yearsToReplace.length > 0 && (
+
+
+
+
+
+ {yearsToReplace.length === 1
+ ? `Räkenskapsår ${yearsToReplace[0]} ersätts`
+ : `Räkenskapsår ${yearsToReplace.join(', ')} ersätts`}
+
+
+ Tidigare importerade verifikationer markeras som annullerade och ersätts av
+ uppdaterad data från källsystemet. Verifikationer som du själv skapat i {branding.appName.toLowerCase()}
+ (kategoriserade banktransaktioner, fakturor m.m.) påverkas inte.
+
+
+
+
+ )}
@@ -1061,6 +1102,11 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb
{' · '}{d.skippedVouchers.total} hoppade över
)}
+ {result.replacedPriorImport && result.replacedPriorImport.cancelledEntries > 0 && (
+
+ {' · '}ersatte {result.replacedPriorImport.cancelledEntries.toLocaleString('sv-SE')} tidigare importerade verifikationer
+
+ )}
{expanded
@@ -1823,43 +1869,43 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setMigrationProgress(10)
setSieImportResults([])
- // Only import files that haven't been imported yet
- const filesToImport = sieData.rawContent
- .map((content, i) => ({ content, status: sieData.fileStatuses?.[i] }))
- .filter(f => !f.status?.alreadyImported)
+ // Send every file to the engine. The Fortnox endpoint runs in
+ // replace-mode, so a year that already has a completed import
+ // gets its prior import marked 'replaced' (imported entries
+ // cancelled, user-created entries untouched) before the new
+ // SIE is loaded. The per-file result reports replacedPriorImport.
+ const filesToImport = sieData.rawContent.map((content, i) => ({
+ content,
+ status: sieData.fileStatuses?.[i],
+ }))
- if (filesToImport.length === 0) {
- // All files already imported — skip SIE phase
- setSieImportResults([])
- } else {
- for (let i = 0; i < filesToImport.length; i++) {
- const progress = 10 + Math.round((i / filesToImport.length) * 40)
- setMigrationProgress(progress)
- setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${filesToImport.length}...`)
+ for (let i = 0; i < filesToImport.length; i++) {
+ const progress = 10 + Math.round((i / filesToImport.length) * 40)
+ setMigrationProgress(progress)
+ setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${filesToImport.length}...`)
- const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- rawContent: filesToImport[i].content,
- mappings: sieData.mappings,
- options: {
- createFiscalPeriod: true,
- importOpeningBalances: true,
- importTransactions: true,
- voucherSeries: migrationOptions.voucherSeries,
- },
- }),
- })
+ const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ rawContent: filesToImport[i].content,
+ mappings: sieData.mappings,
+ options: {
+ createFiscalPeriod: true,
+ importOpeningBalances: true,
+ importTransactions: true,
+ voucherSeries: migrationOptions.voucherSeries,
+ },
+ }),
+ })
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.error || `SIE import HTTP ${res.status}`)
- }
-
- const result = await res.json() as ImportResult
- setSieImportResults(prev => [...prev, result])
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `SIE import HTTP ${res.status}`)
}
+
+ const result = await res.json() as ImportResult
+ setSieImportResults(prev => [...prev, result])
}
}
diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts
index 231bf8ed..049b4646 100644
--- a/extensions/general/arcim-migration/index.ts
+++ b/extensions/general/arcim-migration/index.ts
@@ -17,7 +17,7 @@ import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
-import { parseSIEFile, validateSIEFile, calculateFileHash } from '@/lib/import/sie-parser'
+import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
import { BAS_REFERENCE, getBASReference } from '@/lib/bookkeeping/bas-reference'
@@ -724,28 +724,57 @@ export const arcimMigrationExtension: Extension = {
const preview = generateImportPreview(parsed, mappings)
- // Check each file's hash against existing imports
- const fileStatuses: { fiscalYear: number; rawContent: string; alreadyImported: boolean; importedAt: string | null }[] = []
+ // Detect prior imports by *fiscal period overlap*, not file hash.
+ // Fortnox embeds the export-time #GEN date in every SIE export so
+ // the hash always changes between syncs; only the period stays
+ // stable. A re-sync replaces the prior import for the same period.
+ const fileStatuses: {
+ fiscalYear: number
+ rawContent: string
+ previousImport: {
+ importedAt: string | null
+ fiscalYearStart: string | null
+ fiscalYearEnd: string | null
+ } | null
+ }[] = []
for (const file of sieFiles) {
- const fileHash = await calculateFileHash(file.rawContent)
- const { data: existingImport } = await supabase
- .from('sie_imports')
- .select('imported_at')
- .eq('company_id', companyId)
- .eq('file_hash', fileHash)
- .eq('status', 'completed')
- .maybeSingle()
+ const fileParsed = parseSIEFile(file.rawContent)
+ const fyStart = fileParsed.stats.fiscalYearStart
+ const fyEnd = fileParsed.stats.fiscalYearEnd
+
+ let priorImport: {
+ imported_at: string | null
+ fiscal_year_start: string | null
+ fiscal_year_end: string | null
+ } | null = null
+
+ if (fyStart && fyEnd) {
+ const { data } = await supabase
+ .from('sie_imports')
+ .select('imported_at, fiscal_year_start, fiscal_year_end')
+ .eq('company_id', companyId)
+ .eq('status', 'completed')
+ .lte('fiscal_year_start', fyEnd)
+ .gte('fiscal_year_end', fyStart)
+ .limit(1)
+ .maybeSingle()
+ priorImport = data
+ }
fileStatuses.push({
fiscalYear: file.fiscalYear,
rawContent: file.rawContent,
- alreadyImported: !!existingImport,
- importedAt: existingImport?.imported_at ?? null,
+ previousImport: priorImport
+ ? {
+ importedAt: priorImport.imported_at,
+ fiscalYearStart: priorImport.fiscal_year_start,
+ fiscalYearEnd: priorImport.fiscal_year_end,
+ }
+ : null,
})
}
- const allImported = fileStatuses.every(f => f.alreadyImported)
- const newFiles = fileStatuses.filter(f => !f.alreadyImported)
+ const replacedFileCount = fileStatuses.filter(f => f.previousImport).length
return NextResponse.json({
parsed,
@@ -756,11 +785,15 @@ export const arcimMigrationExtension: Extension = {
rawContent: fileStatuses.map(f => f.rawContent),
fileStatuses: fileStatuses.map(f => ({
fiscalYear: f.fiscalYear,
- alreadyImported: f.alreadyImported,
- importedAt: f.importedAt,
+ previousImport: f.previousImport,
+ // Back-compat for older wizard builds: an `alreadyImported`
+ // boolean. The new wizard reads `previousImport` directly.
+ alreadyImported: !!f.previousImport,
+ importedAt: f.previousImport?.importedAt ?? null,
})),
- allImported,
- newFileCount: newFiles.length,
+ allImported: false,
+ newFileCount: fileStatuses.length - replacedFileCount,
+ replacedFileCount,
basAccounts: BAS_REFERENCE,
})
} catch (error) {
@@ -917,6 +950,12 @@ export const arcimMigrationExtension: Extension = {
importOpeningBalances: options.importOpeningBalances,
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries,
+ // Fortnox re-sync semantics: a prior completed import for the
+ // same fiscal year is automatically replaced (its imported
+ // entries are cancelled) so the user can pull updated data
+ // without manual cleanup. Manual SIE upload keeps default
+ // 'block' behavior.
+ onExistingPeriod: 'replace',
})
log.info('SIE import completed:', {
diff --git a/lib/import/__tests__/sie-import.replace.pg.test.ts b/lib/import/__tests__/sie-import.replace.pg.test.ts
new file mode 100644
index 00000000..db80ea90
--- /dev/null
+++ b/lib/import/__tests__/sie-import.replace.pg.test.ts
@@ -0,0 +1,212 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool } from '@/tests/pg/setup'
+import { seedCompany } from '@/tests/pg/fixtures'
+
+// Covers the Fortnox re-sync flow:
+// 1. The partial unique index `sie_imports_company_id_file_hash_active_idx`
+// (added in migration 20260517150000) blocks duplicate (company_id,
+// file_hash) rows for active statuses but allows them once a prior row
+// is marked 'replaced' or 'failed'.
+// 2. The replace_sie_import RPC cancels journal entries with
+// source_type='import' while leaving user-created entries
+// (source_type='manual', 'bank_transaction', etc.) intact.
+
+async function insertSIEImport(params: {
+ companyId: string
+ userId: string
+ fileHash: string
+ status: 'pending' | 'mapped' | 'completed' | 'failed' | 'replaced'
+ fiscalPeriodId?: string
+ openingBalanceEntryId?: string
+ fiscalYearStart?: string
+ fiscalYearEnd?: string
+}): Promise
{
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.sie_imports
+ (id, user_id, company_id, filename, file_hash, sie_type,
+ fiscal_year_start, fiscal_year_end, accounts_count, transactions_count,
+ status, fiscal_period_id, opening_balance_entry_id, imported_at)
+ VALUES ($1, $2, $3, 'fortnox-export.se', $4, 4,
+ $5, $6, 0, 0,
+ $7, $8, $9, $10)`,
+ [
+ id,
+ params.userId,
+ params.companyId,
+ params.fileHash,
+ params.fiscalYearStart ?? '2026-01-01',
+ params.fiscalYearEnd ?? '2026-12-31',
+ params.status,
+ params.fiscalPeriodId ?? null,
+ params.openingBalanceEntryId ?? null,
+ params.status === 'completed' ? new Date().toISOString() : null,
+ ],
+ )
+ return id
+}
+
+async function insertPostedEntry(params: {
+ userId: string
+ companyId: string
+ fiscalPeriodId: string
+ sourceType: 'import' | 'manual' | 'bank_transaction'
+ voucherNumber: number
+ entryDate?: string
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.journal_entries
+ (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
+ entry_date, description, source_type, status)
+ VALUES ($1, $2, $3, $4, $5, 'A', $6, 'Test entry', $7, 'posted')`,
+ [
+ id,
+ params.userId,
+ params.companyId,
+ params.fiscalPeriodId,
+ params.voucherNumber,
+ params.entryDate ?? '2026-06-01',
+ params.sourceType,
+ ],
+ )
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount)
+ VALUES ($1, '1930', 100, 0),
+ ($1, '3001', 0, 100)`,
+ [id],
+ )
+ return id
+}
+
+describe('sie_imports: partial unique index + replace flow', () => {
+ it('blocks a second active row with the same (company_id, file_hash)', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const hash = `hash-${randomUUID()}`
+
+ await insertSIEImport({
+ companyId,
+ userId,
+ fileHash: hash,
+ status: 'completed',
+ fiscalPeriodId,
+ })
+
+ await expect(
+ insertSIEImport({
+ companyId,
+ userId,
+ fileHash: hash,
+ status: 'pending',
+ fiscalPeriodId,
+ }),
+ ).rejects.toThrow(/sie_imports_company_id_file_hash_active_idx/)
+ })
+
+ it('allows a new pending row with the same hash once the prior row is replaced', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const hash = `hash-${randomUUID()}`
+
+ const priorId = await insertSIEImport({
+ companyId,
+ userId,
+ fileHash: hash,
+ status: 'completed',
+ fiscalPeriodId,
+ })
+
+ // Mark the prior row as replaced (simulating what replace_sie_import does)
+ await getPool().query(
+ `UPDATE public.sie_imports SET status = 'replaced', replaced_at = now() WHERE id = $1`,
+ [priorId],
+ )
+
+ // A new pending row with the same hash now succeeds
+ const newId = await insertSIEImport({
+ companyId,
+ userId,
+ fileHash: hash,
+ status: 'pending',
+ fiscalPeriodId,
+ })
+ expect(newId).toBeTruthy()
+ })
+
+ it('replace_sie_import cancels source_type=import entries and leaves manual/bank_transaction entries posted', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+
+ const obEntry = await insertPostedEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ sourceType: 'import',
+ voucherNumber: 1,
+ })
+ const importEntry1 = await insertPostedEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ sourceType: 'import',
+ voucherNumber: 2,
+ })
+ const importEntry2 = await insertPostedEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ sourceType: 'import',
+ voucherNumber: 3,
+ })
+ const manualEntry = await insertPostedEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ sourceType: 'manual',
+ voucherNumber: 4,
+ })
+ const txnEntry = await insertPostedEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ sourceType: 'bank_transaction',
+ voucherNumber: 5,
+ })
+
+ const importId = await insertSIEImport({
+ companyId,
+ userId,
+ fileHash: `hash-${randomUUID()}`,
+ status: 'completed',
+ fiscalPeriodId,
+ openingBalanceEntryId: obEntry,
+ })
+
+ const { rows } = await getPool().query<{ replace_sie_import: number }>(
+ `SELECT public.replace_sie_import($1::uuid, $2::uuid) AS replace_sie_import`,
+ [companyId, importId],
+ )
+ const cancelled = rows[0]!.replace_sie_import
+
+ // OB entry + 2 import entries = 3 cancelled. Manual & transaction stay posted.
+ expect(cancelled).toBe(3)
+
+ const statuses = await getPool().query<{ id: string; status: string }>(
+ `SELECT id, status FROM public.journal_entries WHERE id = ANY($1)`,
+ [[obEntry, importEntry1, importEntry2, manualEntry, txnEntry]],
+ )
+ const statusById = Object.fromEntries(statuses.rows.map(r => [r.id, r.status]))
+ expect(statusById[obEntry]).toBe('cancelled')
+ expect(statusById[importEntry1]).toBe('cancelled')
+ expect(statusById[importEntry2]).toBe('cancelled')
+ expect(statusById[manualEntry]).toBe('posted')
+ expect(statusById[txnEntry]).toBe('posted')
+
+ const importRow = await getPool().query<{ status: string; replaced_at: string | null }>(
+ `SELECT status, replaced_at FROM public.sie_imports WHERE id = $1`,
+ [importId],
+ )
+ expect(importRow.rows[0]!.status).toBe('replaced')
+ expect(importRow.rows[0]!.replaced_at).not.toBeNull()
+ })
+})
diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts
index d13a193f..7554c6ce 100644
--- a/lib/import/sie-import.ts
+++ b/lib/import/sie-import.ts
@@ -191,23 +191,41 @@ export async function replaceSIEImport(
}
/**
- * Clean up stale pending/failed import records for a given file hash.
- * Prevents UNIQUE constraint conflicts when re-importing after a failure.
+ * Clean up orphan in-flight import records for a given file hash.
+ *
+ * Targets rows in status='pending' — left behind when a prior import
+ * crashed (or short-circuited at checkDuplicatePeriodImport) before
+ * reaching finalizeImportRecord. They hold the slot in the partial
+ * unique index `sie_imports_company_id_file_hash_active_idx`, so a
+ * retry would fail with a constraint violation.
+ *
+ * Five-minute age gate protects an in-flight import in another tab/
+ * session: createPendingImportRecord → ... → finalizeImportRecord can
+ * take tens of seconds for large SIE files. Without the gate, a
+ * concurrent retry of the same file would delete the live pending row
+ * mid-flight and the original session's finalize would silently no-op.
+ * Five minutes is long enough for any normal interactive import yet
+ * short enough that legitimate retries after a crash succeed.
+ *
+ * The 'mapped' status is defined in the type but never written by any
+ * code path, so we don't include it. 'failed' and 'replaced' rows are
+ * allowed by the partial index (excluded from its predicate), so they
+ * stay in place for the audit trail.
*/
async function cleanupStaleImportRecords(
supabase: SupabaseClient,
companyId: string,
fileHash: string
): Promise {
- const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
await supabase
.from('sie_imports')
.delete()
.eq('company_id', companyId)
.eq('file_hash', fileHash)
- .in('status', ['pending', 'failed'])
- .lt('created_at', oneHourAgo)
+ .eq('status', 'pending')
+ .lt('created_at', fiveMinutesAgo)
}
/**
@@ -1377,7 +1395,22 @@ async function createPendingImportRecord(
.single()
if (error || !data) {
- throw new Error(`Failed to create pending import record: ${error?.message}`)
+ // PG error 23505 (unique_violation) on the partial index means another
+ // active row exists for the same (company_id, file_hash). Surface the
+ // recovery path in Swedish instead of leaking the raw Postgres message.
+ const pgCode = (error as { code?: string } | null | undefined)?.code
+ const pgMessage = error?.message ?? ''
+ const hitsActiveIdx =
+ pgCode === '23505' &&
+ pgMessage.includes('sie_imports_company_id_file_hash_active_idx')
+
+ if (hitsActiveIdx) {
+ throw new Error(
+ 'En tidigare SIE-import för samma fil finns redan i gnubok. Öppna importhistoriken och välj "Ersätt import" på den befintliga raden, eller använd Fortnox-synkningen för att hämta uppdaterad data automatiskt.'
+ )
+ }
+
+ throw new Error(`Failed to create pending import record: ${pgMessage}`)
}
return data.id
@@ -1490,6 +1523,19 @@ export async function loadMappings(supabase: SupabaseClient, companyId: string):
/**
* Execute the full SIE import
+ *
+ * `onExistingPeriod` controls how a prior completed import that overlaps
+ * the new SIE's fiscal year is handled:
+ * - 'block' (default): refuse with a Swedish error. Used by the manual
+ * upload route in app/api/import/sie. Preserves prior behavior.
+ * - 'replace': automatically call replaceSIEImport on the prior row
+ * (marks it 'replaced', cancels its imported journal entries) and
+ * proceed. Used by the Fortnox re-sync flow so the user can pull
+ * updated data from Fortnox without manual cleanup.
+ *
+ * Replace only cancels journal entries with source_type='import' — entries
+ * the user created natively in gnubok (categorized transactions, invoices,
+ * etc.) are left alone. See the replace_sie_import RPC.
*/
export async function executeSIEImport(
supabase: SupabaseClient,
@@ -1504,6 +1550,7 @@ export async function executeSIEImport(
importOpeningBalances: boolean
importTransactions: boolean
voucherSeries?: string
+ onExistingPeriod?: 'block' | 'replace'
}
): Promise {
const result: ImportResult = {
@@ -1515,8 +1562,11 @@ export async function executeSIEImport(
journalEntryIds: [],
errors: [],
warnings: [],
+ replacedPriorImport: null,
}
+ const onExistingPeriod = options.onExistingPeriod ?? 'block'
+
try {
// Validate all accounts are mapped
const unmapped = mappings.filter((m) => !m.targetAccount)
@@ -1527,13 +1577,66 @@ export async function executeSIEImport(
return result
}
- // Check for duplicate import (only completed imports count as duplicates)
- const duplicate = await checkDuplicateImport(supabase, companyId, options.fileContent)
- if (duplicate) {
- result.errors.push(
- `This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`
- )
- return result
+ // Replace mode: if a prior completed import overlaps the new SIE's fiscal
+ // year, mark it 'replaced' (and cancel its imported entries) before we
+ // try to insert. Done before checkDuplicateImport / checkDuplicatePeriodImport
+ // since both of those would otherwise reject the replace flow.
+ if (onExistingPeriod === 'replace') {
+ const fyStart = parsed.stats.fiscalYearStart
+ const fyEnd = parsed.stats.fiscalYearEnd
+ if (fyStart && fyEnd) {
+ const priorPeriodImport = await checkDuplicatePeriodImport(
+ supabase, companyId, fyStart, fyEnd
+ )
+ if (priorPeriodImport) {
+ const replaceResult = await replaceSIEImport(
+ supabase, companyId, priorPeriodImport.id
+ )
+ if (!replaceResult.success) {
+ result.errors.push(
+ replaceResult.error ?? 'Kunde inte ersätta tidigare SIE-import'
+ )
+ return result
+ }
+ result.replacedPriorImport = {
+ importId: priorPeriodImport.id,
+ cancelledEntries: replaceResult.cancelledEntries,
+ }
+
+ // The replace_sie_import RPC cancelled the prior import's opening
+ // balance entry, but the fiscal_periods row still flags
+ // opening_balances_set=true and points opening_balance_entry_id at
+ // the now-cancelled row. Without clearing those, the IB import
+ // below would skip ("Ingående balanser finns redan...") and the
+ // new IB would be lost. Only reset when the cleared entry was the
+ // prior import's IB — if the period's IB came from somewhere else
+ // (manual entry, year-end carryover), we must not touch it.
+ if (priorPeriodImport.fiscal_period_id && priorPeriodImport.opening_balance_entry_id) {
+ await supabase
+ .from('fiscal_periods')
+ .update({
+ opening_balances_set: false,
+ opening_balance_entry_id: null,
+ })
+ .eq('id', priorPeriodImport.fiscal_period_id)
+ .eq('company_id', companyId)
+ .eq('opening_balance_entry_id', priorPeriodImport.opening_balance_entry_id)
+ }
+ }
+ }
+ }
+
+ // Block mode (default): the hash and period checks reject duplicates with
+ // graceful Swedish errors. Skipped in replace mode because we've already
+ // resolved any prior import above.
+ if (onExistingPeriod === 'block') {
+ const duplicate = await checkDuplicateImport(supabase, companyId, options.fileContent)
+ if (duplicate) {
+ result.errors.push(
+ `This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`
+ )
+ return result
+ }
}
// Create pending import record early — ensures tracking even if later steps fail
@@ -1628,15 +1731,19 @@ export async function executeSIEImport(
return result
}
- // Safety net: reject if a completed import already exists for this period
- const periodDuplicate = await checkDuplicatePeriodImport(
- supabase, companyId, fiscalYearStart, fiscalYearEnd
- )
- if (periodDuplicate) {
- result.errors.push(
- `En SIE-import för ett överlappande räkenskapsår (${periodDuplicate.fiscal_year_start} – ${periodDuplicate.fiscal_year_end}) finns redan`
+ // Safety net: reject if a completed import already exists for this period.
+ // Skipped in replace mode — any overlapping prior import was already
+ // marked 'replaced' at the top of executeSIEImport.
+ if (onExistingPeriod === 'block') {
+ const periodDuplicate = await checkDuplicatePeriodImport(
+ supabase, companyId, fiscalYearStart, fiscalYearEnd
)
- return result
+ if (periodDuplicate) {
+ result.errors.push(
+ `En SIE-import för ett överlappande räkenskapsår (${periodDuplicate.fiscal_year_start} – ${periodDuplicate.fiscal_year_end}) finns redan`
+ )
+ return result
+ }
}
if (options.createFiscalPeriod) {
diff --git a/lib/import/types.ts b/lib/import/types.ts
index cd5516d3..891d56c8 100644
--- a/lib/import/types.ts
+++ b/lib/import/types.ts
@@ -296,6 +296,11 @@ export interface ImportResult {
// Structured details for UI (populated alongside warnings for backwards compat)
details?: ImportResultDetails
+
+ // If this import replaced a prior completed import for the same fiscal year
+ // (Fortnox re-sync flow), the prior import's id and the count of journal
+ // entries that were cancelled as a result.
+ replacedPriorImport?: { importId: string; cancelledEntries: number } | null
}
/**
diff --git a/supabase/migrations/20260517150000_sie_imports_active_partial_unique.sql b/supabase/migrations/20260517150000_sie_imports_active_partial_unique.sql
new file mode 100644
index 00000000..051761a0
--- /dev/null
+++ b/supabase/migrations/20260517150000_sie_imports_active_partial_unique.sql
@@ -0,0 +1,22 @@
+-- Capture the partial unique index on sie_imports that already exists in
+-- production but was never expressed as a repo migration (schema drift).
+--
+-- The plain UNIQUE (company_id, file_hash) constraint added in
+-- 20260330130000_multi_tenant_company_refactor.sql blocks a fiscal-year
+-- re-sync from Fortnox: the prior 'completed' row keeps the slot even
+-- though we want a new import to take its place (with the old one marked
+-- 'replaced'). The partial index relaxes uniqueness for 'replaced' and
+-- 'failed' rows so a replace-and-reimport works.
+--
+-- Both statements are idempotent. Against production this migration is a
+-- no-op; on dev/staging databases that still carry the plain constraint
+-- it brings them in line.
+
+CREATE UNIQUE INDEX IF NOT EXISTS sie_imports_company_id_file_hash_active_idx
+ ON public.sie_imports (company_id, file_hash)
+ WHERE status <> ALL (ARRAY['replaced'::text, 'failed'::text]);
+
+ALTER TABLE public.sie_imports
+ DROP CONSTRAINT IF EXISTS sie_imports_company_id_file_hash_key;
+
+NOTIFY pgrst, 'reload schema';