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
@@ -149,6 +149,52 @@ describe('POST /migrate: SIE-import-required guard', () => {
it('allows a company-info-only run with no SIE import (writes no ledger data)', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(
migrateRequest({
consentId: 'consent-1',
importCompanyInfo: true,
importCustomers: false,
importSuppliers: false,
importSalesInvoices: false,
importSupplierInvoices: false,
importAssets: false,
}),
buildCtx(0),
)
expect(res.status).toBe(200)
expect(executeMigration).toHaveBeenCalledTimes(1)
})
// The asset register is entity data too: its rows carry BAS account triples
// and depreciation plans that only mean something against an imported chart
// of accounts, so an assets-only run is gated like any other entity import.
it('blocks an assets-only run with no SIE import', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(
migrateRequest({
consentId: 'consent-1',
importCompanyInfo: false,
importCustomers: false,
importSuppliers: false,
importSalesInvoices: false,
importSupplierInvoices: false,
importAssets: true,
}),
buildCtx(0),
)
expect(res.status).toBe(409)
expect(executeMigration).not.toHaveBeenCalled()
})
// Backward compatibility: a client written before the asset option existed
// omits the field entirely. That request must behave as it did then, so the
// omitted option neither imports assets nor trips this guard.
it('allows a company-info-only run that omits importAssets entirely', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(
migrateRequest({
consentId: 'consent-1',
+28 -3
View File
@@ -24,6 +24,7 @@ import {
FortnoxDocumentScopesRequiredError,
importProviderDocuments,
} from './lib/import-documents'
import { fetchFortnoxAssetPreview } from './lib/import-assets'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import { relinkRegistrationVouchers } from './lib/relink-registration-vouchers'
import type { ArcimProvider } from './types'
@@ -758,6 +759,18 @@ export const arcimMigrationExtension: Extension = {
}
}
// Asset register stats (Fortnox only). Soft: a consent without the
// assets scope (or licence) just omits the line; anything else is
// logged and omitted rather than failing an otherwise good preview.
let assetStats: { total: number; importable: number } | null = null
if (provider === 'fortnox') {
try {
assetStats = await fetchFortnoxAssetPreview(resolved.accessToken)
} catch (err) {
log.info('Asset preview failed:', err instanceof Error ? err.message : String(err))
}
}
// Check if the company already has completed SIE imports (from manual upload)
const { count: sieImportCount } = await supabase
.from('sie_imports')
@@ -775,6 +788,7 @@ export const arcimMigrationExtension: Extension = {
companyInfo: mapped,
sieAvailable,
sieStats,
assetStats,
hasSieData: (sieImportCount ?? 0) > 0,
})
} catch (error) {
@@ -1147,6 +1161,11 @@ export const arcimMigrationExtension: Extension = {
importSuppliers = true,
importSalesInvoices = true,
importSupplierInvoices = true,
// New option: an omitted field must behave exactly as it did before
// this option existed, so it defaults OFF. An older client that omits
// it neither imports assets nor trips the SIE guard below; the wizard
// always sends it explicitly.
importAssets = false,
reconcileVouchers = true,
} = await request.json() as {
consentId: string
@@ -1155,6 +1174,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
importAssets?: boolean
reconcileVouchers?: boolean
}
@@ -1195,10 +1215,14 @@ export const arcimMigrationExtension: Extension = {
// Company info (name, org number, VAT number) writes no accounts,
// balances or subledger rows, so a run that imports only that is
// not gated.
// The asset register counts as entity data for this gate: its rows
// carry BAS account triples and depreciation plans that only mean
// something against an imported chart of accounts.
const importsEntities = importCustomers ||
importSuppliers ||
importSalesInvoices ||
importSupplierInvoices
importSupplierInvoices ||
importAssets
const { count: completedSieImports } = importsEntities
? await supabase
.from('sie_imports')
@@ -1220,9 +1244,9 @@ export const arcimMigrationExtension: Extension = {
...(sieViaApi
? {
messageSv:
'Bokföringsdata (SIE) måste importeras först. Kryssa i "Bokföringsdata (SIE)" i guiden så att kontoplan, ingående balanser och verifikationer hämtas innan kunder, leverantörer och fakturor importeras.',
'Bokföringsdata (SIE) måste importeras först. Kryssa i "Bokföringsdata (SIE)" i guiden så att kontoplan, ingående balanser och verifikationer hämtas innan kunder, leverantörer, fakturor och anläggningstillgångar importeras.',
messageEn:
'A completed SIE import is required first. Tick "Bokföringsdata (SIE)" in the wizard so the chart of accounts, opening balances and verifications are fetched before customers, suppliers and invoices are imported.',
'A completed SIE import is required first. Tick "Bokföringsdata (SIE)" in the wizard so the chart of accounts, opening balances and verifications are fetched before customers, suppliers, invoices and fixed assets are imported.',
}
: {}),
})
@@ -1240,6 +1264,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers,
importSalesInvoices,
importSupplierInvoices,
importAssets,
reconcileVouchers,
}
@@ -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
+25 -1
View File
@@ -69,6 +69,15 @@ export interface SkipReasons {
noMatch?: number
}
/**
* Asset-step skip reasons: `unsupported` counts source assets the mapping
* cannot represent (no positive acquisition value, no acquisition date).
* One shared contract for the migration producer and the wizard consumer.
*/
export interface AssetSkipReasons extends SkipReasons {
unsupported?: number
}
/**
* A migration step that failed against the provider API, surfaced to the user
* instead of being swallowed into a "successful" empty sync. `message` is the
@@ -76,7 +85,7 @@ export interface SkipReasons {
* failure classifies, otherwise a generic sentence with the provider's reply).
*/
export interface MigrationStepError {
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'registrationLinks' | 'reconciliation'
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'assets' | 'registrationLinks' | 'reconciliation'
/** Structured code when the failure classifies (e.g. PROVIDER_API_MODULE_INACTIVE), else null. */
code: string | null
message: string
@@ -117,6 +126,21 @@ export interface MigrationResults {
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
salesInvoices?: InvoiceStepResult
supplierInvoices?: InvoiceStepResult
/**
* Fortnox anläggningsregister → local asset register. Creates register
* rows only, never journal entries (the values arrived via SIE).
* `scopesMissing` marks the whole step as skipped because the consent
* lacks the Fortnox assets scope (or the licence behind it).
*/
assets?: {
total: number
imported: number
skipped: number
skipReasons?: AssetSkipReasons
errorSample?: string
scopesMissing?: boolean
}
/**
* Imported invoices linked to the SIE-imported verifikat that BOOKED them
* (the registration voucher the provider named on the invoice). Only exact,