diff --git a/DECISIONS.md b/DECISIONS.md index 949855b5..f8771eb9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -816,3 +816,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 964f0279..f5c8432d 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -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]) diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index 34bf67bf..eccdc82e 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -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', () => { diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index acec9c5f..dd19fdb9 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -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)) } diff --git a/lib/import/__tests__/sie-import-coverage.test.ts b/lib/import/__tests__/sie-import-coverage.test.ts index 6b24026c..ef8d0224 100644 --- a/lib/import/__tests__/sie-import-coverage.test.ts +++ b/lib/import/__tests__/sie-import-coverage.test.ts @@ -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 { @@ -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() diff --git a/lib/import/__tests__/sie-import-derived-ib.test.ts b/lib/import/__tests__/sie-import-derived-ib.test.ts index 1686b011..1d416e55 100644 --- a/lib/import/__tests__/sie-import-derived-ib.test.ts +++ b/lib/import/__tests__/sie-import-derived-ib.test.ts @@ -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 () => { diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index a29848df..eb71c926 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -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(