* fix(import): chunk SIE account creation and close the import row on every exit
A full-BAS Bokio SIE file creates 1 200+ chart_of_accounts rows in one
INSERT. PostgREST runs it under the authenticated role's 8 s
statement_timeout, and with four row-level triggers plus the RLS WITH
CHECK that single statement measured 6.5 s to 8.2 s on prod: it was
cancelled for one company and passed for the next (2026-09-09).
- syncMappedAccounts inserts in chunks of 100 rows (INSERT_CHUNK_SIZE),
so every statement stays an order of magnitude inside the limit. A
failed chunk leaves the earlier ones committed; the next attempt reads
the chart again and inserts only what is still missing.
- executeSIEImport closes its pending sie_imports row in a finally
block. Every early `return result` after createPendingImportRecord
(account sync failure, missing fiscal year, overlapping import,
vouchers outside the year) used to leave the row 'pending'. That row
holds the (company_id, file_hash) slot in the partial unique index,
so a retry inside the five-minute cleanup gate failed on the index
instead of on the real error.
- The slot-held message no longer names "gnubok", an "Ersätt import"
button the import history has never had, or Fortnox; it says the
same file is being imported or was interrupted moments ago and to
retry in a few minutes. The thrown-error prefix is Swedish
("Importen misslyckades:") and the two undo hints name
accounted_undo_sie_import.
Tests: chunk sizes and first-failing-chunk behaviour in
account-sync.test.ts; failed-row finalize on two early exits and the
new slot message in sie-import.account-names.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBTRraVXkCnx93Pa9wxJ6G
* fix(import): write account chunks as ignore-duplicates upserts with an exact count
Review follow-up on #2451. A plain INSERT per chunk still had the old
race: a concurrent import (or the replace flow) creating one account
between our read and write raised a duplicate-key error that the code
swallowed as success, while PostgREST had rolled back the whole chunk,
so every other account in it was silently missing.
Each chunk is now an upsert with onConflict (company_id, account_number)
and ignoreDuplicates, selecting the landed rows: the race becomes a
skipped row, `created` counts exactly what was written (also across a
mid-loop failure, which the compliance review flagged), and the
"duplicate" string special-case is gone.
Tests: conflict-skipped row not counted; a race yields no error; a
failing later chunk reports the rows the earlier chunks committed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBTRraVXkCnx93Pa9wxJ6G
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
352 lines
10 KiB
TypeScript
352 lines
10 KiB
TypeScript
/**
|
|
* executeSIEImport ↔ syncMappedAccounts wiring (F: customized #KONTO names
|
|
* from Fortnox were lost on import).
|
|
*
|
|
* The name-resolution behavior itself is covered by account-sync.test.ts:
|
|
* these tests assert that executeSIEImport threads the updateAccountNames
|
|
* option through (default ON), surfaces rename counts as Swedish warnings,
|
|
* and aborts on a fatal create error.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { executeSIEImport } from '../sie-import'
|
|
import { syncMappedAccounts } from '../account-sync'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { ParsedSIEFile, AccountMapping } from '../types'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
vi.mock('../account-sync', () => ({
|
|
syncMappedAccounts: vi.fn(),
|
|
}))
|
|
|
|
const mockSync = vi.mocked(syncMappedAccounts)
|
|
|
|
// Stops right after the account sync: stats carry no fiscal year, so
|
|
// executeSIEImport returns "No fiscal year defined" without needing the
|
|
// fiscal-period / voucher mocks.
|
|
function makeParsedFile(): ParsedSIEFile {
|
|
return {
|
|
header: {
|
|
sieType: 4,
|
|
flagga: 0,
|
|
program: 'TestProg',
|
|
programVersion: '1.0',
|
|
generatedDate: '2024-01-01',
|
|
format: 'PC8',
|
|
companyName: 'Test AB',
|
|
orgNumber: '5566778899',
|
|
address: null,
|
|
fiscalYears: [],
|
|
currency: 'SEK',
|
|
kontoPlanType: null,
|
|
},
|
|
accounts: [
|
|
{ number: '1930', name: 'Företagskonto Swedbank' },
|
|
{ number: '6110', name: 'Kontorsmaterial' },
|
|
],
|
|
openingBalances: [],
|
|
closingBalances: [],
|
|
resultBalances: [],
|
|
dimensions: [],
|
|
dimensionValues: [],
|
|
vouchers: [
|
|
{
|
|
series: 'A',
|
|
number: 1,
|
|
date: new Date(2024, 0, 15),
|
|
description: 'Inköp',
|
|
lines: [
|
|
{ account: '6110', amount: 1000 },
|
|
{ account: '1930', amount: -1000 },
|
|
],
|
|
},
|
|
],
|
|
issues: [],
|
|
stats: {
|
|
totalAccounts: 2,
|
|
totalVouchers: 1,
|
|
totalTransactionLines: 2,
|
|
fiscalYearStart: null,
|
|
fiscalYearEnd: null,
|
|
},
|
|
} as unknown as ParsedSIEFile
|
|
}
|
|
|
|
function makeMappings(): AccountMapping[] {
|
|
return [
|
|
{
|
|
sourceAccount: '1930',
|
|
sourceName: 'Företagskonto Swedbank',
|
|
targetAccount: '1930',
|
|
targetName: 'Företagskonto/checkkonto',
|
|
confidence: 1,
|
|
matchType: 'exact',
|
|
isOverride: false,
|
|
},
|
|
{
|
|
sourceAccount: '6110',
|
|
sourceName: 'Kontorsmaterial',
|
|
targetAccount: '6110',
|
|
targetName: 'Kontorsmaterial',
|
|
confidence: 1,
|
|
matchType: 'exact',
|
|
isOverride: false,
|
|
},
|
|
]
|
|
}
|
|
|
|
function buildSupabase() {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: null }, // checkDuplicateImport: no prior import
|
|
{ data: null }, // cleanupStaleImportRecords
|
|
{ data: { id: 'imp-1' } }, // createPendingImportRecord insert
|
|
])
|
|
return supabase as unknown as SupabaseClient
|
|
}
|
|
|
|
function runImport(opts?: { updateAccountNames?: boolean }) {
|
|
return executeSIEImport(
|
|
buildSupabase(),
|
|
'company-1',
|
|
'user-1',
|
|
makeParsedFile(),
|
|
makeMappings(),
|
|
{
|
|
filename: 'fortnox.se',
|
|
fileContent: '#dummy',
|
|
createFiscalPeriod: false,
|
|
importOpeningBalances: false,
|
|
importTransactions: true,
|
|
...opts,
|
|
}
|
|
)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockSync.mockReset()
|
|
mockSync.mockResolvedValue({
|
|
created: 0,
|
|
renamed: 0,
|
|
renamedAccounts: [],
|
|
renameFailed: 0,
|
|
error: null,
|
|
})
|
|
})
|
|
|
|
describe('executeSIEImport: account name sync wiring', () => {
|
|
it('defaults updateAccountNames to true', async () => {
|
|
await runImport()
|
|
|
|
expect(mockSync).toHaveBeenCalledTimes(1)
|
|
const [, companyId, userId, mappings, updateNames] = mockSync.mock.calls[0]
|
|
expect(companyId).toBe('company-1')
|
|
expect(userId).toBe('user-1')
|
|
expect(mappings).toHaveLength(2)
|
|
expect(updateNames).toBe(true)
|
|
})
|
|
|
|
it('passes updateAccountNames: false through', async () => {
|
|
await runImport({ updateAccountNames: false })
|
|
|
|
expect(mockSync.mock.calls[0][4]).toBe(false)
|
|
})
|
|
|
|
it('surfaces rename counts as a Swedish warning (plural)', async () => {
|
|
mockSync.mockResolvedValue({
|
|
created: 1,
|
|
renamed: 2,
|
|
renamedAccounts: [
|
|
{ accountNumber: '1930', from: 'Företagskonto/checkkonto', to: 'Företagskonto Swedbank' },
|
|
{ accountNumber: '1510', from: 'Kundfordringar', to: 'Kundfordringar SEK' },
|
|
],
|
|
renameFailed: 0,
|
|
error: null,
|
|
})
|
|
|
|
const result = await runImport()
|
|
|
|
expect(result.warnings).toContain('2 konton bytte namn till namnen från SIE-filen')
|
|
})
|
|
|
|
it('uses singular wording for one rename', async () => {
|
|
mockSync.mockResolvedValue({
|
|
created: 0,
|
|
renamed: 1,
|
|
renamedAccounts: [
|
|
{ accountNumber: '1930', from: 'Företagskonto/checkkonto', to: 'Företagskonto Swedbank' },
|
|
],
|
|
renameFailed: 0,
|
|
error: null,
|
|
})
|
|
|
|
const result = await runImport()
|
|
|
|
expect(result.warnings).toContain('1 konto bytte namn till namnet från SIE-filen')
|
|
})
|
|
|
|
it('warns about failed renames without failing the import step', async () => {
|
|
mockSync.mockResolvedValue({
|
|
created: 0,
|
|
renamed: 0,
|
|
renamedAccounts: [],
|
|
renameFailed: 1,
|
|
error: null,
|
|
})
|
|
|
|
const result = await runImport()
|
|
|
|
expect(result.warnings).toContain('1 kontonamn kunde inte uppdateras från SIE-filen')
|
|
expect(result.errors.join(' ')).not.toMatch(/Failed to create accounts/)
|
|
})
|
|
|
|
it('aborts with an error when the create pass fails', async () => {
|
|
mockSync.mockResolvedValue({
|
|
created: 0,
|
|
renamed: 0,
|
|
renamedAccounts: [],
|
|
renameFailed: 0,
|
|
error: 'permission denied',
|
|
})
|
|
|
|
const result = await runImport()
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.errors).toContain('Failed to create accounts: permission denied')
|
|
})
|
|
|
|
it('adds no rename warning when nothing was renamed', async () => {
|
|
const result = await runImport()
|
|
|
|
expect(result.warnings.join(' ')).not.toMatch(/bytte namn/)
|
|
})
|
|
|
|
it('reports the number of accounts the sync inserted', async () => {
|
|
mockSync.mockResolvedValue({
|
|
created: 109,
|
|
renamed: 0,
|
|
renamedAccounts: [],
|
|
renameFailed: 0,
|
|
error: null,
|
|
})
|
|
|
|
const result = await runImport()
|
|
|
|
expect(result.accountsCreated).toBe(109)
|
|
})
|
|
|
|
it('leaves accountsCreated unset when the create pass fails', async () => {
|
|
mockSync.mockResolvedValue({
|
|
created: 3,
|
|
renamed: 0,
|
|
renamedAccounts: [],
|
|
renameFailed: 0,
|
|
error: 'permission denied',
|
|
})
|
|
|
|
const result = await runImport()
|
|
|
|
expect(result.accountsCreated).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// Regression for 2026-09-09: the account insert timed out, executeSIEImport
|
|
// returned through the early `return result` after syncMappedAccounts, and
|
|
// the sie_imports row it had just created stayed 'pending'. That row holds
|
|
// the (company_id, file_hash) slot in the partial unique index, so the user's
|
|
// retry 40 s later failed on the index with a message that named the retired
|
|
// brand and a button that does not exist.
|
|
describe('executeSIEImport: pending import record on early exit', () => {
|
|
function importWith(mock: ReturnType<typeof createQueuedMockSupabase>) {
|
|
return executeSIEImport(
|
|
mock.supabase as unknown as SupabaseClient,
|
|
'company-1',
|
|
'user-1',
|
|
makeParsedFile(),
|
|
makeMappings(),
|
|
{
|
|
filename: 'bokio.se',
|
|
fileContent: '#dummy',
|
|
createFiscalPeriod: false,
|
|
importOpeningBalances: false,
|
|
importTransactions: true,
|
|
}
|
|
)
|
|
}
|
|
|
|
it('closes the pending record as failed when the create pass aborts', async () => {
|
|
mockSync.mockResolvedValue({
|
|
created: 0,
|
|
renamed: 0,
|
|
renamedAccounts: [],
|
|
renameFailed: 0,
|
|
error: 'canceling statement due to statement timeout',
|
|
})
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueueMany([
|
|
{ data: null }, // checkDuplicateImport: no prior import
|
|
{ data: null }, // cleanupStaleImportRecords
|
|
{ data: { id: 'imp-1' } }, // createPendingImportRecord insert
|
|
{ data: null }, // finalizeImportRecord update
|
|
])
|
|
|
|
const result = await importWith(mock)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.importId).toBe('imp-1')
|
|
const updates = mock.findCalls('sie_imports', 'update')
|
|
expect(updates).toHaveLength(1)
|
|
expect(updates[0][0]).toMatchObject({
|
|
status: 'failed',
|
|
imported_at: null,
|
|
transactions_count: 0,
|
|
error_message: expect.stringContaining('statement timeout'),
|
|
})
|
|
})
|
|
|
|
it('closes the pending record as failed when the file has no fiscal year', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueueMany([
|
|
{ data: null },
|
|
{ data: null },
|
|
{ data: { id: 'imp-2' } },
|
|
{ data: null },
|
|
])
|
|
|
|
const result = await importWith(mock)
|
|
|
|
expect(result.errors).toContain('No fiscal year defined in the SIE file')
|
|
const updates = mock.findCalls('sie_imports', 'update')
|
|
expect(updates).toHaveLength(1)
|
|
expect(updates[0][0]).toMatchObject({ status: 'failed' })
|
|
})
|
|
|
|
it('explains a held file-hash slot in Swedish, without the retired brand or a missing button', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueueMany([
|
|
{ data: null }, // checkDuplicateImport
|
|
{ data: null }, // cleanupStaleImportRecords
|
|
{
|
|
data: null,
|
|
error: {
|
|
code: '23505',
|
|
message:
|
|
'duplicate key value violates unique constraint "sie_imports_company_id_file_hash_active_idx"',
|
|
},
|
|
},
|
|
])
|
|
|
|
const result = await importWith(mock)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.importId).toBeNull()
|
|
expect(result.errors).toHaveLength(1)
|
|
expect(result.errors[0]).toMatch(
|
|
/^Importen misslyckades: Samma SIE-fil håller redan på att importeras/
|
|
)
|
|
expect(result.errors[0]).toMatch(/Vänta några minuter och försök igen/)
|
|
expect(result.errors[0]).not.toMatch(/gnubok|Ersätt import|Fortnox/)
|
|
// No row was created, so there is nothing to close.
|
|
expect(mock.findCalls('sie_imports', 'update')).toHaveLength(0)
|
|
})
|
|
})
|