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 10:25:55 +02:00
committed by GitHub
parent 6ac9679fb5
commit 8b0aa80ea0
11 changed files with 1054 additions and 60 deletions
+1
View File
@@ -1323,6 +1323,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-27] Added `npm run check:types`, a typecheck ratchet (scripts/checks/no-new-type-errors.mjs + typecheck-baseline.json), wired into the core-build `checks` job next to check:lint. Reason: `npm test` does NOT typecheck. Vitest transpiles and discards types, so a type error passes all 18 000 tests and only surfaces in `npm run build` minutes later; that happened TWICE on 2026-08-27 (a widened errorKind union in the MCP server that lib/events/types.ts still contradicted, and an `interface` that would not assign into `Record<string, unknown>[]` because interfaces have no implicit index signature). It is not merely a faster copy of the build job: `tsc --noEmit` also covers `__tests__` files, which the Next.js build never compiles, and that is where all 539 baseline errors live. Baseline is keyed per FILE, deliberately unlike the per-RULE lint ratchet: the legacy errors are concentrated in a handful of old test files and TS2322 is common enough that a code-keyed budget would silently absorb a real regression somewhere else, whereas per-file trips the moment a previously-clean file gains an error. Verified the gate actually fires by introducing a deliberate `const x: number = 'str'` and watching it fail with the exact location, then restoring. Cost measured: 36 s cold (what CI pays, since tsconfig.tsbuildinfo is gitignored) and 4.4 s warm locally via the existing `incremental: true`. The script sets NODE_OPTIONS=--max-old-space-size=8192 because a bare tsc dies with "Ineffective mark-compacts near heap limit" on this graph after about two minutes, which reads like a hang rather than a misconfiguration; it also detects that OOM string and exits 2 with a "raise HEAP_MB" message rather than silently reporting zero errors. NOT changed: Definition of Done item 1 still says only lint + test. CI enforcement is the stronger mechanism and does not need the policy edit; adding it to DoD is a founder call.
[2026-08-27] Dropped VAT cadence localStorage persistence from PR #1998 (kept the settings-row gate): skeptic pass refuted it twice (SSR hydration mismatch from render-phase localStorage read; persisting a cadence that deviates from moms_period keeps the filing pipeline open on the wrong period type with no downstream period-type validation). The mount-time re-seed from moms_period is the self-healing control; FyPicker already persists the rakenskapsar pick.
[2026-08-27] Invite-only brand signup ships accepting a low-severity allowlist enumeration residual: POST /api/auth/signup returns 403 for a non-allowlisted email vs 200/400 for an allowlisted one, and the 403 short-circuits before GoTrue, so it is captcha-free and unthrottled: someone with candidate emails can test which are on a brand's allowlist. Not closed because (a) the app deliberately never holds the Turnstile secret (it lives in Supabase/GoTrue; a repo test forbids TURNSTILE_SECRET_KEY in app env), and (b) the clear "you're not invited, go to Accounted" redirect UX inherently reveals the verdict. It leaks membership of guessed emails, not the list, and no ledger/credential data. Follow-up option if it matters later: add signup-endpoint rate limiting. The related fail-OPEN (a brands-table error was read as unbranded, opening invite-only signup during a DB blip) WAS fixed: the gate now returns lookupFailed and both signup routes answer 503.
[2026-08-27] Fortnox asset register import ships behind FORTNOX_ASSET_SCOPES_APPROVED=false: the portal registration does not carry the assets scope yet, so consents stay unchanged and the migration reports assets as skipped (scopesMissing) instead of failing, mirroring the document-scopes pattern.
[2026-08-27] Byrå-team invite acceptance was implemented only in POST /api/team/accept, which the email+password signup flow never reaches before the dashboard (hosted requires email confirmation, so the register page gets no session to run its client-side accept, and the auth callback + onboarding recovery only knew company_invitations). A new byrå admin therefore landed on /onboarding instead of /clients. Fix: one shared server helper acceptPendingTeamInviteByToken (lib/company/pending-invites.ts), called by the route (unchanged HTTP contract), the auth callback (accepts BEFORE landing resolves, so resolveLandingDestination sees the membership and sends admins to /clients; cookie cleared on success), and acceptPendingInviteByToken (onboarding/select-company recovery, tries company then team). hasPendingInviteForEmail now checks both invite tables. No migration.
[2026-08-28] Per-company hiding of standardmallar via new booking_template_hidden table (insert=hide, delete=unhide), not is_active or a library column: system template rows are shared globally, so per-company state must live beside them; hiding is opt-in per company and restorable in settings (user request).
[2026-08-28] AR-PDF minus fix uses ASCII hyphen formatting, not font embedding: registering a Unicode TTF for react-pdf would change the whole document's typography and bundle size to fix one glyph; formatPdfKronor keeps built-in Helvetica and sidesteps WinAnsi's missing U+2212.
@@ -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,
}),
})
@@ -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,
+2 -2
View File
@@ -2241,9 +2241,9 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
PROVIDER_SIE_IMPORT_REQUIRED: {
httpStatus: 409,
message_sv:
'Bokföringsdata (SIE) måste importeras först. Ladda upp en SIE-fil med kontoplan, ingående balanser och verifikationer innan du hämtar kunder, leverantörer och fakturor från den här leverantören.',
'Bokföringsdata (SIE) måste importeras först. Ladda upp en SIE-fil med kontoplan, ingående balanser och verifikationer innan du hämtar kunder, leverantörer, fakturor och anläggningstillgångar från den här leverantören.',
message_en:
'A completed SIE import is required first. Import the SIE file (chart of accounts, opening balances and verifications) before importing customers, suppliers and invoices from this provider.',
'A completed SIE import is required first. Import the SIE file (chart of accounts, opening balances and verifications) before importing customers, suppliers, invoices and fixed assets from this provider.',
},
PROVIDER_MIGRATE_FAILED: {
httpStatus: 500,
@@ -5,6 +5,8 @@ import {
fortnoxConsentScopes,
FORTNOX_DOCUMENT_SCOPES,
FORTNOX_DOCUMENT_SCOPES_APPROVED,
FORTNOX_ASSET_SCOPES,
FORTNOX_ASSET_SCOPES_APPROVED,
} from '../oauth';
describe('Fortnox OAuth scopes', () => {
@@ -47,6 +49,21 @@ describe('Fortnox OAuth scopes', () => {
expect(fortnoxConsentScopes()).not.toContain('connectfile');
});
// The asset register scope is gated on its own portal approval. While the
// flag is false, no consent may request it: an unapproved scope in the
// authorize request is rejected with invalid_scope BEFORE login (the same
// outage mode the document flag above guards against).
it('keeps the asset scope out of every consent until the portal approves it', () => {
expect(FORTNOX_ASSET_SCOPES).toEqual(['assets']);
if (FORTNOX_ASSET_SCOPES_APPROVED) {
expect(fortnoxConsentScopes()).toContain('assets');
expect(fortnoxConsentScopes({ documents: true })).toContain('assets');
} else {
expect(fortnoxConsentScopes()).not.toContain('assets');
expect(fortnoxConsentScopes({ documents: true })).not.toContain('assets');
}
});
// Even once the portal registration lands, opting in must never cost the
// consent its ledger access: the callback overwrites its tokens in place.
it('keeps a document consent a superset of an ordinary one', () => {
+24 -1
View File
@@ -35,6 +35,24 @@ export const FORTNOX_DOCUMENT_SCOPES = ['archive', 'connectfile'];
*/
export const FORTNOX_DOCUMENT_SCOPES_APPROVED: boolean = true;
/** The asset register (anläggningsregistret): what the asset import reads. */
export const FORTNOX_ASSET_SCOPES = ['assets'];
/**
* Whether the registered Fortnox app has the Assets scope (Anläggningsregister)
* enabled in the Fortnox Developer Portal. Ships false until the portal
* registration is confirmed to carry it: requesting a scope the app lacks
* makes the authorize endpoint reject with invalid_scope BEFORE login, the
* same failure mode the document scopes guard against above.
*
* When true, the ordinary connect requests the scope. Unlike Arkivplats and
* Koppla filer, the asset register carries no separate Fortnox customer
* licence, so no per-user opt-in is needed. A consent minted without the
* scope degrades gracefully: the migration reports assets as skipped instead
* of failing (see arcim-migration import-assets).
*/
export const FORTNOX_ASSET_SCOPES_APPROVED: boolean = false;
/**
* The scopes a Fortnox consent is minted with. The document scopes are opt-in
* per authorize call, because Fortnox derives its customer licence
@@ -48,9 +66,14 @@ export const FORTNOX_DOCUMENT_SCOPES_APPROVED: boolean = true;
export function fortnoxConsentScopes(options?: { documents?: boolean }): string[] {
const withDocuments =
options?.documents === true && FORTNOX_DOCUMENT_SCOPES_APPROVED;
return withDocuments
const scopes = withDocuments
? [...BASE_SCOPES, ...FORTNOX_DOCUMENT_SCOPES]
: [...BASE_SCOPES];
// The asset register rides along on every consent once the portal
// registration carries the scope: it needs no extra customer licence, so
// there is nothing to opt in to.
if (FORTNOX_ASSET_SCOPES_APPROVED) scopes.push(...FORTNOX_ASSET_SCOPES);
return scopes;
}
export function buildFortnoxAuthUrl(