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

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

396 lines
14 KiB
TypeScript

/**
* 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,
}
}