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
@@ -219,40 +219,15 @@ function apiErrorCode(data: unknown): string | null {
return null
}
interface SkipReasons {
duplicate?: number
inactive?: number
failed?: number
noMatch?: number
}
interface MigrationStepError {
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'registrationLinks' | 'reconciliation'
code: string | null
message: string
}
/** Mirrors MigrationResults.registrationLinks in extensions/general/arcim-migration/types.ts. */
interface RegistrationLinkCounts {
scanned: number
linked: number
noRef: number
refNotFetched: number
unresolved: number
ambiguous: number
amountMismatch: number
alreadyLinked: number
}
interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
registrationLinks?: RegistrationLinkCounts
stepErrors?: MigrationStepError[]
}
// The migration result contract is owned by the extension: a second
// hand-written copy here would drift from the API. Type-only import, same
// pattern as the other extension workspaces.
import type {
MigrationResults,
MigrationStepError,
SkipReasons,
AssetSkipReasons,
} from '@/extensions/general/arcim-migration/types'
import AccountMappingStep from '@/components/import/AccountMappingStep'
import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigrationTheater'
import TheaterCanvas from '@/components/import/TheaterCanvas'
@@ -290,6 +265,7 @@ interface MigrationOptions {
importSuppliers: boolean
importSalesInvoices: boolean
importSupplierInvoices: boolean
importAssets: boolean
voucherSeries: string
}
@@ -300,6 +276,7 @@ const DEFAULT_OPTIONS: MigrationOptions = {
importSuppliers: true,
importSalesInvoices: true,
importSupplierInvoices: true,
importAssets: true,
voucherSeries: 'B',
}
@@ -327,6 +304,10 @@ interface PreviewData {
transactionCount: number
fiscalYears: number[]
} | null
assetStats: {
total: number
importable: number
} | null
hasSieData: boolean
}
@@ -924,19 +905,33 @@ function PreviewStep({
</div>
)}
{/* SIE stats: one quiet statline, the same grammar as the import
reveal, instead of a boxed summary. */}
{preview?.sieAvailable && preview.sieStats && (
<p className="animate-fade-in mt-3 text-[13px] text-muted-foreground tabular-nums">
{preview.sieStats.accountCount.toLocaleString('sv-SE')} konton
{' · '}
{preview.sieStats.transactionCount.toLocaleString('sv-SE')} verifikationer
{' · '}
{preview.sieStats.fiscalYears.length === 1
? `räkenskapsåret ${preview.sieStats.fiscalYears[0]}`
: `${preview.sieStats.fiscalYears.length} räkenskapsår: ${preview.sieStats.fiscalYears.join(', ')}`}
</p>
)}
{/* SIE + asset stats: one quiet statline, the same grammar as the
import reveal, instead of a boxed summary. The asset count renders
on its own when the SIE fetch failed: the preview endpoint sets
them independently. */}
{(() => {
const sieStats = preview?.sieAvailable ? preview.sieStats : null
const assetCount = preview?.assetStats?.importable ?? 0
if (!sieStats && assetCount === 0) return null
const parts: string[] = []
if (sieStats) {
parts.push(`${sieStats.accountCount.toLocaleString('sv-SE')} konton`)
parts.push(`${sieStats.transactionCount.toLocaleString('sv-SE')} verifikationer`)
parts.push(
sieStats.fiscalYears.length === 1
? `räkenskapsåret ${sieStats.fiscalYears[0]}`
: `${sieStats.fiscalYears.length} räkenskapsår: ${sieStats.fiscalYears.join(', ')}`,
)
}
if (assetCount > 0) {
parts.push(`${assetCount.toLocaleString('sv-SE')} anläggningstillgångar`)
}
return (
<p className="animate-fade-in mt-3 text-[13px] text-muted-foreground tabular-nums">
{parts.join(' · ')}
</p>
)
})()}
{preview && !preview.sieAvailable && !isLoading && preview.hasSieData && (
<p className="animate-fade-in mt-3 text-[13px] text-muted-foreground">
@@ -1111,6 +1106,7 @@ function OptionsStep({
if (options.importSuppliers) selectedItems.push('Leverantörer')
if (options.importSalesInvoices) selectedItems.push('Kundfakturor')
if (options.importSupplierInvoices) selectedItems.push('Leverantörsfakturor')
if (provider === 'fortnox' && options.importAssets) selectedItems.push('Anläggningstillgångar')
// Entities without the SIE-derived ledger leave an incomplete bokföring:
// POST /migrate refuses with PROVIDER_SIE_IMPORT_REQUIRED unless a completed
@@ -1230,6 +1226,14 @@ function OptionsStep({
checked={options.importSupplierInvoices}
onChange={() => toggleOption('importSupplierInvoices')}
/>
{provider === 'fortnox' && (
<OptionRow
label="Anläggningstillgångar"
description="Anläggningsregistret med avskrivningsplaner. Bokförda värden kommer via SIE; registret gör att avskrivningarna fortsätter automatiskt."
checked={options.importAssets}
onChange={() => toggleOption('importAssets')}
/>
)}
</div>
{sieRequiredButUnchecked && (
@@ -1764,7 +1768,8 @@ function ResultStep({
(results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 0 || results.customers.skipped > 0)) ||
(results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) ||
(results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) ||
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0))
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)) ||
(results.assets && (results.assets.imported > 0 || results.assets.skipped > 0 || results.assets.scopesMissing))
)
// Steps that failed against the provider API. An empty sync with failed
@@ -1784,7 +1789,7 @@ function ResultStep({
? fyCount === 1
? `${totalJournalEntries.toLocaleString('sv-SE')} verifikationer är på plats.`
: `${totalJournalEntries.toLocaleString('sv-SE')} verifikationer över ${fyCount} räkenskapsår är på plats.`
: !allSieSucceeded || totalErrors > 0
: (sieResults.length > 0 && !allSieSucceeded) || totalErrors > 0
? 'Migreringen är klar, med anmärkningar.'
: 'Migreringen är klar.'
@@ -1867,6 +1872,19 @@ function ResultStep({
failed: false,
})
}
if (results.assets && (results.assets.imported > 0 || results.assets.skipped > 0 || results.assets.scopesMissing)) {
entityLines.push({
label: 'Anläggningstillgångar',
value: results.assets.scopesMissing ? 'Hoppades över' : `${results.assets.imported} importerade`,
detail: results.assets.scopesMissing
? 'Fortnox-anslutningen saknar behörighet till anläggningsregistret (assets-scope). Bokförda värden är ändå med via SIE.'
: results.assets.skipped > 0
? formatSkipReasons(results.assets.skipReasons, 'asset', results.assets.errorSample) ?? `${results.assets.skipped} hoppades över`
: undefined,
failed: !results.assets.scopesMissing &&
entityRowStatus(results.assets.imported, results.assets.skipReasons) === 'error',
})
}
}
return (
@@ -2006,6 +2024,7 @@ const STEP_ERROR_LABELS: Record<MigrationStepError['step'], string> = {
suppliers: 'Leverantörer',
salesInvoices: 'Kundfakturor',
supplierInvoices: 'Leverantörsfakturor',
assets: 'Anläggningstillgångar',
registrationLinks: 'Koppling till verifikationer',
reconciliation: 'Avstämning av betalningar',
}
@@ -2025,14 +2044,21 @@ function groupStepErrors(errors: MigrationStepError[]): { message: string; steps
}
function formatSkipReasons(
reasons?: SkipReasons,
entityType?: 'customer' | 'supplier' | 'invoice',
reasons?: AssetSkipReasons,
entityType?: 'customer' | 'supplier' | 'invoice' | 'asset',
errorSample?: string,
): string | undefined {
if (!reasons) return undefined
const parts: string[] = []
if (reasons.duplicate) parts.push(`${reasons.duplicate} fanns redan`)
if (reasons.inactive) parts.push(`${reasons.inactive} inaktiv${reasons.inactive > 1 ? 'a' : ''}`)
if (reasons.inactive) {
parts.push(
entityType === 'asset'
? `${reasons.inactive} avyttrad${reasons.inactive > 1 ? 'e' : ''} eller annullerad${reasons.inactive > 1 ? 'e' : ''}`
: `${reasons.inactive} inaktiv${reasons.inactive > 1 ? 'a' : ''}`,
)
}
if (reasons.unsupported) parts.push(`${reasons.unsupported} kunde inte tolkas`)
if (reasons.noMatch) {
const matchLabel = entityType === 'invoice' ? 'utan matchning' : 'utan matchning'
parts.push(`${reasons.noMatch} ${matchLabel}`)
@@ -2852,11 +2878,19 @@ export default function ArcimMigrationWorkspace({
}
// ── Phase 2: API import (customers, suppliers, invoices) ──
// The asset toggle is only rendered for Fortnox (the one provider with
// an asset register API), but its DEFAULT_OPTIONS value stays true for
// everyone. Gate it on the provider here too, so a hidden option can
// never be the reason /migrate starts for a user who deselected every
// visible API import.
const effectiveImportAssets =
selectedProvider === 'fortnox' && migrationOptions.importAssets
const hasApiImport = migrationOptions.importCompanyInfo ||
migrationOptions.importCustomers ||
migrationOptions.importSuppliers ||
migrationOptions.importSalesInvoices ||
migrationOptions.importSupplierInvoices
migrationOptions.importSupplierInvoices ||
effectiveImportAssets
let hadStepErrors = false
if (hasApiImport) {
@@ -2873,6 +2907,7 @@ export default function ArcimMigrationWorkspace({
importSuppliers: migrationOptions.importSuppliers,
importSalesInvoices: migrationOptions.importSalesInvoices,
importSupplierInvoices: migrationOptions.importSupplierInvoices,
importAssets: effectiveImportAssets,
}),
})