diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index 7a062745..ea1af70a 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -1,10 +1,8 @@ -import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser' import { suggestMappings } from '@/lib/import/account-mapper' import { executeSIEImport, checkDuplicateImport } from '@/lib/import/sie-import' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' -import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types' @@ -46,6 +44,7 @@ export const POST = withRouteContext( importOpeningBalances: true, importTransactions: true, voucherSeries: companyDefaultSeries, + updateAccountNames: true, } const arrayBuffer = await file.arrayBuffer() @@ -93,95 +92,9 @@ export const POST = withRouteContext( }) } - const mappedAccountNumbers = [ - ...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)), - ] - - const allCompanyAccounts = await fetchAllRows(({ from, to }) => - supabase - .from('chart_of_accounts') - .select('account_number') - .eq('company_id', companyId) - .range(from, to), - ) - const mappedSet = new Set(mappedAccountNumbers) - const existingAccounts = allCompanyAccounts.filter((a) => mappedSet.has(a.account_number)) - - const mappingNameLookup = new Map() - for (const m of mappings) { - if (m.targetAccount) { - mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName) - } - } - - const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) - const accountsToActivate = mappedAccountNumbers - .filter((num) => !existingNumbers.has(num)) - .map((num) => { - const ref = getBASReference(num) - if (ref) { - return { - user_id: user.id, - company_id: companyId, - account_number: ref.account_number, - account_name: ref.account_name, - account_class: ref.account_class, - account_group: ref.account_group, - account_type: ref.account_type, - normal_balance: ref.normal_balance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: ref.description, - sru_code: ref.sru_code, - sort_order: parseInt(ref.account_number), - } - } - - // Sub-account not in BAS reference (e.g. 1241 Personbilar). Derive - // metadata from the account number. - const accountClass = parseInt(num.charAt(0), 10) - const accountGroup = num.substring(0, 2) - const accountName = mappingNameLookup.get(num) || `Konto ${num}` - const accountType = - accountClass === 1 ? 'asset' - : accountClass === 2 ? 'liability' - : accountClass === 3 ? 'revenue' - : 'expense' - const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' - - return { - user_id: user.id, - company_id: companyId, - account_number: num, - account_name: accountName, - account_class: accountClass, - account_group: accountGroup, - account_type: accountType, - normal_balance: normalBalance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: accountName, - sru_code: null, - sort_order: parseInt(num), - } - }) - - if (accountsToActivate.length > 0) { - const { error: activateError } = await supabase - .from('chart_of_accounts') - .insert(accountsToActivate) - - if (activateError) { - opLog.error('sie account activation failed', activateError) - return errorResponseFromCode('SIE_IMPORT_ACCOUNT_ACTIVATION_FAILED', opLog, { - requestId, - details: { reason: activateError.message }, - }) - } - } - + // Account creation (and #KONTO renames) happen inside executeSIEImport + // via syncMappedAccounts — the pre-create block that used to live here + // was a duplicate of that logic. const result = await executeSIEImport( supabase, companyId!, @@ -195,6 +108,7 @@ export const POST = withRouteContext( importOpeningBalances: options.importOpeningBalances, importTransactions: options.importTransactions, voucherSeries: options.voucherSeries || companyDefaultSeries, + updateAccountNames: options.updateAccountNames ?? true, }, ) diff --git a/app/api/v1/companies/[companyId]/imports/sie/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/imports/sie/__tests__/route.test.ts new file mode 100644 index 00000000..89b38874 --- /dev/null +++ b/app/api/v1/companies/[companyId]/imports/sie/__tests__/route.test.ts @@ -0,0 +1,187 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/imports/sie. + * + * Regression: the route used to pass [] as account mappings, which + * executeSIEImport's mapping-coverage guard rejects for any real file + * (before that guard existed, every voucher was silently skipped). The + * route must generate mappings server-side from the file's #KONTO records, + * like the dashboard execute route does. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +const { executeSIEImportMock, checkDuplicateImportMock, startOperationMock } = vi.hoisted(() => ({ + executeSIEImportMock: vi.fn(), + checkDuplicateImportMock: vi.fn().mockResolvedValue(null), + startOperationMock: vi.fn().mockResolvedValue({ id: 'op-1' }), +})) + +vi.mock('@/lib/import/sie-import', async () => { + const actual = await vi.importActual( + '@/lib/import/sie-import', + ) + return { + ...actual, + executeSIEImport: executeSIEImportMock, + checkDuplicateImport: checkDuplicateImportMock, + } +}) +vi.mock('@/lib/api/v1/operations', () => ({ + startOperation: startOperationMock, + completeOperation: vi.fn().mockResolvedValue(undefined), + failOperation: vi.fn().mockResolvedValue(undefined), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { POST } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + +const VALID_SIE = [ + '#FLAGGA 0', + '#SIETYP 4', + '#FNAMN "Import AB"', + '#ORGNR 5566778899', + '#RAR 0 20240101 20241231', + '#KONTO 1930 "Företagskonto Swedbank"', + '#KONTO 2081 "Aktiekapital"', + '#KONTO 6110 "Kontorsmaterial"', + '#IB 0 1930 50000.00', + '#IB 0 2081 -50000.00', + '#VER A 1 20240115 "Inköp"', + '{', + '#TRANS 6110 {} 1000.00', + '#TRANS 1930 {} -1000.00', + '}', +].join('\n') + +function makeRequest(options?: Record): Request { + const fd = new FormData() + fd.append('file', new File([VALID_SIE], 'bok.se', { type: 'application/octet-stream' })) + if (options) fd.append('options', JSON.stringify(options)) + return new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/imports/sie`, { + method: 'POST', + headers: { Authorization: 'Bearer test-fixture-not-a-real-key' }, + body: fd, + }) +} + +function callRoute(options?: Record) { + return POST(makeRequest(options), { + params: Promise.resolve({ companyId: COMPANY_ID }), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + scopes: ['bookkeeping:write'], + mode: 'live', + }) + checkDuplicateImportMock.mockResolvedValue(null) + startOperationMock.mockResolvedValue({ id: 'op-1' }) + executeSIEImportMock.mockResolvedValue({ + success: true, + importId: 'imp-1', + fiscalPeriodId: 'fp-1', + openingBalanceEntryId: 'ob-1', + journalEntriesCreated: 1, + journalEntryIds: ['je-1'], + errors: [], + warnings: [], + replacedPriorImport: null, + }) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + sie_account_mappings: { data: [], error: null }, + }), + ) +}) + +describe('POST /imports/sie', () => { + it('generates account mappings from #KONTO records instead of passing []', async () => { + const res = await callRoute() + + expect(res.status).toBe(202) + const body = await res.json() + expect(body.data.operation_id).toBe('op-1') + + expect(executeSIEImportMock).toHaveBeenCalledTimes(1) + const mappings = executeSIEImportMock.mock.calls[0][4] as Array<{ + sourceAccount: string + sourceName: string + targetAccount: string + }> + expect(mappings).toHaveLength(3) + // Identity mappings carrying the file's #KONTO names. + const m1930 = mappings.find((m) => m.sourceAccount === '1930')! + expect(m1930.targetAccount).toBe('1930') + expect(m1930.sourceName).toBe('Företagskonto Swedbank') + }) + + it('defaults updateAccountNames to true', async () => { + await callRoute() + + const options = executeSIEImportMock.mock.calls[0][5] as Record + expect(options.updateAccountNames).toBe(true) + }) + + it('passes updateAccountNames: false through from the options JSON', async () => { + await callRoute({ updateAccountNames: false }) + + const options = executeSIEImportMock.mock.calls[0][5] as Record + expect(options.updateAccountNames).toBe(false) + }) + + it('rejects unknown options keys (schema stays strict)', async () => { + const res = await callRoute({ updateAccountNamez: true }) + + expect(res.status).toBe(400) + expect(executeSIEImportMock).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/v1/companies/[companyId]/imports/sie/route.ts b/app/api/v1/companies/[companyId]/imports/sie/route.ts index b6a08783..36f471f1 100644 --- a/app/api/v1/companies/[companyId]/imports/sie/route.ts +++ b/app/api/v1/companies/[companyId]/imports/sie/route.ts @@ -42,6 +42,9 @@ import { executeSIEImport, checkDuplicateImport, } from '@/lib/import/sie-import' +import { suggestMappings } from '@/lib/import/account-mapper' +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' +import type { SIEAccountMappingRecord } from '@/lib/import/types' const SieImportAccepted = z.object({ operation_id: z.string().uuid(), @@ -71,6 +74,7 @@ registerEndpoint({ 'Duplicate-file detection is by SHA-256 hash — re-importing the same file returns 409 SIE_IMPORT_DUPLICATE without re-running the import.', 'The operation can take 1–5 minutes for multi-year files. The HTTP response returns immediately with operation_id; poll /operations/{id} every ~2s for status.', 'BFL 7 kap räkenskapsinformation: once a SIE import completes, the resulting verifikationer are immutable. Cancellation midway is not supported.', + 'Account mappings are generated server-side from the file\'s #KONTO records (plus stored per-company overrides). By default the file\'s account names are carried into the chart, renaming existing accounts whose names differ — pass options.updateAccountNames=false to keep BAS default names.', ], example: { response: { @@ -148,6 +152,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( importOpeningBalances: z.boolean().optional().default(true), importTransactions: z.boolean().optional().default(true), voucherSeries: z.string().min(1).max(2).optional().default('A'), + updateAccountNames: z.boolean().optional().default(true), }) // OWASP V4.5: reject unknown keys so a future schema-extension // (or a careless edit) doesn't silently pass mass-assigned fields @@ -223,6 +228,38 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( }) } + // Build account mappings server-side from the file's #KONTO records and + // any stored per-company overrides — same as the dashboard execute route. + // (This route used to pass [] as mappings, which executeSIEImport's + // mapping-coverage guard rejects for any real file.) + const { data: storedMappings } = await ctx.supabase + .from('sie_account_mappings') + .select('*') + .eq('company_id', ctx.companyId) + const mappings = suggestMappings( + parsed.accounts, + BAS_REFERENCE, + (storedMappings as SIEAccountMappingRecord[]) || undefined, + ) + + // Reject unmappable files with a clean 400 before starting the operation + // row, mirroring the dashboard route — the alternative is a permanently + // failed operation from executeSIEImport's coverage guard. + const unmapped = mappings.filter((m) => !m.targetAccount) + if (unmapped.length > 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'file', + message: `${unmapped.length} account(s) in the SIE file could not be mapped to BAS accounts.`, + unmapped_accounts: unmapped.slice(0, 5).map((m) => ({ + account: m.sourceAccount, + name: m.sourceName, + })), + }, + }) + } + // Start the operation row — caller polls /operations/{id} for status. const op = await startOperation( ctx.supabase, @@ -248,7 +285,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( ctx.companyId!, ctx.userId, parsed, - [], + mappings, { filename: file.name, fileContent: content, @@ -256,6 +293,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( importOpeningBalances: options.importOpeningBalances, importTransactions: options.importTransactions, voucherSeries: options.voucherSeries, + updateAccountNames: options.updateAccountNames, }, ) await completeOperation(ctx.supabase, { id: op.id, result }, ctx.log) diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx index 0870a381..135bd8e9 100644 --- a/components/import/ImportReviewStep.tsx +++ b/components/import/ImportReviewStep.tsx @@ -43,6 +43,7 @@ export interface ImportExecuteOptions { createFiscalPeriod: boolean importOpeningBalances: boolean importTransactions: boolean + updateAccountNames: boolean voucherSeries: string } @@ -59,6 +60,7 @@ export default function ImportReviewStep({ createFiscalPeriod: true, importOpeningBalances: true, importTransactions: true, + updateAccountNames: true, voucherSeries: 'B', }) const [defaultSeries, setDefaultSeries] = useState(null) @@ -144,6 +146,16 @@ export default function ImportReviewStep({ const mappedCount = mappings.filter((m) => m.targetAccount).length const hasOpeningBalances = preview.openingBalanceTotal > 0 const hasTransactions = preview.voucherCount > 0 + // Identity-mapped accounts whose #KONTO name differs from the BAS default — + // mirrors the filter in syncMappedAccounts, so the count matches what the + // import would actually rename/create with a custom name. + const customNameCount = mappings.filter( + (m) => + m.targetAccount && + m.sourceAccount === m.targetAccount && + m.sourceName?.trim() && + m.sourceName.trim() !== m.targetName?.trim() + ).length // Full-screen loading takeover during import execution if (isLoading) { @@ -286,6 +298,25 @@ export default function ImportReviewStep({ /> + {/* Account names from file */} +
+
+ +

+ {customNameCount > 0 + ? `${customNameCount} ${customNameCount === 1 ? 'konto' : 'konton'} har egna namn i filen som skiljer sig från BAS-standard` + : 'Kontonamnen i filen följer BAS-standard'} +

+
+ updateOption('updateAccountNames', checked)} + /> +
+ {/* Voucher series */} {options.importTransactions && hasTransactions && (
diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index 9d29c33f..612f4ec3 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -21,8 +21,7 @@ import { ARCIM_PROVIDERS } from './types' 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' -import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' import { FortnoxClient } from '@/lib/providers/fortnox/client' import type { ProviderName } from '@/lib/providers/types' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -833,6 +832,7 @@ export const arcimMigrationExtension: Extension = { importOpeningBalances: boolean importTransactions: boolean voucherSeries?: string + updateAccountNames?: boolean } } @@ -856,92 +856,9 @@ export const arcimMigrationExtension: Extension = { }, { status: 400 }) } - // Auto-activate mapped BAS accounts not yet in user's chart (same as manual upload) - const mappedAccountNumbers = [ - ...new Set(mappings.filter((m: import('@/lib/import/types').AccountMapping) => m.targetAccount).map((m: import('@/lib/import/types').AccountMapping) => m.targetAccount)), - ] - - const allCompanyAccounts = await fetchAllRows(({ from, to }) => - supabase - .from('chart_of_accounts') - .select('account_number') - .eq('company_id', companyId) - .range(from, to) - ) - const existingNumbers = new Set(allCompanyAccounts.map((a: { account_number: string }) => a.account_number)) - - const mappingNameLookup = new Map() - for (const m of mappings) { - if (m.targetAccount) { - mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName) - } - } - - const accountsToActivate = mappedAccountNumbers - .filter((num) => !existingNumbers.has(num)) - .map((num) => { - const ref = getBASReference(num) - if (ref) { - return { - user_id: user.id, - company_id: companyId, - account_number: ref.account_number, - account_name: ref.account_name, - account_class: ref.account_class, - account_group: ref.account_group, - account_type: ref.account_type, - normal_balance: ref.normal_balance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: ref.description, - sru_code: ref.sru_code, - sort_order: parseInt(ref.account_number), - } - } - - const accountClass = parseInt(num.charAt(0), 10) - const accountGroup = num.substring(0, 2) - const accountName = mappingNameLookup.get(num) || `Konto ${num}` - const accountType = - accountClass === 1 ? 'asset' - : accountClass === 2 ? 'liability' - : accountClass === 3 ? 'revenue' - : 'expense' - const normalBalance = - accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' - - return { - user_id: user.id, - company_id: companyId, - account_number: num, - account_name: accountName, - account_class: accountClass, - account_group: accountGroup, - account_type: accountType, - normal_balance: normalBalance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: accountName, - sru_code: null, - sort_order: parseInt(num), - } - }) - - if (accountsToActivate.length > 0) { - const { error: activateError } = await supabase - .from('chart_of_accounts') - .insert(accountsToActivate) - - if (activateError) { - return NextResponse.json({ - error: `Failed to activate accounts: ${activateError.message}`, - }, { status: 500 }) - } - log.info(`Auto-activated ${accountsToActivate.length} accounts`) - } - + // Account creation (and #KONTO renames) happen inside + // executeSIEImport via syncMappedAccounts — the auto-activate block + // that used to live here was a duplicate of that logic. await saveMappings(supabase, user.id, mappings) const result = await executeSIEImport(supabase, companyId, user.id, parsed, mappings, { @@ -951,6 +868,9 @@ export const arcimMigrationExtension: Extension = { importOpeningBalances: options.importOpeningBalances, importTransactions: options.importTransactions, voucherSeries: options.voucherSeries, + // Default ON: re-syncs keep account names current with the source + // system (idempotent — equal names are a no-op in the rename pass). + updateAccountNames: options.updateAccountNames ?? true, // 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 diff --git a/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts b/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts index 77baad4e..0e7fda8a 100644 --- a/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts +++ b/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts @@ -177,3 +177,61 @@ describe('gnubok_import_sie — stage-time validation', () => { expect(result.preview.would_skip_all_vouchers).toBe(false) }) }) + +describe('gnubok_import_sie — update_account_names staging', () => { + // Captures the pending_operations insert payload so the staged params can + // be asserted (createQueuedMockSupabase cannot inspect arguments). + function buildCapturingSupabase() { + const staged: Array> = [] + const supabase = { + from: (table: string) => { + if (table !== 'pending_operations') throw new Error(`Unexpected table: ${table}`) + return { + insert: (row: Record) => { + staged.push(row) + return { + select: () => ({ + single: () => Promise.resolve({ data: { id: 'op-sie' }, error: null }), + }), + } + }, + } + }, + } + return { supabase, staged } + } + + it('defaults update_account_names to true in the staged params', async () => { + const { supabase, staged } = buildCapturingSupabase() + + await importSie.execute( + { file_content: VALID_SIE, filename: 'bok.se', mappings: COVER_VALID_SIE }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' }, + ) + + expect(staged).toHaveLength(1) + expect((staged[0].params as Record).update_account_names).toBe(true) + }) + + it('stages update_account_names: false when the caller opts out', async () => { + const { supabase, staged } = buildCapturingSupabase() + + await importSie.execute( + { + file_content: VALID_SIE, + filename: 'bok.se', + mappings: COVER_VALID_SIE, + update_account_names: false, + }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' }, + ) + + expect((staged[0].params as Record).update_account_names).toBe(false) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 463f5fa9..ea4f540b 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -7497,6 +7497,7 @@ export const tools: McpTool[] = [ import_opening_balances: { type: 'boolean' }, import_transactions: { type: 'boolean' }, voucher_series: { type: 'string', description: 'Override voucher series for imported vouchers' }, + update_account_names: { type: 'boolean', description: 'Use #KONTO names from the file for created and existing accounts (default true). Set false to keep BAS default names.' }, }, required: ['file_content', 'filename', 'mappings'], }, @@ -7572,6 +7573,9 @@ export const tools: McpTool[] = [ import_opening_balances: Boolean(args.import_opening_balances), import_transactions: Boolean(args.import_transactions), voucher_series: args.voucher_series, + // Default true — Boolean(undefined) would silently flip it off. + update_account_names: + args.update_account_names === undefined ? true : Boolean(args.update_account_names), }, { filename, diff --git a/lib/import/__tests__/account-sync.test.ts b/lib/import/__tests__/account-sync.test.ts new file mode 100644 index 00000000..1b801f1b --- /dev/null +++ b/lib/import/__tests__/account-sync.test.ts @@ -0,0 +1,474 @@ +import { describe, it, expect, vi } from 'vitest' +import { syncMappedAccounts } from '../account-sync' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import type { AccountMapping } from '../types' +import type { SupabaseClient } from '@supabase/supabase-js' + +const COMPANY_ID = 'company-1' +const USER_ID = 'user-1' + +// --- Helpers --- + +function mapping( + partial: Partial & { sourceAccount: string; targetAccount: string } +): AccountMapping { + return { + sourceName: '', + targetName: '', + confidence: 1, + matchType: 'exact', + isOverride: false, + ...partial, + } +} + +/** + * Hand-rolled capturing mock (same approach as the importVouchers tests): + * we need to inspect the rows passed to .insert() and the payload/filters of + * .update(), which createQueuedMockSupabase cannot do. + */ +function buildCapturingSupabase(opts?: { + existingAccounts?: Array<{ account_number: string; account_name: string }> + insertError?: { message: string } | null + updateError?: { message: string } | null + selectError?: { message: string } | null +}) { + const existing = opts?.existingAccounts ?? [] + const inserts: Array> = [] + const updates: Array<{ + payload: Record + filters: Record + }> = [] + + const supabase = { + from: vi.fn((table: string) => { + if (table !== 'chart_of_accounts') throw new Error(`Unexpected table: ${table}`) + return { + select: () => ({ + eq: () => ({ + range: (from: number, to: number) => ({ + then: ( + resolve: (v: { + data: Array<{ account_number: string; account_name: string }> | null + error: { message: string } | null + }) => void + ) => { + if (opts?.selectError) { + resolve({ data: null, error: opts.selectError }) + return + } + resolve({ data: existing.slice(from, to + 1), error: null }) + }, + }), + }), + }), + insert: (rows: Array>) => { + inserts.push(...rows) + return Promise.resolve({ error: opts?.insertError ?? null }) + }, + update: (payload: Record) => { + const filters: Record = {} + const chain = { + eq(col: string, val: string) { + filters[col] = val + return chain + }, + then(resolve: (v: { error: { message: string } | null }) => void) { + updates.push({ payload, filters }) + resolve({ error: opts?.updateError ?? null }) + }, + } + return chain + }, + } + }), + } + + return { supabase: supabase as unknown as SupabaseClient, inserts, updates } +} + +function run( + supabase: SupabaseClient, + mappings: AccountMapping[], + updateAccountNames = true +) { + return syncMappedAccounts(supabase, COMPANY_ID, USER_ID, mappings, updateAccountNames) +} + +// --- Tests --- + +describe('syncMappedAccounts — create pass', () => { + it('creates a missing BAS account with the BAS default name when the file has no custom name', async () => { + const { supabase, inserts } = buildCapturingSupabase() + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: '' }), + ]) + + expect(result.error).toBeNull() + expect(result.created).toBe(1) + expect(inserts).toHaveLength(1) + expect(inserts[0].account_name).toBe(getBASReference('1930')!.account_name) + }) + + it('creates a missing BAS account with the #KONTO name from the file (identity mapping)', async () => { + const { supabase, inserts } = buildCapturingSupabase() + const basRef = getBASReference('1930')! + + const result = await run(supabase, [ + mapping({ + sourceAccount: '1930', + targetAccount: '1930', + sourceName: 'Företagskonto Swedbank', + targetName: basRef.account_name, + }), + ]) + + expect(result.error).toBeNull() + expect(inserts).toHaveLength(1) + // The customized Fortnox name wins over the BAS default… + expect(inserts[0].account_name).toBe('Företagskonto Swedbank') + // …while the rest of the metadata still comes from the BAS reference. + expect(inserts[0].account_class).toBe(basRef.account_class) + expect(inserts[0].account_type).toBe(basRef.account_type) + expect(inserts[0].description).toBe(basRef.description) + expect(inserts[0].sort_order).toBe(1930) + expect(inserts[0].is_system_account).toBe(false) + expect(inserts[0].company_id).toBe(COMPANY_ID) + }) + + it('keeps the BAS default name for a remapped (non-identity) target', async () => { + const { supabase, inserts } = buildCapturingSupabase() + + await run(supabase, [ + mapping({ + sourceAccount: '1910', + targetAccount: '1930', + sourceName: 'Kassa special', + }), + ]) + + // The file name describes source 1910, not target 1930. + expect(inserts).toHaveLength(1) + expect(inserts[0].account_name).toBe(getBASReference('1930')!.account_name) + }) + + it('creates a non-BAS sub-account with the file name when flag is on', async () => { + // Precondition: 1932 is not in the BAS reference (bank sub-account). + expect(getBASReference('1932')).toBeUndefined() + const { supabase, inserts } = buildCapturingSupabase() + + await run(supabase, [ + mapping({ + sourceAccount: '1932', + targetAccount: '1932', + sourceName: 'Sparkonto SBAB', + targetName: 'Sparkonto SBAB', + matchType: 'bas_range', + }), + ]) + + expect(inserts).toHaveLength(1) + expect(inserts[0].account_name).toBe('Sparkonto SBAB') + expect(inserts[0].account_class).toBe(1) + expect(inserts[0].account_group).toBe('19') + expect(inserts[0].account_type).toBe('asset') + expect(inserts[0].normal_balance).toBe('debit') + }) + + it('uses the legacy targetName-first fallback for non-BAS accounts when flag is off', async () => { + const { supabase, inserts } = buildCapturingSupabase() + + await run( + supabase, + [ + mapping({ + sourceAccount: '1932', + targetAccount: '1932', + sourceName: 'Sparkonto (källa)', + targetName: 'Sparkonto (mål)', + }), + ], + false + ) + + expect(inserts).toHaveLength(1) + expect(inserts[0].account_name).toBe('Sparkonto (mål)') + }) + + it('creates with BAS defaults when flag is off, even with a custom file name', async () => { + const { supabase, inserts } = buildCapturingSupabase() + + const result = await run( + supabase, + [ + mapping({ + sourceAccount: '1930', + targetAccount: '1930', + sourceName: 'Företagskonto Swedbank', + }), + ], + false + ) + + expect(inserts[0].account_name).toBe(getBASReference('1930')!.account_name) + expect(result.renamed).toBe(0) + }) + + it('ignores empty/whitespace #KONTO names', async () => { + const { supabase, inserts, updates } = buildCapturingSupabase({ + existingAccounts: [{ account_number: '1510', account_name: 'Kundfordringar' }], + }) + + const result = await run(supabase, [ + mapping({ sourceAccount: '1510', targetAccount: '1510', sourceName: ' ' }), + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: ' ' }), + ]) + + expect(inserts).toHaveLength(1) + expect(inserts[0].account_name).toBe(getBASReference('1930')!.account_name) + expect(updates).toHaveLength(0) + expect(result.renamed).toBe(0) + }) + + it('falls back to "Konto {nr}" for non-BAS accounts without any name', async () => { + const { supabase, inserts } = buildCapturingSupabase() + + await run(supabase, [ + mapping({ sourceAccount: '1932', targetAccount: '1932', sourceName: '', targetName: '' }), + ]) + + expect(inserts[0].account_name).toBe('Konto 1932') + }) + + it('swallows duplicate-key insert errors (concurrent import race)', async () => { + const { supabase } = buildCapturingSupabase({ + insertError: { message: 'duplicate key value violates unique constraint' }, + }) + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '1930' }), + ]) + + expect(result.error).toBeNull() + }) + + it('returns a fatal error for non-duplicate insert failures', async () => { + const { supabase, updates } = buildCapturingSupabase({ + insertError: { message: 'permission denied' }, + }) + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: 'Eget namn' }), + ]) + + expect(result.error).toBe('permission denied') + // Rename pass never runs after a fatal create error. + expect(updates).toHaveLength(0) + }) + + it('returns a fatal error when the chart cannot be loaded', async () => { + const { supabase, inserts } = buildCapturingSupabase({ + selectError: { message: 'connection refused' }, + }) + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '1930' }), + ]) + + expect(result.error).toBe('connection refused') + expect(inserts).toHaveLength(0) + }) + + it('does nothing when no mappings have a target account', async () => { + const { supabase } = buildCapturingSupabase() + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '' }), + ]) + + expect(result).toEqual({ + created: 0, + renamed: 0, + renamedAccounts: [], + renameFailed: 0, + error: null, + }) + expect(supabase.from).not.toHaveBeenCalled() + }) +}) + +describe('syncMappedAccounts — rename pass', () => { + it('renames an existing account whose name differs from the file (K1-seeded default)', async () => { + const basName = getBASReference('1930')!.account_name + const { supabase, inserts, updates } = buildCapturingSupabase({ + existingAccounts: [{ account_number: '1930', account_name: basName }], + }) + + const result = await run(supabase, [ + mapping({ + sourceAccount: '1930', + targetAccount: '1930', + sourceName: 'Företagskonto Swedbank', + targetName: basName, + }), + ]) + + expect(inserts).toHaveLength(0) + expect(updates).toHaveLength(1) + // Only the name is touched — never is_system_account or anything else. + expect(Object.keys(updates[0].payload)).toEqual(['account_name']) + expect(updates[0].payload.account_name).toBe('Företagskonto Swedbank') + expect(updates[0].filters).toEqual({ + company_id: COMPANY_ID, + account_number: '1930', + }) + expect(result.renamed).toBe(1) + expect(result.renamedAccounts).toEqual([ + { accountNumber: '1930', from: basName, to: 'Företagskonto Swedbank' }, + ]) + }) + + it('is a no-op when the existing name already matches (idempotent re-sync)', async () => { + const { supabase, updates } = buildCapturingSupabase({ + existingAccounts: [{ account_number: '1930', account_name: 'Företagskonto Swedbank' }], + }) + + const result = await run(supabase, [ + mapping({ + sourceAccount: '1930', + targetAccount: '1930', + sourceName: 'Företagskonto Swedbank', + }), + ]) + + expect(updates).toHaveLength(0) + expect(result.renamed).toBe(0) + }) + + it('never renames when the flag is off', async () => { + const { supabase, updates } = buildCapturingSupabase({ + existingAccounts: [{ account_number: '1930', account_name: 'Gammalt namn' }], + }) + + const result = await run( + supabase, + [ + mapping({ + sourceAccount: '1930', + targetAccount: '1930', + sourceName: 'Företagskonto Swedbank', + }), + ], + false + ) + + expect(updates).toHaveLength(0) + expect(result.renamed).toBe(0) + }) + + it('does not rename a target from a non-identity mapping', async () => { + const { supabase, updates } = buildCapturingSupabase({ + existingAccounts: [{ account_number: '1930', account_name: 'Företagskonto' }], + }) + + const result = await run(supabase, [ + mapping({ + sourceAccount: '1910', + targetAccount: '1930', + sourceName: 'Kassa special', + }), + ]) + + expect(updates).toHaveLength(0) + expect(result.renamed).toBe(0) + }) + + it('last #KONTO wins on duplicate identity mappings', async () => { + const { supabase, updates } = buildCapturingSupabase({ + existingAccounts: [{ account_number: '1930', account_name: 'Gammalt namn' }], + }) + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: 'Första' }), + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: 'Andra' }), + ]) + + expect(updates).toHaveLength(1) + expect(updates[0].payload.account_name).toBe('Andra') + expect(result.renamed).toBe(1) + }) + + it('renames multiple accounts concurrently and aggregates per-account results', async () => { + const { supabase, updates } = buildCapturingSupabase({ + existingAccounts: [ + { account_number: '1930', account_name: 'Gammalt bankkonto' }, + { account_number: '1510', account_name: 'Gamla kundfordringar' }, + { account_number: '2440', account_name: 'Leverantörsskulder' }, + ], + }) + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: 'Företagskonto Swedbank' }), + mapping({ sourceAccount: '1510', targetAccount: '1510', sourceName: 'Kundfordringar SEK' }), + // Unchanged name — must not produce an UPDATE. + mapping({ sourceAccount: '2440', targetAccount: '2440', sourceName: 'Leverantörsskulder' }), + ]) + + expect(updates).toHaveLength(2) + expect(result.renamed).toBe(2) + expect(result.renameFailed).toBe(0) + expect(result.renamedAccounts.map((r) => r.accountNumber).sort()).toEqual(['1510', '1930']) + expect(result.renamedAccounts.find((r) => r.accountNumber === '1930')).toEqual({ + accountNumber: '1930', + from: 'Gammalt bankkonto', + to: 'Företagskonto Swedbank', + }) + }) + + it('counts failed renames as non-fatal', async () => { + const { supabase } = buildCapturingSupabase({ + existingAccounts: [{ account_number: '1930', account_name: 'Gammalt namn' }], + updateError: { message: 'permission denied' }, + }) + + const result = await run(supabase, [ + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: 'Nytt namn' }), + ]) + + expect(result.error).toBeNull() + expect(result.renamed).toBe(0) + expect(result.renameFailed).toBe(1) + }) + + it('handles mixed create + rename in one call', async () => { + const { supabase, inserts, updates } = buildCapturingSupabase({ + existingAccounts: [ + { account_number: '1930', account_name: getBASReference('1930')!.account_name }, + { account_number: '1510', account_name: getBASReference('1510')!.account_name }, + ], + }) + + const result = await run(supabase, [ + // Existing, renamed in Fortnox → rename. + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: 'Huvudkonto' }), + // Existing, untouched name → no-op. + mapping({ + sourceAccount: '1510', + targetAccount: '1510', + sourceName: getBASReference('1510')!.account_name, + }), + // Missing, custom name → created with the file name. + mapping({ sourceAccount: '3010', targetAccount: '3010', sourceName: 'Konsultarvoden' }), + ]) + + expect(result.error).toBeNull() + expect(result.created).toBe(1) + expect(inserts).toHaveLength(1) + expect(inserts[0].account_number).toBe('3010') + expect(inserts[0].account_name).toBe('Konsultarvoden') + expect(updates).toHaveLength(1) + expect(updates[0].filters.account_number).toBe('1930') + expect(result.renamed).toBe(1) + }) +}) diff --git a/lib/import/__tests__/sie-import.account-names.test.ts b/lib/import/__tests__/sie-import.account-names.test.ts new file mode 100644 index 00000000..b874b5dc --- /dev/null +++ b/lib/import/__tests__/sie-import.account-names.test.ts @@ -0,0 +1,220 @@ +/** + * 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: [], + 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/) + }) +}) diff --git a/lib/import/account-sync.ts b/lib/import/account-sync.ts new file mode 100644 index 00000000..f259da1f --- /dev/null +++ b/lib/import/account-sync.ts @@ -0,0 +1,234 @@ +/** + * Chart-of-accounts synchronization for SIE imports. + * + * Single home for the "ensure every mapped target account exists" logic that + * previously lived in three near-identical copies (executeSIEImport, the + * /api/import/sie/execute route, and the arcim-migration extension), plus the + * rename pass that carries customized #KONTO names from the SIE file into + * accounts that already exist (e.g. the K1-seeded defaults). + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { getBASReference, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference' +import { classifyAccount } from '@/lib/bookkeeping/account-classifier' +import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import type { AccountMapping } from './types' + +export interface AccountSyncResult { + /** Accounts inserted into chart_of_accounts */ + created: number + /** Existing accounts whose name was updated from the SIE file */ + renamed: number + /** Detail of each rename, for warnings/logging */ + renamedAccounts: Array<{ accountNumber: string; from: string; to: string }> + /** Renames that failed (non-fatal — the import proceeds with old names) */ + renameFailed: number + /** Fatal error from the create pass; null on success */ + error: string | null +} + +function emptyResult(): AccountSyncResult { + return { created: 0, renamed: 0, renamedAccounts: [], renameFailed: 0, error: null } +} + +/** + * Build a chart_of_accounts insert row with the richest metadata available: + * BAS reference when the number is in BAS_REFERENCE (incl. description and + * k2_excluded), otherwise derived from the account number. + */ +function buildInsertRow( + accountNumber: string, + accountName: string, + basRef: BASReferenceAccount | undefined, + companyId: string, + userId: string, +) { + const sortOrder = /^\d+$/.test(accountNumber) ? parseInt(accountNumber, 10) : null + + if (basRef) { + return { + user_id: userId, + company_id: companyId, + account_number: accountNumber, + account_name: accountName, + account_class: basRef.account_class, + account_group: basRef.account_group, + account_type: basRef.account_type, + normal_balance: basRef.normal_balance, + sru_code: basRef.sru_code ?? computeSRUCode(accountNumber), + k2_excluded: basRef.k2_excluded, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: basRef.description, + sort_order: sortOrder, + } + } + + // Sub-account not in the BAS reference (e.g. 1932 Sparkonto). Derive + // metadata from the account number. + const classified = classifyAccount(accountNumber) + return { + user_id: userId, + company_id: companyId, + account_number: accountNumber, + account_name: accountName, + account_class: parseInt(accountNumber.charAt(0), 10), + account_group: accountNumber.substring(0, 2), + account_type: classified.account_type, + normal_balance: classified.normal_balance, + sru_code: computeSRUCode(accountNumber), + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: accountName, + sort_order: sortOrder, + } +} + +/** + * Ensure every mapped target account exists in chart_of_accounts and, + * when `updateAccountNames` is true, carry the SIE file's #KONTO names into + * the chart. + * + * Name resolution: the file's name applies only to IDENTITY mappings + * (sourceAccount === targetAccount with a non-empty sourceName) — when the + * user remaps a source to a different target, the file name describes the + * source account, not the target, so the target keeps its BAS/current name. + * + * - Create: account_name = file name (identity, flag on) + * ?? BAS reference name + * ?? targetName/sourceName fallback (non-BAS numbers) + * ?? `Konto ${number}`. + * - Rename (flag on only): existing accounts that are identity targets and + * whose stored name differs from the file name get a scoped UPDATE of + * account_name — and nothing else. Applies to is_system_account rows too + * (K1-seeded defaults); the flag itself is never touched. Equal names are + * a no-op, so replace-mode re-imports (Fortnox re-sync) are idempotent. + * + * When `updateAccountNames` is false the behavior matches the legacy code + * exactly: BAS defaults on create, existing accounts untouched. + */ +export async function syncMappedAccounts( + supabase: SupabaseClient, + companyId: string, + userId: string, + mappings: AccountMapping[], + updateAccountNames: boolean, +): Promise { + const result = emptyResult() + + const targetAccounts = [...new Set( + mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount) + )] + if (targetAccounts.length === 0) return result + + // The SIE file's name for each identity-mapped account. Last write wins on + // duplicate #KONTO records (rare, benign). + const desiredNames = new Map() + if (updateAccountNames) { + for (const m of mappings) { + const name = m.sourceName?.trim() + if (name && m.targetAccount && m.sourceAccount === m.targetAccount) { + desiredNames.set(m.targetAccount, name) + } + } + } + + // Legacy create-time fallback for numbers outside the BAS reference. + const fallbackNames = new Map() + for (const m of mappings) { + const fallback = m.targetName || m.sourceName + if (m.targetAccount && fallback) fallbackNames.set(m.targetAccount, fallback) + } + + // Fetch the company's chart once (paged) and filter in JS — avoids a huge + // .in() URL for full-chart imports and the silent 1000-row PostgREST cap. + let existingByNumber: Map + try { + const targetSet = new Set(targetAccounts) + const allAccounts = await fetchAllRows<{ account_number: string; account_name: string }>( + ({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number, account_name') + .eq('company_id', companyId) + .range(from, to) + ) + existingByNumber = new Map( + allAccounts + .filter((a) => targetSet.has(a.account_number)) + .map((a) => [a.account_number, a.account_name]) + ) + } catch (err) { + result.error = err instanceof Error ? err.message : 'Failed to load chart of accounts' + return result + } + + // Create pass: insert missing target accounts. + const missing = targetAccounts.filter((num) => !existingByNumber.has(num)) + if (missing.length > 0) { + const inserts = missing.map((num) => { + const basRef = getBASReference(num) + const name = + desiredNames.get(num) ?? + basRef?.account_name ?? + fallbackNames.get(num) ?? + `Konto ${num}` + return buildInsertRow(num, name, basRef, companyId, userId) + }) + + const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts) + // A duplicate means a concurrent import (or the replace flow) created the + // account between our read and write — the account exists, which is all + // this pass guarantees. + if (insertError && !insertError.message.includes('duplicate')) { + result.error = insertError.message + return result + } + result.created = missing.length + } + + // Rename pass: carry the file's names into existing accounts. The diff set + // is small (only names that actually changed), so the UPDATEs run + // concurrently in bounded batches — a full-chart re-sync must not serialize + // N round trips, but also must not stampede the API with 1000+ in flight. + if (updateAccountNames) { + const renames: Array<{ num: string; from: string; to: string }> = [] + for (const [num, currentName] of existingByNumber) { + const desired = desiredNames.get(num) + if (desired && desired !== currentName) { + renames.push({ num, from: currentName, to: desired }) + } + } + + const RENAME_BATCH_SIZE = 25 + for (let i = 0; i < renames.length; i += RENAME_BATCH_SIZE) { + const batch = renames.slice(i, i + RENAME_BATCH_SIZE) + const outcomes = await Promise.allSettled( + batch.map(async ({ num, to }) => { + const { error: updateError } = await supabase + .from('chart_of_accounts') + .update({ account_name: to }) + .eq('company_id', companyId) + .eq('account_number', num) + if (updateError) throw new Error(updateError.message) + }) + ) + + outcomes.forEach((outcome, idx) => { + if (outcome.status === 'rejected') { + // Non-fatal: the import is still correct with the old name. + result.renameFailed++ + return + } + result.renamed++ + const { num, from, to } = batch[idx] + result.renamedAccounts.push({ accountNumber: num, from, to }) + }) + } + } + + return result +} diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index de528727..3179ff3a 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -18,6 +18,7 @@ import type { } from './types' import type { CreateJournalEntryLineInput } from '@/types' import { mappingsToMap, getMappingStats } from './account-mapper' +import { syncMappedAccounts } from './account-sync' import { calculateFileHash } from './sie-parser' import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { classifyAccount } from '@/lib/bookkeeping/account-classifier' @@ -1807,6 +1808,12 @@ export async function loadMappings(supabase: SupabaseClient, companyId: string): * Replace only cancels journal entries with source_type='import' — entries * the user created natively in Accounted (categorized transactions, invoices, * etc.) are left alone. See the replace_sie_import RPC. + * + * `updateAccountNames` (default true) carries the SIE file's #KONTO names + * into the chart for identity-mapped accounts: new accounts are created with + * the file's name and existing accounts whose name differs are renamed. + * When false, accounts are created with BAS default names and existing + * accounts are left untouched (the pre-2026-06 behavior). */ export async function executeSIEImport( supabase: SupabaseClient, @@ -1822,6 +1829,7 @@ export async function executeSIEImport( importTransactions: boolean voucherSeries?: string onExistingPeriod?: 'block' | 'replace' + updateAccountNames?: boolean } ): Promise { const result: ImportResult = { @@ -1837,6 +1845,7 @@ export async function executeSIEImport( } const onExistingPeriod = options.onExistingPeriod ?? 'block' + const updateAccountNames = options.updateAccountNames ?? true try { // Validate all accounts are mapped @@ -1950,72 +1959,32 @@ export async function executeSIEImport( // Build account mapping lookup const accountMap = mappingsToMap(mappings) - // Ensure all mapped target accounts exist in chart_of_accounts. - // Uses a single batch query + batch insert instead of per-account round trips. - const targetAccounts = [...new Set( - mappings.filter(m => m.targetAccount).map(m => m.targetAccount!) - )] - - if (targetAccounts.length > 0) { - const { data: existing } = await supabase - .from('chart_of_accounts') - .select('account_number') - .eq('company_id', companyId) - .in('account_number', targetAccounts) - - const existingSet = new Set((existing || []).map(a => a.account_number)) - const missing = targetAccounts.filter(num => !existingSet.has(num)) - - if (missing.length > 0) { - const targetNameMap = new Map() - for (const m of mappings) { - if (m.targetAccount) targetNameMap.set(m.targetAccount, m.targetName || m.sourceName) - } - - const inserts = missing.map(num => { - const basRef = getBASReference(num) - if (basRef) { - return { - user_id: userId, - company_id: companyId, - account_number: num, - account_name: basRef.account_name, - account_class: basRef.account_class, - account_group: basRef.account_group, - account_type: basRef.account_type, - normal_balance: basRef.normal_balance, - sru_code: basRef.sru_code ?? computeSRUCode(num), - k2_excluded: basRef.k2_excluded, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - } - } - const classNum = parseInt(num.charAt(0), 10) - const group = num.substring(0, 2) - const classified = classifyAccount(num) - return { - user_id: userId, - company_id: companyId, - account_number: num, - account_name: targetNameMap.get(num) || `Konto ${num}`, - account_class: classNum, - account_group: group, - account_type: classified.account_type, - normal_balance: classified.normal_balance, - sru_code: computeSRUCode(num), - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - } - }) - - const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts) - if (insertError && !insertError.message.includes('duplicate')) { - result.errors.push(`Failed to create accounts: ${insertError.message}`) - return result - } - } + // Ensure all mapped target accounts exist in chart_of_accounts and, + // unless disabled, carry the SIE file's #KONTO names into the chart — + // customized names from the source system (e.g. Fortnox) would otherwise + // be lost to the BAS defaults. + const accountSync = await syncMappedAccounts( + supabase, + companyId, + userId, + mappings, + updateAccountNames + ) + if (accountSync.error) { + result.errors.push(`Failed to create accounts: ${accountSync.error}`) + return result + } + if (accountSync.renamed > 0) { + result.warnings.push( + accountSync.renamed === 1 + ? '1 konto bytte namn till namnet från SIE-filen' + : `${accountSync.renamed} konton bytte namn till namnen från SIE-filen` + ) + } + if (accountSync.renameFailed > 0) { + result.warnings.push( + `${accountSync.renameFailed} kontonamn kunde inte uppdateras från SIE-filen` + ) } // Create or find fiscal period @@ -2418,6 +2387,10 @@ export async function executeSIEImport( manual: mappingStats.manual, unmapped: mappingStats.unmapped, }, + // Behandlingshistorik for #KONTO renames applied by this import + // (BFNAR 2013:2 — the warnings array only carries the count). + accountRenames: + accountSync.renamedAccounts.length > 0 ? accountSync.renamedAccounts : undefined, vouchers: voucherStats, openingBalanceRounding: ibRoundingAdjustment !== 0 ? ibRoundingAdjustment : null, migrationAdjustment: migrationAdjustmentInfo, diff --git a/lib/import/types.ts b/lib/import/types.ts index 4b86ae87..1fd2183d 100644 --- a/lib/import/types.ts +++ b/lib/import/types.ts @@ -384,6 +384,12 @@ export interface MigrationDocumentation { unmapped: number } + // Chart-of-accounts renames applied from the file's #KONTO records + // (behandlingshistorik per BFNAR 2013:2 — who/when is carried by + // importedBy/importedAt on this record). Absent when nothing was renamed + // and on imports recorded before this field existed. + accountRenames?: Array<{ accountNumber: string; from: string; to: string }> + // Voucher statistics vouchers: { total: number diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index f44af404..ba356b27 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -303,6 +303,58 @@ describe('commitPendingOperation: import_sie', () => { warnings: ['minor warning'], }) expect(parseSIEFile).toHaveBeenCalledWith('#FLAGGA 0\n') + // Operations staged before update_account_names existed (params without + // the key) must default to true — Boolean(undefined) would flip it off. + expect(executeSIEImport).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.anything(), + [], + expect.objectContaining({ updateAccountNames: true }) + ) + }) + + it('passes update_account_names: false through to executeSIEImport', async () => { + vi.mocked(parseSIEFile).mockReturnValueOnce({} as never) + vi.mocked(executeSIEImport).mockResolvedValueOnce({ + success: true, + importId: 'imp-2', + fiscalPeriodId: 'fp-1', + openingBalanceEntryId: null, + journalEntriesCreated: 1, + journalEntryIds: ['je-1'], + errors: [], + warnings: [], + }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's update + + const op = makePendingOp({ + operation_type: 'import_sie', + params: { + file_content: '#FLAGGA 0\n', + filename: 'test.sie', + mappings: [], + create_fiscal_period: true, + import_opening_balances: true, + import_transactions: true, + update_account_names: false, + }, + }) + + await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(executeSIEImport).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.anything(), + [], + expect.objectContaining({ updateAccountNames: false }) + ) }) it('rejects when required params are missing', async () => { diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 4cf37d8c..c3039370 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -2261,6 +2261,10 @@ async function commitImportSie( const importOpeningBalances = Boolean(params.import_opening_balances) const importTransactions = Boolean(params.import_transactions) const voucherSeries = params.voucher_series as string | undefined + // Default true (not Boolean(...) — operations staged before this param + // existed must keep the file's account names, matching the UI default). + const updateAccountNames = + params.update_account_names === undefined ? true : Boolean(params.update_account_names) if (!fileContent || !filename || !Array.isArray(mappings)) { return { error: 'file_content, filename, and mappings are required', status: 400 } @@ -2281,6 +2285,7 @@ async function commitImportSie( importOpeningBalances, importTransactions, voucherSeries, + updateAccountNames, }) if (!result.success) {