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>
This commit is contained in:
Pierre Grönberg
2026-08-31 09:25:55 +01:00
committed by GitHub
co-authored by pgronberg
parent 6ac9679fb5
commit 8b0aa80ea0
11 changed files with 1054 additions and 60 deletions
@@ -0,0 +1,403 @@
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',
})
})
})
@@ -0,0 +1,395 @@
/**
* Asset register import: fetch the provider asset register and
* create matching rows in the local asset register.
*
* Fortnox only. The SIE import already carries the bookkeeping VALUES
* (the 1xxx acquisition accounts and accumulated depreciation), so this import
* writes NO journal entries: it recreates the register metadata (per-asset
* acquisition data, useful life, account triple) that SIE cannot express, so
* the depreciation engine can keep depreciating after the migration.
*
* Depreciation already booked in the source system arrives via SIE and must
* not be booked again: each imported asset's notes record how far the source
* system had depreciated it, so the first depreciation proposal after the
* migration can be reviewed against that.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { FortnoxClient, isFortnoxPermissionError } from '@/lib/providers/fortnox/client'
import { resolveConsent } from '@/lib/providers/resolve-consent'
import { createAsset } from '@/lib/bokslut/assets/asset-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { roundOre } from '@/lib/money'
import { isAccountNumber, isIsoDateShaped } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
import type { AssetCategory } from '@/types'
import type { AssetSkipReasons } from '../types'
const log = createLogger('extensions/arcim-migration/import-assets')
export class FortnoxAssetScopesRequiredError extends Error {
readonly code = 'PROVIDER_ASSET_SCOPES_REQUIRED'
constructor() {
super('Fortnox consent lacks assets scope: asset register not readable')
this.name = 'FortnoxAssetScopesRequiredError'
}
}
/** The subset of GET /3/assets fields the import reads. */
export interface FortnoxAsset {
Number?: string | null
Description?: string | null
AcquisitionDate?: string | null
AcquisitionStart?: string | null
AcquisitionValue?: number | string | null
DepreciationFinal?: string | null
DepreciatedTo?: string | null
Status?: string | null
StatusId?: string | null
TypeId?: number | null
Type?: string | null
Notes?: string | null
}
/** The subset of GET /3/assets/types fields the import reads. */
export interface FortnoxAssetType {
Id?: number | null
Number?: string | null
Description?: string | null
AccountAsset?: number | string | null
AccountDepreciation?: number | string | null
AccountValueLoss?: number | string | null
}
export interface AssetsStepResult {
total: number
imported: number
skipped: number
skipReasons?: AssetSkipReasons
errorSample?: string
/** True when the Fortnox consent lacks the assets scope (or licence): the
* step was skipped as a whole rather than failing the migration. */
scopesMissing?: boolean
}
/**
* BAS account class → asset category, mirroring the ranges documented on the
* assets table (migration 20260516120000). The account is the TYPE's
* acquisition account (anskaffningskonto) as configured in Fortnox, which is the most reliable
* category signal the API exposes.
*/
export function categoryForAssetAccount(account: string | null): AssetCategory | null {
if (!account || !isAccountNumber(account)) return null
const n = Number(account)
if (n >= 1010 && n <= 1099) return 'immaterial'
if (n >= 1150 && n <= 1159) return 'land_improvement'
if (n >= 1110 && n <= 1149) return 'building'
if (n >= 1160 && n <= 1199) return 'building'
if (n >= 1210 && n <= 1219) return 'machinery'
if (n >= 1220 && n <= 1239) return 'equipment'
if (n >= 1240 && n <= 1249) return 'vehicle'
if (n >= 1250 && n <= 1259) return 'computer'
if (n >= 1260 && n <= 1299) return 'other_tangible'
return null
}
function isoDateOrNull(value: string | null | undefined): string | null {
if (!value) return null
const date = value.slice(0, 10)
return isIsoDateShaped(date) ? date : null
}
/** Whole months between two ISO dates, rounded to nearest, minimum 1. */
export function monthsBetween(fromIso: string, toIso: string): number {
const from = new Date(`${fromIso}T00:00:00Z`)
const to = new Date(`${toIso}T00:00:00Z`)
const months =
(to.getUTCFullYear() - from.getUTCFullYear()) * 12 +
(to.getUTCMonth() - from.getUTCMonth()) +
(to.getUTCDate() - from.getUTCDate()) / 30
return Math.max(1, Math.round(months))
}
/** K2 standard useful life (schablon, 5 years) when the source gives no usable depreciation window. */
export const FALLBACK_USEFUL_LIFE_MONTHS = 60
export interface MappedAsset {
name: string
category: AssetCategory
acquisition_date: string
acquisition_cost: number
useful_life_months: number
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
notes: string
}
/**
* Provenance marker written into the imported asset's notes. It doubles as
* the re-run identity: the register has no provider-id column, so the marker
* is what lets a re-run recognize an already-imported Fortnox asset even
* after it was renamed in either system.
*/
export function fortnoxAssetMarker(number: string): string {
return `Importerad från Fortnox (tillgång ${number}).`
}
const FORTNOX_ASSET_MARKER_RE = /Importerad från Fortnox \(tillgång ([^)]+)\)\./
/** Extract the Fortnox asset number from an imported asset's notes, if any. */
export function fortnoxNumberFromNotes(notes: string | null | undefined): string | null {
if (!notes) return null
const match = FORTNOX_ASSET_MARKER_RE.exec(notes)
return match ? match[1] : null
}
function accountString(value: number | string | null | undefined): string | null {
if (value === null || value === undefined) return null
const text = String(value)
return isAccountNumber(text) ? text : null
}
/**
* Map one Fortnox asset (plus its type's account configuration) to a local
* CreateAssetInput. Returns null with a reason when the asset cannot be
* represented (no positive acquisition value, no acquisition date).
*/
export function mapFortnoxAsset(
asset: FortnoxAsset,
type: FortnoxAssetType | undefined,
): { input: MappedAsset } | { reason: 'unsupported' } {
const acquisitionDate =
isoDateOrNull(asset.AcquisitionDate) ?? isoDateOrNull(asset.AcquisitionStart)
const acquisitionCost = Number(asset.AcquisitionValue)
if (!acquisitionDate || !Number.isFinite(acquisitionCost) || acquisitionCost <= 0) {
return { reason: 'unsupported' }
}
const assetAccount = accountString(type?.AccountAsset)
const category = categoryForAssetAccount(assetAccount) ?? 'other_tangible'
// Useful life from the source's own depreciation window when it exists.
const depreciationStart = isoDateOrNull(asset.AcquisitionStart) ?? acquisitionDate
const depreciationFinal = isoDateOrNull(asset.DepreciationFinal)
const usefulLifeMonths =
depreciationFinal && depreciationFinal > depreciationStart
? monthsBetween(depreciationStart, depreciationFinal)
: FALLBACK_USEFUL_LIFE_MONTHS
// Account triple from the Fortnox type where it is shaped like the BAS
// account the column expects; anything else falls back to the category
// defaults inside createAsset. AccountDepreciation is the 1xx9 accumulated-
// depreciation account and AccountValueLoss the 78xx cost account in
// Fortnox's model.
const accumulated = accountString(type?.AccountDepreciation)
const expense = accountString(type?.AccountValueLoss)
const name =
asset.Description?.trim() ||
(asset.Number ? `Tillgång ${asset.Number}` : 'Importerad tillgång')
const noteParts = [
asset.Number ? fortnoxAssetMarker(asset.Number) : 'Importerad från Fortnox.',
]
const depreciatedTo = isoDateOrNull(asset.DepreciatedTo)
if (depreciatedTo) {
noteParts.push(
`Avskriven t.o.m. ${depreciatedTo} i källsystemet; avskrivningar fram till dess är redan bokförda via SIE-importen.`,
)
}
if (asset.Notes?.trim()) noteParts.push(asset.Notes.trim())
return {
input: {
name: name.slice(0, 200),
category,
acquisition_date: acquisitionDate,
acquisition_cost: roundOre(acquisitionCost),
useful_life_months: usefulLifeMonths,
bas_asset_account: assetAccount ?? undefined,
bas_accumulated_account: accumulated?.startsWith('1') ? accumulated : undefined,
bas_expense_account: expense?.startsWith('78') ? expense : undefined,
notes: noteParts.join(' '),
},
}
}
/**
* Statuses that belong in the register. Fortnox reports sold/scrapped/deleted
* assets in the list as well; those are history, not open register rows, and
* recreating them would immediately mis-state the register against the
* SIE-imported balances.
*/
export function isImportableStatus(status: string | null | undefined): boolean {
if (!status) return true
return !/sold|såld|scrap|utrangera|delete|raderad|void|cancel|annuller|makuler|not[_ ]?active|ej aktiv/i.test(status)
}
const fortnoxClient = new FortnoxClient()
/**
* Lightweight register stats for the connect-step preview: how many assets
* the consent can read, and how many of them the migration would import.
* Returns null when the consent lacks the scope/licence (the preview simply
* omits the line; the migration reports the same condition properly).
*/
export async function fetchFortnoxAssetPreview(
accessToken: string,
): Promise<{ total: number; importable: number } | null> {
try {
const assets = await fortnoxClient.getPaginated<FortnoxAsset>(
accessToken,
'/assets',
'Assets',
)
const importable = assets.filter((asset) =>
isImportableStatus(asset.Status ?? asset.StatusId),
).length
return { total: assets.length, importable }
} catch (error) {
if (isFortnoxPermissionError(error)) return null
throw error
}
}
export interface ImportAssetsOptions {
supabase: SupabaseClient
companyId: string
userId: string
consentId: string
}
/**
* Fetch the Fortnox asset register and create local asset rows.
*
* Throws FortnoxAssetScopesRequiredError when Fortnox refuses the resource
* for scope/licence reasons; every other per-asset failure is counted and
* reported, never thrown, so one bad asset cannot discard the rest.
*/
export async function importProviderAssets(
options: ImportAssetsOptions,
): Promise<AssetsStepResult | null> {
const { supabase, companyId, userId, consentId } = options
const resolved = await resolveConsent(companyId, consentId)
if ((resolved.consent.provider as string) !== 'fortnox') {
// Only Fortnox exposes an asset register API today; other providers
// simply have no step.
return null
}
const accessToken = resolved.accessToken
let assets: FortnoxAsset[]
let types: FortnoxAssetType[]
try {
;[assets, types] = await Promise.all([
fortnoxClient.getPaginated<FortnoxAsset>(accessToken, '/assets', 'Assets'),
fortnoxClient.getPaginated<FortnoxAssetType>(accessToken, '/assets/types', 'Types'),
])
} catch (error) {
if (isFortnoxPermissionError(error)) {
throw new FortnoxAssetScopesRequiredError()
}
throw error
}
const typeById = new Map<number, FortnoxAssetType>()
for (const type of types) {
if (typeof type.Id === 'number') typeById.set(type.Id, type)
}
// Dedupe against register rows that already exist (re-run of the wizard).
// Primary identity is the Fortnox asset number recovered from the notes
// marker, which survives renames in either system; name + acquisition date
// is the fallback for rows that predate the marker or were edited free.
const existing = await fetchAllRows<{
name: string | null
acquisition_date: string | null
notes: string | null
}>(
({ from, to }) =>
supabase
.from('assets')
.select('name, acquisition_date, notes')
.eq('company_id', companyId)
.range(from, to),
)
const existingKeys = new Set(
existing.map((row) => `${row.name ?? ''}|${row.acquisition_date ?? ''}`),
)
const existingFortnoxNumbers = new Set(
existing
.map((row) => fortnoxNumberFromNotes(row.notes))
.filter((n): n is string => n !== null),
)
// Pre-existing rows WITHOUT a marker (created by hand, or with edited
// notes) can still collide with a numbered source asset on name + date:
// a numbered asset must not re-insert over such a row just because no
// marker ties them together. Snapshot of the initial state only: newly
// imported numbered assets are deliberately NOT added here, so two source
// assets that legitimately share name and date both import.
const markerlessExistingKeys = new Set(
existing
.filter((row) => fortnoxNumberFromNotes(row.notes) === null)
.map((row) => `${row.name ?? ''}|${row.acquisition_date ?? ''}`),
)
let imported = 0
let skipped = 0
const skipReasons: AssetsStepResult['skipReasons'] = {}
let errorSample: string | null = null
for (const asset of assets) {
if (!isImportableStatus(asset.Status ?? asset.StatusId)) {
skipReasons.inactive = (skipReasons.inactive ?? 0) + 1
skipped++
continue
}
const mapped = mapFortnoxAsset(
asset,
typeof asset.TypeId === 'number' ? typeById.get(asset.TypeId) : undefined,
)
if ('reason' in mapped) {
skipReasons.unsupported = (skipReasons.unsupported ?? 0) + 1
skipped++
continue
}
const fortnoxNumber = asset.Number?.trim() || null
const key = `${mapped.input.name}|${mapped.input.acquisition_date}`
const alreadyImported = fortnoxNumber
? existingFortnoxNumbers.has(fortnoxNumber) || markerlessExistingKeys.has(key)
: existingKeys.has(key)
if (alreadyImported) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
if (fortnoxNumber) existingFortnoxNumbers.add(fortnoxNumber)
existingKeys.add(key)
try {
await createAsset(supabase, companyId, userId, mapped.input)
imported++
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
log.error('failed to import an asset', error as Error, {
entityId: asset.Number ?? null,
})
errorSample ??= message
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
}
}
return {
total: assets.length,
imported,
skipped,
skipReasons,
errorSample: errorSample ?? undefined,
}
}
@@ -10,6 +10,7 @@
* 3. Suppliers → needed before supplier invoices
* 4. Sales invoices (all statuses, duplicates skipped)
* 5. Supplier invoices (all statuses, duplicates skipped)
* 6. Asset register (Fortnox only) → asset register rows, no journal entries
*
* Performance note: All steps use bulk reads + chunked inserts to
* avoid N+1 round-trips that would exhaust the Vercel function
@@ -45,6 +46,7 @@ import {
type ExistingCustomerMetadata,
} from './customer-metadata'
import { insertWithPerRowFallback } from './insert-fallback'
import { importProviderAssets, FortnoxAssetScopesRequiredError } from './import-assets'
import {
mapCustomer,
mapSupplier,
@@ -68,6 +70,8 @@ export interface MigrationOptions {
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
/** Import the provider's asset register (Fortnox only). Default true. */
importAssets?: boolean
/** Auto-link imported supplier invoices to GL payment vouchers. Default true. */
reconcileVouchers?: boolean
onProgress?: (progress: MigrationProgress) => void
@@ -1009,6 +1013,27 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 5c: Asset register (Fortnox only) ─────────────────────
// Register metadata only: the bookkeeping values (12xx anskaffning and
// ackumulerade avskrivningar) already arrived via SIE, so this step never
// writes journal entries. A consent without the Fortnox assets scope
// reports scopesMissing instead of failing the migration.
if (options.importAssets !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar anläggningstillgångar...', progress: 92 })
try {
const assets = await importProviderAssets({ supabase, companyId, userId, consentId })
if (assets) results.assets = assets
} catch (err) {
if (err instanceof FortnoxAssetScopesRequiredError) {
log.warn('asset register skipped: consent lacks the Fortnox assets scope')
results.assets = { total: 0, imported: 0, skipped: 0, scopesMissing: true }
} else {
console.error('Failed to import assets:', err)
recordStepError(results, 'assets', err)
}
}
}
// ── Step 6: Reconcile supplier invoices to GL payment vouchers ────
// The GL (incl. the Dr 2440 / Cr 1930 bank-payment vouchers) is imported
// separately via SIE. Supplier invoices arrive (via ?filter=unpaid) as open