From 8b0aa80ea0f217ddeeac6b87d6bd2fb1b00fdcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20Gr=C3=B6nberg?= Date: Mon, 31 Aug 2026 10:25:55 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 1 + .../general/ArcimMigrationWorkspace.tsx | 141 +++--- .../__tests__/migrate-guard.test.ts | 46 ++ extensions/general/arcim-migration/index.ts | 31 +- .../lib/__tests__/import-assets.test.ts | 403 ++++++++++++++++++ .../arcim-migration/lib/import-assets.ts | 395 +++++++++++++++++ .../lib/migration-orchestrator.ts | 25 ++ extensions/general/arcim-migration/types.ts | 26 +- lib/errors/structured-errors.ts | 4 +- lib/providers/fortnox/__tests__/oauth.test.ts | 17 + lib/providers/fortnox/oauth.ts | 25 +- 11 files changed, 1054 insertions(+), 60 deletions(-) create mode 100644 extensions/general/arcim-migration/lib/__tests__/import-assets.test.ts create mode 100644 extensions/general/arcim-migration/lib/import-assets.ts diff --git a/DECISIONS.md b/DECISIONS.md index a1f7024d..04b21b8d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1323,6 +1323,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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[]` 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. diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 03b62780..8940ab9a 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -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({ )} - {/* SIE stats: one quiet statline, the same grammar as the import - reveal, instead of a boxed summary. */} - {preview?.sieAvailable && preview.sieStats && ( -

- {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(', ')}`} -

- )} + {/* 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 ( +

+ {parts.join(' · ')} +

+ ) + })()} {preview && !preview.sieAvailable && !isLoading && preview.hasSieData && (

@@ -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' && ( + toggleOption('importAssets')} + /> + )} {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 = { 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, }), }) diff --git a/extensions/general/arcim-migration/__tests__/migrate-guard.test.ts b/extensions/general/arcim-migration/__tests__/migrate-guard.test.ts index 9c45477b..d06734c5 100644 --- a/extensions/general/arcim-migration/__tests__/migrate-guard.test.ts +++ b/extensions/general/arcim-migration/__tests__/migrate-guard.test.ts @@ -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', diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index f524185f..022dc254 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -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, } diff --git a/extensions/general/arcim-migration/lib/__tests__/import-assets.test.ts b/extensions/general/arcim-migration/lib/__tests__/import-assets.test.ts new file mode 100644 index 00000000..a9053696 --- /dev/null +++ b/extensions/general/arcim-migration/lib/__tests__/import-assets.test.ts @@ -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, + 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 + + 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', + }) + }) +}) diff --git a/extensions/general/arcim-migration/lib/import-assets.ts b/extensions/general/arcim-migration/lib/import-assets.ts new file mode 100644 index 00000000..b28c4e3c --- /dev/null +++ b/extensions/general/arcim-migration/lib/import-assets.ts @@ -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( + 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 { + 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(accessToken, '/assets', 'Assets'), + fortnoxClient.getPaginated(accessToken, '/assets/types', 'Types'), + ]) + } catch (error) { + if (isFortnoxPermissionError(error)) { + throw new FortnoxAssetScopesRequiredError() + } + throw error + } + + const typeById = new Map() + 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, + } +} diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 93fc1be6..d15e3824 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -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 = { 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, diff --git a/lib/providers/fortnox/__tests__/oauth.test.ts b/lib/providers/fortnox/__tests__/oauth.test.ts index 166d11e9..7da28aa9 100644 --- a/lib/providers/fortnox/__tests__/oauth.test.ts +++ b/lib/providers/fortnox/__tests__/oauth.test.ts @@ -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', () => { diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts index 2bd90ffb..8ed15aa9 100644 --- a/lib/providers/fortnox/oauth.ts +++ b/lib/providers/fortnox/oauth.ts @@ -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(