fix(import): preserve customized SIE #KONTO account names (#669)

* feat(import): add syncMappedAccounts helper for account create + rename

Single home for the create-missing-accounts logic that exists in three
near-identical copies (executeSIEImport, the SIE execute route, and the
arcim-migration extension), plus a new rename pass that carries customized
SIE #KONTO names into accounts that already exist (e.g. K1-seeded defaults).

The file's name applies only to identity mappings (source === target);
remapped targets keep their BAS/current name. With updateAccountNames=false
the behavior matches the legacy code exactly. Not wired up yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): preserve SIE #KONTO account names; add updateAccountNames option

Customer report: account names customized in Fortnox did not follow into
Accounted via SIE import. The import always used BAS default names for
accounts in the BAS reference and never touched accounts that already
existed (the K1-seeded chart), so the file's names were silently dropped.

executeSIEImport now routes account creation through syncMappedAccounts,
which prefers the file's #KONTO name for identity-mapped accounts and
renames existing accounts whose name differs (surfaced as a warning).
New option updateAccountNames (default true) restores the old behavior
when disabled. The duplicated pre-create blocks in the execute route and
the arcim-migration extension are removed — executeSIEImport owns account
sync on every path now, including the Fortnox re-sync (idempotent renames).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(mcp): expose update_account_names on gnubok_import_sie

Optional boolean on the tool schema, staged into the pending operation and
threaded through commitImportSie to executeSIEImport. Defaults to true at
both stage and commit time — the commit-side default also covers operations
staged before the param existed (Boolean(undefined) would have silently
flipped it off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): v1 SIE import generated no account mappings

The route passed [] as mappings to executeSIEImport, which the
mapping-coverage guard (added in #613) rejects for any real file — and
before that guard, every voucher was silently skipped as unmapped. The
route has never produced a working import for files with vouchers.

Generate mappings server-side from the file's #KONTO records plus stored
per-company overrides (same as the dashboard execute route), reject
unmappable files with a clean 400 before the operation row is created,
and expose options.updateAccountNames (default true).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): "Använd kontonamn från filen" toggle in import review step

New switch (default on) controlling whether the SIE file's #KONTO names
are carried into the chart of accounts. Helper text shows how many
identity-mapped accounts carry names that differ from the BAS defaults.
The page already serializes the whole options object to the execute
route, so no further wiring is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): address PR #669 review — parallel renames, rename audit trail

- Rename pass now runs UPDATEs concurrently in bounded batches of 25
  (greptile P2): a re-sync with many custom names no longer serializes
  N round trips, and a pathological full-chart rename cannot stampede
  the API. Per-rename failures stay non-fatal via Promise.allSettled.
- Persist the per-account rename detail (number, from, to) into
  sie_imports.migration_documentation as accountRenames — the
  behandlingshistorik record per BFNAR 2013:2 (swedish-compliance
  review); the result warnings only carry the count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-04 20:35:49 +02:00
committed by GitHub
parent 4a54467599
commit f7cd1b86e7
14 changed files with 1362 additions and 246 deletions
+5 -91
View File
@@ -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<string, string>()
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,
},
)
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@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<typeof import('@/lib/import/sie-import')>(
'@/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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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<string, unknown>): 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<string, unknown>) {
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<string, unknown>
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<string, unknown>
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()
})
})
@@ -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 15 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)
+31
View File
@@ -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<string | null>(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({
/>
</div>
{/* Account names from file */}
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-account-names" className="font-medium">
Använd kontonamn från filen
</Label>
<p className="text-sm text-muted-foreground">
{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'}
</p>
</div>
<Switch
id="update-account-names"
checked={options.updateAccountNames}
onCheckedChange={(checked) => updateOption('updateAccountNames', checked)}
/>
</div>
{/* Voucher series */}
{options.importTransactions && hasTransactions && (
<div className="space-y-2">
+8 -88
View File
@@ -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<string, string>()
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
@@ -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<Record<string, unknown>> = []
const supabase = {
from: (table: string) => {
if (table !== 'pending_operations') throw new Error(`Unexpected table: ${table}`)
return {
insert: (row: Record<string, unknown>) => {
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<string, unknown>).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<string, unknown>).update_account_names).toBe(false)
})
})
+4
View File
@@ -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,
+474
View File
@@ -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<AccountMapping> & { 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<Record<string, unknown>> = []
const updates: Array<{
payload: Record<string, unknown>
filters: Record<string, string>
}> = []
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<Record<string, unknown>>) => {
inserts.push(...rows)
return Promise.resolve({ error: opts?.insertError ?? null })
},
update: (payload: Record<string, unknown>) => {
const filters: Record<string, string> = {}
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)
})
})
@@ -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/)
})
})
+234
View File
@@ -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<AccountSyncResult> {
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<string, string>()
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<string, string>()
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<string, string>
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
}
+39 -66
View File
@@ -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<ImportResult> {
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<string, string>()
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,
+6
View File
@@ -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
@@ -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 () => {
+5
View File
@@ -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) {