fix(import): treat a voucher-less SIE file as a no-op, not a failed migration (#1445)
* fix(import): treat a voucher-less SIE file as a no-op, not a failed migration A Fortnox migration aborted with the generic "Något gick fel. Försök igen." when the current fiscal year had nothing booked yet: Fortnox exports an empty SIE file for such a year, the finalizer's 0-entry safety net flipped it to 'failed', and the wizard stopped before the customer/supplier/invoice phase ever ran. Three layered fixes: - finalizeImportRecord only downgrades a 0-entry run to 'failed' when the file actually contained vouchers (parsed count via the documentation object). A file with no vouchers completes as a no-op with an explanatory warning; the mapping-fix retry loop the downgrade exists for (Lookma case) is unchanged. - The migration wizard no longer routes messages that are already user-facing Swedish (server envelopes, ImportResult.errors) through getErrorMessage's Swedish-pattern heuristic, which swallowed unrecognized sentences into the generic fallback. - The heuristic itself learns the import-error family (verifikation/importera) so other surfaces rethrowing engine messages keep the real reason too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): cross-check raw #VER before accepting a 0-voucher file as empty The parsed voucher count alone cannot prove a fiscal year was empty: a field-separator or encoding mismatch can swallow every #VER block with only a warning-severity parse issue, and executeSIEImport does not fail on those. Only a raw content check proves the file never declared any vouchers. Addresses the truncation/corruption finding from the Swedish compliance review on #1445. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the raw #VER safeguard in the empty-SIE-file decision entry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
707d597b2e
commit
63d520719a
@@ -816,3 +816,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-06] Sandbox payroll takes skatteavdrag from FALLBACK_TAX_TABLES_2026 rather than a flat schablon: the draft run ships calculated, so its live "Beräkna om" would have jumped ~4 600 kr away from the sibling booked run, and a wrong skatteavdrag would show unlabelled in the payslip, the 2710 line and the AGI figures.
|
||||
[2026-08-06] Added guardSandbox to /api/salary/runs/[id]/payslips/send: it was the only send path without one, and seeding a booked salary run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it.
|
||||
[2026-08-06] Login credentials error says "Fel e-postadress eller lösenord", not "Fel lösenord": GoTrue returns one invalid_credentials code for unknown-email and wrong-password alike (anti-enumeration), so a "wrong password" claim would be both unknowable and an account-existence leak. Clarity comes from inline placement + reset link instead.
|
||||
[2026-08-06] Empty SIE file (0 parsed vouchers AND no raw #VER declaration) finalizes as completed no-op, not failed: Fortnox exports an empty file for a not-yet-booked fiscal year and failing it aborted the whole migration wizard (CashLeads case). The failed-downgrade now fires only when the file contained vouchers that could not be imported; the raw-content #VER cross-check must stay, since a separator/encoding mismatch can swallow every #VER block with only a warning-severity parse issue and would otherwise masquerade as a legitimate empty year. The balance-only continuation-guard scenario rides along as no-op since re-running the same file can never produce a different outcome.
|
||||
|
||||
@@ -70,6 +70,34 @@ function apiErrorMessage(data: unknown, fallback: string): string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an error whose message is already user-facing Swedish (server
|
||||
* envelopes, ImportResult.errors). The catch blocks must show these
|
||||
* verbatim: routing them through getErrorMessage would test them against
|
||||
* its Swedish-pattern heuristic and swallow any miss into the generic
|
||||
* "Något gick fel. Försök igen.", hiding the real reason the migration
|
||||
* stopped.
|
||||
*/
|
||||
class UserFacingError extends Error {}
|
||||
|
||||
/**
|
||||
* Build the throwable for a failed API response: an extracted server
|
||||
* message passes through to the UI verbatim, while the technical fallback
|
||||
* (e.g. "HTTP 500") stays a plain Error so getErrorMessage maps it to a
|
||||
* friendly message.
|
||||
*/
|
||||
function apiError(data: unknown, fallback: string): Error {
|
||||
const extracted = apiErrorMessage(data, '')
|
||||
return extracted ? new UserFacingError(extracted) : new Error(fallback)
|
||||
}
|
||||
|
||||
/** Resolve the message a catch block should display. */
|
||||
function displayError(err: unknown, nonErrorFallback?: string): string {
|
||||
if (err instanceof UserFacingError) return err.message
|
||||
if (!(err instanceof Error) && nonErrorFallback) return nonErrorFallback
|
||||
return getUserErrorMessage(err)
|
||||
}
|
||||
|
||||
/** Pull the structured error `code` from an envelope, if present. */
|
||||
function apiErrorCode(data: unknown): string | null {
|
||||
const err = (data as { error?: unknown } | null)?.error
|
||||
@@ -2094,11 +2122,11 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
const validationErrors = data?.error === 'validation' ? data.validation?.errors : undefined
|
||||
if (Array.isArray(validationErrors)) {
|
||||
setErrorDetails(validationErrors.filter((e): e is string => typeof e === 'string'))
|
||||
throw new Error(
|
||||
throw new UserFacingError(
|
||||
'Bokföringsdatan hos leverantören klarade inte valideringen. Felen nedan måste rättas i källsystemet innan importen kan fortsätta.'
|
||||
)
|
||||
}
|
||||
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
|
||||
throw apiError(data, `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
@@ -2114,7 +2142,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
setStep('options')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta SIE-data')
|
||||
setError(displayError(err, 'Kunde inte hämta SIE-data'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -2196,7 +2224,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(apiErrorMessage(data, `SIE import HTTP ${res.status}`))
|
||||
throw apiError(data, `SIE import HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const result = await res.json() as ImportResult
|
||||
@@ -2207,7 +2235,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
// to /migrate would hit its SIE-guard, whose "SIE måste importeras
|
||||
// först" message masks the real error.
|
||||
if (!result.success) {
|
||||
throw new Error(result.errors.length > 0
|
||||
throw new UserFacingError(result.errors.length > 0
|
||||
? result.errors.join('\n')
|
||||
: 'SIE-importen misslyckades utan felmeddelande.')
|
||||
}
|
||||
@@ -2240,7 +2268,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
|
||||
throw apiError(data, `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
@@ -2264,8 +2292,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
description: 'Din bokföringsdata har importerats.',
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = getUserErrorMessage(err)
|
||||
setError(msg)
|
||||
setError(displayError(err))
|
||||
setStep('result')
|
||||
}
|
||||
}, [consentId, migrationOptions, sieData, toast])
|
||||
|
||||
@@ -460,6 +460,19 @@ describe('getErrorMessage: Swedish heuristic covers real route sentences', () =>
|
||||
'Ett oväntat serverfel uppstod. Försök igen senare.',
|
||||
)
|
||||
})
|
||||
|
||||
// The CashLeads Fortnox migration (2026-08-06): the sie-import finalizer's
|
||||
// guard message reached the wizard as a thrown Error, matched none of the
|
||||
// patterns, and the user saw the generic fallback instead of the reason the
|
||||
// migration stopped. Pins the added patterns: verifikation / importen.
|
||||
it('the 0-verifikationer import guard sentence passes through verbatim', () => {
|
||||
const thrown = new Error(
|
||||
'Importen skapade 0 verifikationer: markerar som misslyckad så filen kan importeras om utan replace/undo. Granska varningarna för att se vilka konton som behöver mappas.',
|
||||
)
|
||||
const msg = getErrorMessage(thrown)
|
||||
expect(msg).toContain('0 verifikationer')
|
||||
expect(msg).not.toBe('Något gick fel. Försök igen.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorMessage: GoTrue auth error patterns', () => {
|
||||
|
||||
@@ -210,6 +210,8 @@ function isSwedishUserMessage(message: string): boolean {
|
||||
/clearingnummer/i,
|
||||
/nummer är/i,
|
||||
/tillgängligt/i,
|
||||
/verifikation/i,
|
||||
/importera|importen/i,
|
||||
]
|
||||
return swedishPatterns.some((p) => p.test(message))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { executeSIEImport, finalizeImportRecord } from '../sie-import'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ParsedSIEFile, AccountMapping, ImportResult } from '../types'
|
||||
import type { ParsedSIEFile, AccountMapping, ImportResult, MigrationDocumentation } from '../types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
@@ -208,6 +208,124 @@ describe('finalizeImportRecord: 0-entry downgrade', () => {
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
// The CashLeads Fortnox migration case (2026-08-06): Fortnox exports an
|
||||
// empty SIE file for a fiscal year with nothing booked yet. The parsed
|
||||
// voucher count travels in documentation.vouchers.total; when it is 0 the
|
||||
// run is a legitimate no-op, not a failure, and flipping it to 'failed'
|
||||
// aborted the whole migration wizard.
|
||||
function makeDocumentation(voucherTotal: number): MigrationDocumentation {
|
||||
return {
|
||||
sourceSystem: 'Fortnox',
|
||||
sourceVersion: '3.61.17',
|
||||
sieType: 4,
|
||||
generatedDate: '2026-08-06',
|
||||
fiscalYear: { start: '2026-01-01', end: '2026-12-31' },
|
||||
importedAt: '2026-08-06T19:40:46.781Z',
|
||||
importedBy: 'user-1',
|
||||
accountMappings: { total: 739, exact: 613, basRange: 126, manual: 0, unmapped: 0 },
|
||||
vouchers: {
|
||||
total: voucherTotal,
|
||||
imported: 0,
|
||||
skippedUnbalanced: 0,
|
||||
skippedUnmapped: voucherTotal,
|
||||
skippedSingleLine: 0,
|
||||
skippedEmpty: 0,
|
||||
},
|
||||
openingBalanceRounding: null,
|
||||
migrationAdjustment: { created: false, deltaAccounts: 0, entryId: null },
|
||||
voucherSeriesUsed: ['B'],
|
||||
voucherNumberRanges: [],
|
||||
voucherNumberMapping: [],
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps a 0-entry run completed when the file itself contained no vouchers', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const result: ImportResult = {
|
||||
success: true,
|
||||
importId: 'imp-empty',
|
||||
fiscalPeriodId: 'fp-2026',
|
||||
openingBalanceEntryId: null,
|
||||
journalEntriesCreated: 0,
|
||||
journalEntryIds: [],
|
||||
errors: [],
|
||||
warnings: [],
|
||||
replacedPriorImport: null,
|
||||
}
|
||||
|
||||
await finalizeImportRecord(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'imp-empty',
|
||||
'company-1',
|
||||
result,
|
||||
'#dummy',
|
||||
makeDocumentation(0),
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.warnings.join(' ')).toMatch(/inga verifikationer/i)
|
||||
})
|
||||
|
||||
it('still flips to failed when raw content declares #VER but parsing yielded 0 vouchers', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const result: ImportResult = {
|
||||
success: true,
|
||||
importId: 'imp-swallowed',
|
||||
fiscalPeriodId: 'fp-2026',
|
||||
openingBalanceEntryId: null,
|
||||
journalEntriesCreated: 0,
|
||||
journalEntryIds: [],
|
||||
errors: [],
|
||||
warnings: ['3 #VER-rader hittades men inga verifikationer kunde tolkas: kontrollera fältavskiljare och teckenkodning'],
|
||||
replacedPriorImport: null,
|
||||
}
|
||||
|
||||
// A truncated or mis-decoded file: the parser saw no vouchers
|
||||
// (documentation says total 0) but the raw content declares #VER.
|
||||
await finalizeImportRecord(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'imp-swallowed',
|
||||
'company-1',
|
||||
result,
|
||||
'#FLAGGA 0\n#VER "A" "1" 20260115 "Inköp"\n{\n}\n',
|
||||
makeDocumentation(0),
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors.join(' ')).toMatch(/0 verifikationer/i)
|
||||
})
|
||||
|
||||
it('still flips to failed when the file had vouchers but none were imported', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const result: ImportResult = {
|
||||
success: true,
|
||||
importId: 'imp-skipped',
|
||||
fiscalPeriodId: 'fp-1',
|
||||
openingBalanceEntryId: null,
|
||||
journalEntriesCreated: 0,
|
||||
journalEntryIds: [],
|
||||
errors: [],
|
||||
warnings: ['100 verifikationer hoppades över med ej mappade konton'],
|
||||
replacedPriorImport: null,
|
||||
}
|
||||
|
||||
await finalizeImportRecord(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'imp-skipped',
|
||||
'company-1',
|
||||
result,
|
||||
'#dummy',
|
||||
makeDocumentation(100),
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors.join(' ')).toMatch(/0 verifikationer/i)
|
||||
})
|
||||
|
||||
it('leaves a 0-voucher run alone when an OB entry was created', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
|
||||
@@ -252,10 +252,13 @@ describe('executeSIEImport: derived IB from #UB -1 (issue #675)', () => {
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
expect(result.openingBalanceEntryId).toBeNull()
|
||||
expect(result.warnings.join(' ')).toMatch(/hoppades över eftersom bolaget redan har bokförda verifikationer/)
|
||||
// Zero entries created → the finalizer safety net downgrades the run so
|
||||
// the file slot stays free for a retry (existing behavior).
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors.join(' ')).toMatch(/0 verifikationer/)
|
||||
// Zero entries from a file with no vouchers is a deliberate no-op (the
|
||||
// continuation guard skipped the IB), not a failure: the finalizer
|
||||
// downgrade only fires when the file contained vouchers that could not
|
||||
// be imported. Re-running the same file could never produce a different
|
||||
// outcome, so failing it would just dead-end the user.
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('creates no IB entry when the file has neither #IB 0 nor #UB -1', async () => {
|
||||
|
||||
@@ -1700,11 +1700,32 @@ export async function finalizeImportRecord(
|
||||
// overlapping-period check would block any retry. Flipping to 'failed'
|
||||
// (which the partial index already excludes) keeps the slot free so the
|
||||
// caller can re-import the same file once the mapping is fixed.
|
||||
//
|
||||
// Exception: a file the parser found NO vouchers in (documentation
|
||||
// carries the parsed count) is a legitimate no-op, not a failure.
|
||||
// Fortnox exports an empty SIE file for a fiscal year with nothing
|
||||
// booked yet, and failing it aborts the whole migration wizard. A later
|
||||
// export with actual vouchers has a different hash, so the claimed slot
|
||||
// never blocks it: the Fortnox flow replaces completed imports, and the
|
||||
// manual flow offers "Ersätt import".
|
||||
const noEntriesCreated =
|
||||
result.success &&
|
||||
result.journalEntriesCreated === 0 &&
|
||||
!result.openingBalanceEntryId
|
||||
if (noEntriesCreated) {
|
||||
// The parsed count alone can't prove the year was empty: a separator or
|
||||
// encoding mismatch can swallow every #VER block without a parse error
|
||||
// (the parser only warns). Cross-check the raw content; a file that
|
||||
// declares #VER but parsed to 0 vouchers must keep failing, or real
|
||||
// affärshändelser would silently never be bokförda (BFL 5 kap).
|
||||
const rawDeclaresVouchers = /^\s*#VER\b/m.test(fileContent)
|
||||
const fileHadNoVouchers =
|
||||
documentation?.vouchers.total === 0 && !rawDeclaresVouchers
|
||||
if (noEntriesCreated && fileHadNoVouchers) {
|
||||
result.warnings.push(
|
||||
'SIE-filen innehåller inga verifikationer för räkenskapsåret: inget ' +
|
||||
'att importera. Räkenskapsåret är skapat och redo att bokföras i.',
|
||||
)
|
||||
} else if (noEntriesCreated) {
|
||||
result.success = false
|
||||
if (result.errors.length === 0) {
|
||||
result.errors.push(
|
||||
|
||||
Reference in New Issue
Block a user