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:
co-authored by
pgronberg
parent
6ac9679fb5
commit
8b0aa80ea0
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user