Files
accounted/lib/import/__tests__/sie-import.account-names.test.ts
T
Jakob WennbergandClaude Fable 5 fb3fe82a56 feat(dimensions): PR5 SIE round-trip — lossless dimension import + undo lockstep (#866)
* feat(dimensions): PR5 SIE round-trip — lossless dimension import, registry upsert, undo lockstep

SIE import previously parsed and silently DISCARDED all dimension data
(object lists at sie-parser.ts:651-654, #DIM/#OBJEKT in the ignore list at
:689). Import is now lossless — the dimensions plan PR5 milestone.

Parser: #TRANS object lists ({1 "KS01" 6 "P001"}) land on the line as an
SIE-dim-no → code map (canonical numeric keys, quoted codes, malformed
pairs warn); #DIM/#UNDERDIM/#OBJEKT parse into registry records. OIB/OUB
stay ignored (dimension reporting is P&L-only in v1).

Importer (lib/import/sie-dimensions.ts): upserts missing dimensions/
dimension_values rows — never renames existing ones (ON CONFLICT DO
NOTHING); undeclared reserved numbers synthesize their SIE-standard names
(mirroring the export's orphan synthesis); codes violating the registry
CHECK are skipped with a warning but survive verbatim on lines (documented
legacy-free-text exception). Bulk voucher insert now writes the dimensions
jsonb + cost_center/project mirrors via the sanctioned dual-write helpers
(no trigger suppression needed — the immutability trigger guards
UPDATE/DELETE, not INSERT). Import auto-enables dimensions_enabled with a
result-card notice (pre-authorized by the column comment). arcim-migration
provider syncs inherit all of it via the shared parser/importer.

Undo lockstep (migration 20260702154500): created_by_import_id provenance
on both registry tables (ON DELETE SET NULL); undo_sie_import deletes the
values/dimensions the undone import introduced when no remaining
posted/reversed line references them — user-created rows and rows other
bookkeeping references are untouched. The registry guard triggers act as
backstop. replace_sie_import deliberately skips the lockstep (re-import
re-upserts the same codes). Six pg-real tests cover the lockstep.

Round-trip pinned by test: parse → import state → export → parse preserves
declarations (#UNDERDIM parent links included), values, and per-line object
lists — including synthesis of referenced-but-undeclared values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): restate function-local statement_timeout on undo_sie_import

CREATE OR REPLACE resets proconfig, so the 290s timeout from 20260629160100
was silently dropped — regressing service-client bulk deletes to the
authenticator role's 8s limit. Caught by sie-import.replace.pg.test.ts in CI.
Full pg-real suite green (483/483, TZ=UTC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): surface OIB/OUB drops and dimension presence as parse-level info (#866 review)

Dropping object-balance records must never be silent — one info issue counts
the skipped #OIB/#OUB rows (object-level balances are P&L-out-of-scope in
v1), and a second announces dimension data before the user executes the
import (the preview step renders parse issues), so the auto-enable notice is
no longer purely post-hoc.

Triage notes for the remaining findings: the RPC's opening SELECT is the
company-ownership check the swarm asked for; registry writes are RLS-bound;
line-verbatim codes are the documented legacy-free-text exception; export
emits no #KSUMMA so there is nothing to recompute; SIE dims 3–5 are
"reserved for future use" with no standard names, so generic synthesis is
spec-correct; ON DELETE SET NULL is deliberate — provenance is operational
metadata for undo, not räkenskapsinformation (the guarded journal lines
are), and RESTRICT would block legitimate post-retention housekeeping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:05:13 +02:00

223 lines
6.0 KiB
TypeScript

/**
* 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: [],
dimensions: [],
dimensionValues: [],
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/)
})
})