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:
Jakob Wennberg
2026-08-07 09:45:29 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 707d597b2e
commit 63d520719a
7 changed files with 199 additions and 14 deletions
+1
View File
@@ -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', () => {
+2
View File
@@ -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 () => {
+22 -1
View File
@@ -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(