Files
accounted/extensions/general/arcim-migration/lib/__tests__/import-assets.test.ts
T
Pierre Grönbergandpgronberg 8b0aa80ea0 feat(arcim-migration): import the Fortnox asset register during migration (#1999)
The Fortnox migration now imports the asset register (GET /3/assets + /3/assets/types) as local register rows via createAsset: category from the type's anskaffningskonto BAS class, useful life from the source's depreciation window (K2 schablon fallback), never any journal entries (values arrived via SIE; the source's depreciated-to date is recorded in notes for review of the first proposal). Sold/scrapped/voided assets are skipped, re-runs dedupe, one bad asset counts as skipped. Gated behind FORTNOX_ASSET_SCOPES_APPROVED=false until the portal registration for integration 39254 carries the Assets scope, so hosted consents are unchanged and the wizard shows an honest skipped row.

Co-authored-by: pgronberg <pgronberg@users.noreply.github.com>
2026-08-31 09:25:55 +01:00

404 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
categoryForAssetAccount,
monthsBetween,
mapFortnoxAsset,
isImportableStatus,
importProviderAssets,
fortnoxAssetMarker,
fortnoxNumberFromNotes,
FortnoxAssetScopesRequiredError,
FALLBACK_USEFUL_LIFE_MONTHS,
type FortnoxAsset,
type FortnoxAssetType,
} from '../import-assets'
vi.mock('@/lib/providers/resolve-consent', () => ({
resolveConsent: vi.fn(),
}))
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
createAsset: vi.fn(),
}))
// The journal engine must never be touched by the asset import: the values
// already arrived via SIE. Mock it so any call is visible as a failure.
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
createDraftEntry: vi.fn(),
commitEntry: vi.fn(),
}))
import { resolveConsent } from '@/lib/providers/resolve-consent'
import { createAsset } from '@/lib/bokslut/assets/asset-service'
import * as engine from '@/lib/bookkeeping/engine'
const resolveConsentMock = vi.mocked(resolveConsent)
const createAssetMock = vi.mocked(createAsset)
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
function routeFetch(
fetchSpy: ReturnType<typeof vi.spyOn>,
routes: { match: string; respond: () => Response }[],
) {
fetchSpy.mockImplementation(((input: RequestInfo | URL) => {
const url = String(input)
const route = routes.find((r) => url.includes(r.match))
if (!route) {
return Promise.resolve(new Response(`no mock for ${url}`, { status: 404 }))
}
return Promise.resolve(route.respond())
}) as typeof fetch)
}
/** Chainable supabase mock answering the existing-assets dedupe read. */
function mockSupabaseWithExistingAssets(
rows: { name: string; acquisition_date: string; notes?: string | null }[],
) {
const builder = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
range: vi.fn().mockResolvedValue({ data: rows, error: null }),
}
return { from: vi.fn(() => builder) } as never
}
const EQUIPMENT_TYPE: FortnoxAssetType = {
Id: 1,
Number: 'INV',
Description: 'Inventarier',
AccountAsset: 1220,
AccountDepreciation: 1229,
AccountValueLoss: 7832,
}
const LAPTOP: FortnoxAsset = {
Number: 'A-1',
Description: 'MacBook Pro',
AcquisitionDate: '2024-03-01',
AcquisitionStart: '2024-03-01',
AcquisitionValue: 30000,
DepreciationFinal: '2027-03-01',
DepreciatedTo: '2026-06-30',
Status: 'ACTIVE',
TypeId: 1,
}
describe('categoryForAssetAccount', () => {
it('maps BAS classes to categories per the assets table ranges', () => {
expect(categoryForAssetAccount('1030')).toBe('immaterial')
expect(categoryForAssetAccount('1110')).toBe('building')
expect(categoryForAssetAccount('1150')).toBe('land_improvement')
expect(categoryForAssetAccount('1210')).toBe('machinery')
expect(categoryForAssetAccount('1220')).toBe('equipment')
expect(categoryForAssetAccount('1240')).toBe('vehicle')
expect(categoryForAssetAccount('1250')).toBe('computer')
expect(categoryForAssetAccount('1280')).toBe('other_tangible')
})
it('rejects non-asset accounts and malformed strings', () => {
expect(categoryForAssetAccount('1930')).toBeNull()
expect(categoryForAssetAccount('12')).toBeNull()
expect(categoryForAssetAccount(null)).toBeNull()
})
})
describe('monthsBetween', () => {
it('computes whole months and never returns less than 1', () => {
expect(monthsBetween('2024-03-01', '2027-03-01')).toBe(36)
expect(monthsBetween('2024-01-01', '2024-01-15')).toBe(1)
})
})
describe('isImportableStatus', () => {
it('keeps active and fully depreciated assets', () => {
expect(isImportableStatus('ACTIVE')).toBe(true)
expect(isImportableStatus('FULLY_DEPRECIATED')).toBe(true)
expect(isImportableStatus(undefined)).toBe(true)
})
it('drops sold, scrapped, deleted, voided and not-yet-active assets', () => {
expect(isImportableStatus('SOLD')).toBe(false)
expect(isImportableStatus('SCRAPPED')).toBe(false)
expect(isImportableStatus('DELETED')).toBe(false)
expect(isImportableStatus('VOIDED')).toBe(false)
expect(isImportableStatus('CANCELLED')).toBe(false)
expect(isImportableStatus('CANCELED')).toBe(false)
expect(isImportableStatus('NOT_ACTIVE')).toBe(false)
})
})
describe('mapFortnoxAsset', () => {
it('maps a Fortnox asset with its type accounts', () => {
const mapped = mapFortnoxAsset(LAPTOP, EQUIPMENT_TYPE)
expect('input' in mapped).toBe(true)
if (!('input' in mapped)) return
expect(mapped.input).toMatchObject({
name: 'MacBook Pro',
category: 'equipment',
acquisition_date: '2024-03-01',
acquisition_cost: 30000,
useful_life_months: 36,
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
})
expect(mapped.input.notes).toContain('A-1')
expect(mapped.input.notes).toContain('2026-06-30')
})
it('falls back to the K2 schablon when no depreciation window exists', () => {
const mapped = mapFortnoxAsset({ ...LAPTOP, DepreciationFinal: null }, EQUIPMENT_TYPE)
if (!('input' in mapped)) throw new Error('expected mapped input')
expect(mapped.input.useful_life_months).toBe(FALLBACK_USEFUL_LIFE_MONTHS)
})
it('drops account overrides that are not shaped like the expected BAS class', () => {
const type: FortnoxAssetType = {
...EQUIPMENT_TYPE,
AccountDepreciation: 7832, // not a 1xx9 balance account
AccountValueLoss: 1229, // not a 78xx cost account
}
const mapped = mapFortnoxAsset(LAPTOP, type)
if (!('input' in mapped)) throw new Error('expected mapped input')
expect(mapped.input.bas_accumulated_account).toBeUndefined()
expect(mapped.input.bas_expense_account).toBeUndefined()
})
it('reports assets without value or date as unsupported', () => {
expect(mapFortnoxAsset({ ...LAPTOP, AcquisitionValue: 0 }, EQUIPMENT_TYPE)).toEqual({
reason: 'unsupported',
})
expect(
mapFortnoxAsset(
{ ...LAPTOP, AcquisitionDate: null, AcquisitionStart: null },
EQUIPMENT_TYPE,
),
).toEqual({ reason: 'unsupported' })
})
})
describe('fortnoxNumberFromNotes', () => {
it('round-trips the marker and tolerates surrounding text', () => {
expect(fortnoxNumberFromNotes(fortnoxAssetMarker('A-1'))).toBe('A-1')
expect(fortnoxNumberFromNotes(`${fortnoxAssetMarker('7')} Avskriven t.o.m. 2026-06-30.`)).toBe('7')
expect(fortnoxNumberFromNotes('Egen anteckning utan markör')).toBeNull()
expect(fortnoxNumberFromNotes(null)).toBeNull()
})
})
describe('importProviderAssets', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
fetchSpy = vi.spyOn(globalThis, 'fetch')
resolveConsentMock.mockResolvedValue({
consent: { provider: 'fortnox' },
accessToken: 'token-1',
} as never)
createAssetMock.mockResolvedValue({ id: 'asset-1' } as never)
})
afterEach(() => {
fetchSpy.mockRestore()
})
const options = {
companyId: 'company-1',
userId: 'user-1',
consentId: 'consent-1',
}
it('imports active assets and never touches the journal engine', async () => {
routeFetch(fetchSpy, [
{
match: '/assets/types',
respond: () => jsonResponse({ Types: [EQUIPMENT_TYPE] }),
},
{
match: '/assets',
respond: () =>
jsonResponse({
Assets: [LAPTOP, { ...LAPTOP, Number: 'A-2', Description: 'Skrivbord', Status: 'SOLD' }],
}),
},
])
const result = await importProviderAssets({
...options,
supabase: mockSupabaseWithExistingAssets([]),
})
expect(result).toMatchObject({
total: 2,
imported: 1,
skipped: 1,
skipReasons: { inactive: 1 },
})
expect(createAssetMock).toHaveBeenCalledTimes(1)
expect(engine.createJournalEntry).not.toHaveBeenCalled()
expect(engine.createDraftEntry).not.toHaveBeenCalled()
})
it('skips an already-imported asset by its Fortnox number even after a rename', async () => {
routeFetch(fetchSpy, [
{ match: '/assets/types', respond: () => jsonResponse({ Types: [EQUIPMENT_TYPE] }) },
{ match: '/assets', respond: () => jsonResponse({ Assets: [LAPTOP] }) },
])
const result = await importProviderAssets({
...options,
supabase: mockSupabaseWithExistingAssets([
// Renamed locally after the first import: only the notes marker
// still ties the row to Fortnox asset A-1.
{
name: 'Bärbar dator (byt namn)',
acquisition_date: '2024-03-01',
notes: fortnoxAssetMarker('A-1'),
},
]),
})
expect(result).toMatchObject({
total: 1,
imported: 0,
skipped: 1,
skipReasons: { duplicate: 1 },
})
expect(createAssetMock).not.toHaveBeenCalled()
})
it('falls back to name + acquisition date for rows without a marker', async () => {
routeFetch(fetchSpy, [
{ match: '/assets/types', respond: () => jsonResponse({ Types: [EQUIPMENT_TYPE] }) },
{ match: '/assets', respond: () => jsonResponse({ Assets: [{ ...LAPTOP, Number: null }] }) },
])
const result = await importProviderAssets({
...options,
supabase: mockSupabaseWithExistingAssets([
{ name: 'MacBook Pro', acquisition_date: '2024-03-01', notes: null },
]),
})
expect(result).toMatchObject({ imported: 0, skipped: 1, skipReasons: { duplicate: 1 } })
expect(createAssetMock).not.toHaveBeenCalled()
})
it('skips a numbered asset colliding with a markerless existing row on name and date', async () => {
routeFetch(fetchSpy, [
{ match: '/assets/types', respond: () => jsonResponse({ Types: [EQUIPMENT_TYPE] }) },
{ match: '/assets', respond: () => jsonResponse({ Assets: [LAPTOP] }) },
])
const result = await importProviderAssets({
...options,
supabase: mockSupabaseWithExistingAssets([
// Hand-created before any import: no marker ties it to Fortnox, but
// re-inserting A-1 over it would duplicate the asset.
{ name: 'MacBook Pro', acquisition_date: '2024-03-01', notes: null },
]),
})
expect(result).toMatchObject({
total: 1,
imported: 0,
skipped: 1,
skipReasons: { duplicate: 1 },
})
expect(createAssetMock).not.toHaveBeenCalled()
})
it('imports two assets sharing name and date when their Fortnox numbers differ', async () => {
routeFetch(fetchSpy, [
{ match: '/assets/types', respond: () => jsonResponse({ Types: [EQUIPMENT_TYPE] }) },
{
match: '/assets',
respond: () =>
jsonResponse({ Assets: [LAPTOP, { ...LAPTOP, Number: 'A-9' }] }),
},
])
const result = await importProviderAssets({
...options,
supabase: mockSupabaseWithExistingAssets([]),
})
expect(result).toMatchObject({ total: 2, imported: 2, skipped: 0 })
expect(createAssetMock).toHaveBeenCalledTimes(2)
})
it('throws FortnoxAssetScopesRequiredError on a scope/licence refusal', async () => {
routeFetch(fetchSpy, [
{
match: '/assets',
respond: () =>
new Response(
JSON.stringify({
ErrorInformation: {
error: 1,
message: 'Det finns ingen aktiv licens för önskat scope.',
code: 2001101,
},
}),
{ status: 400 },
),
},
])
await expect(
importProviderAssets({ ...options, supabase: mockSupabaseWithExistingAssets([]) }),
).rejects.toBeInstanceOf(FortnoxAssetScopesRequiredError)
expect(createAssetMock).not.toHaveBeenCalled()
})
it('returns null for providers without an asset register API', async () => {
resolveConsentMock.mockResolvedValue({
consent: { provider: 'visma' },
accessToken: 'token-2',
} as never)
const result = await importProviderAssets({
...options,
supabase: mockSupabaseWithExistingAssets([]),
})
expect(result).toBeNull()
expect(fetchSpy).not.toHaveBeenCalled()
})
it('counts a per-asset insert failure without aborting the step', async () => {
routeFetch(fetchSpy, [
{ match: '/assets/types', respond: () => jsonResponse({ Types: [EQUIPMENT_TYPE] }) },
{
match: '/assets',
respond: () =>
jsonResponse({
Assets: [LAPTOP, { ...LAPTOP, Number: 'A-3', Description: 'Server' }],
}),
},
])
createAssetMock
.mockRejectedValueOnce(new Error('insert failed'))
.mockResolvedValueOnce({ id: 'asset-2' } as never)
const result = await importProviderAssets({
...options,
supabase: mockSupabaseWithExistingAssets([]),
})
expect(result).toMatchObject({
total: 2,
imported: 1,
skipped: 1,
skipReasons: { failed: 1 },
errorSample: 'insert failed',
})
})
})