fix(arcim): allow Fortnox re-sync to replace prior SIE import per fiscal year (#512)

* fix(arcim): allow Fortnox re-sync to replace prior SIE import per fiscal year

A user reported a sync failure when retrying Fortnox after adding more
verifications:

  Import failed: Failed to create pending import record:
  duplicate key value violates unique constraint
  "sie_imports_company_id_file_hash_active_idx"

Root cause: Fortnox embeds the export-time #GEN date in every SIE export,
so the file hash always differs between syncs. The wizard's hash-based
duplicate detection treated each sync as a brand new file, but the engine
still rejected the insert because the per-period import slot was held by
the prior 'completed' row.

This change reframes Fortnox re-sync as a replace operation rather than a
fresh import:

- New executeSIEImport option `onExistingPeriod: 'block' | 'replace'`.
  Manual SIE upload at /api/import/sie keeps default 'block' (current
  behavior, no regression). The Fortnox /import-sie endpoint passes
  'replace', which runs replaceSIEImport on any overlapping completed
  import before insert. Imported journal entries from the prior import are
  cancelled per BFL 5 kap 5§; user-created entries (manual, transaction,
  invoice) are untouched.

- /sie-data switches from hash-based to period-based duplicate detection
  and returns previousImport metadata per fiscal year.

- Wizard drops the alreadyImported skip filter, surfaces an amber callout
  in the confirm dialog listing fiscal years that will be replaced, and
  shows "ersatte N tidigare importerade verifikationer" per year.

- createPendingImportRecord translates 23505 partial-index violations to
  a clear Swedish recovery message instead of leaking the raw constraint
  name.

- cleanupStaleImportRecords drops the 1-hour age gate and also cleans
  status='mapped' orphans. SIE imports are single-flight per company so
  the gate just made legitimate retries fail.

- After replace, the fiscal_periods row's opening_balances_set and
  opening_balance_entry_id are cleared (only when they pointed at the
  cancelled prior IB entry), so the new IB import isn't skipped.

Schema-drift migration captures the partial unique index
sie_imports_company_id_file_hash_active_idx that already exists in
production (added out-of-band) and drops the now-superseded plain
sie_imports_company_id_file_hash_key constraint. Both statements are
idempotent — verified no-op against production.

Tests: new pg-real test covers the partial index admit-replaced
semantics, source_type='import'-only cancellation in replace_sie_import,
and the post-replace insert path.

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

* fix(arcim): address PR review — restore 5-min cleanup gate, fix test source_type

Greptile P2: `cleanupStaleImportRecords` was deleting `pending` rows
unconditionally, which could wipe a concurrent in-flight import in
another tab/session. Restored a 5-minute age gate (long enough for any
normal interactive import, short enough that legitimate retries after
a crash still succeed). Also dropped `mapped` from the cleanup — it is
defined in SIEImportStatus but no code path writes it, so including it
was both unnecessary and added the concurrent-session risk Greptile
flagged.

pg-real test: insertPostedEntry used `source_type='transaction'` which
is not a valid value per the journal_entries_source_type_check
constraint (migration 20260516060000). Switched to `'bank_transaction'`
— the actual source_type emitted when a user categorizes a bank
transaction in gnubok.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-17 16:25:43 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent f98ffee145
commit 831920fede
6 changed files with 527 additions and 96 deletions
+58 -19
View File
@@ -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:', {