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:
co-authored by
Claude Opus 4.7
parent
f98ffee145
commit
831920fede
@@ -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:', {
|
||||
|
||||
Reference in New Issue
Block a user