Files
accounted/components/extensions/general/ArcimMigrationWorkspace.tsx
T
Jakob Wennberg a769bc9d03 feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)
* feat(migration): Björn Lundén activation through Lundify's redirect flow

BL issued our integration activation key on 2026-09-07. With
BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i
Lundify": the customer logs in at Lundify, picks the company and accepts
the scopes, and Lundify returns the company's User-Key to our callback as
publicKey with our one-time state echoed as extra. The manual User-Key
field stays as a folded fallback for companies that activated inside
Lundify already.

The callback folds publicKey/extra into the OAuth-shaped locals, so the
atomic state consumption, initiator binding and white-label handoff run
unchanged; only the final step differs: submitProviderToken (the same
client-credentials probe as the manual field) instead of an OAuth code
exchange, owned by the consent's company read from the server-written row.
consumeOAuthState/consumeHandoff now return that company id.

Closes #2323.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L

* fix(migration): reset the previous connect attempt before a new provider request

Review follow-up: a failed /connect used to leave the earlier consent id
and one-time activation URL in place, so the step kept offering a link
that completed the previous consent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:38:07 +02:00

3340 lines
131 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect, useReducer, useRef } from 'react'
import { useAccounts } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { AttnLine } from '@/components/ui/attn-line'
import Link from 'next/link'
import { getBranding } from '@/lib/branding/service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const branding = getBranding()
import {
ArrowLeft,
ArrowRight,
Check,
ChevronRight,
ExternalLink,
Loader2,
RefreshCw,
RotateCcw,
XCircle,
} from 'lucide-react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import {
ARCIM_DOCUMENT_OAUTH_RESUME_KEY,
INITIAL_ARCIM_DOCUMENT_IMPORT_STATE,
ArcimDocumentImportRequestError,
arcimDocumentImportReducer,
documentOAuthProblemFromReason,
parseArcimDocumentOAuthResume,
PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
requestArcimDocumentImport,
runArcimDocumentImportToCompletion,
resolveArcimDocumentFollowUpProvider,
watchArcimOAuthPopup,
type ArcimDocumentImportProblem,
type ArcimDocumentImportState,
} from './arcim-document-import-flow'
type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden' | 'wint'
// `sieViaApi`: the provider serves its general ledger as SIE over the API:
// no manual SIE upload needed. Deliberately duplicated from
// extensions/general/arcim-migration/types.ts (core code must not import from
// @/extensions/: CI enforces it). Keep both lists in sync.
// WINT is env-gated server-side (WINT_MIGRATION_ENABLED): the wizard renders
// whatever GET /providers returns, so no client-side gate is needed here.
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
{ id: 'visma', name: 'Visma', authType: 'oauth', sieViaApi: false },
{ id: 'bokio', name: 'Bokio', authType: 'token', sieViaApi: false },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token', sieViaApi: true },
{ id: 'briox', name: 'Briox', authType: 'token', sieViaApi: true },
{ id: 'wint', name: 'WINT', authType: 'token', sieViaApi: true },
]
/**
* Extract a human-readable message from an API error body. Routes answer in
* two shapes: legacy `{ error: 'text' }` and the structured envelope
* `{ error: { code, message } }`: naively rendering the latter shows
* "[object Object]".
*/
function apiErrorMessage(data: unknown, fallback: string): string {
const err = (data as { error?: unknown } | null)?.error
if (typeof err === 'string' && err) return err
if (err && typeof err === 'object') {
const message = (err as { message?: unknown }).message
if (typeof message === 'string' && message) return message
}
return fallback
}
/**
* Marks an error whose message is already user-facing Swedish (server
* envelopes, ImportResult.errors). The catch blocks must show these
* verbatim: routing them through getErrorMessage would test them against
* its Swedish-pattern heuristic and swallow any miss into the generic
* "Något gick fel. Försök igen.", hiding the real reason the migration
* stopped.
*/
class UserFacingError extends Error {}
/**
* Build the throwable for a failed API response: an extracted server
* message passes through to the UI verbatim, while the technical fallback
* (e.g. "HTTP 500") stays a plain Error so getErrorMessage maps it to a
* friendly message.
*/
function apiError(data: unknown, fallback: string): Error {
const extracted = apiErrorMessage(data, '')
return extracted ? new UserFacingError(extracted) : new Error(fallback)
}
/** Resolve the message a catch block should display. */
function displayError(err: unknown, nonErrorFallback?: string): string {
if (err instanceof UserFacingError) return err.message
if (!(err instanceof Error) && nonErrorFallback) return nonErrorFallback
return getUserErrorMessage(err)
}
function documentImportProblem(error: unknown): ArcimDocumentImportProblem {
if (error instanceof ArcimDocumentImportRequestError) return error.problem
return { code: null, requestId: null, reconnectRequired: false }
}
function storeDocumentOAuthResume(
action: 'discover' | 'import',
): void {
try {
window.sessionStorage.setItem(
ARCIM_DOCUMENT_OAUTH_RESUME_KEY,
action,
)
} catch {
// Full-page recovery is best-effort when browser storage is unavailable.
}
}
function readDocumentOAuthResume() {
try {
return parseArcimDocumentOAuthResume(
window.sessionStorage.getItem(ARCIM_DOCUMENT_OAUTH_RESUME_KEY),
)
} catch {
return null
}
}
function clearDocumentOAuthResume(): void {
try {
window.sessionStorage.removeItem(ARCIM_DOCUMENT_OAUTH_RESUME_KEY)
} catch {
// Nothing else is required when browser storage is unavailable.
}
}
/**
* Read the /migrate NDJSON stream: one JSON object per line. `progress`
* events carry the orchestrator's real step labels and anchors; the stream
* ends with a terminal `done` line (results) or `error` line (the same
* structured envelope the JSON path answers with, thrown here so the catch
* block shows it verbatim).
*/
async function consumeMigrationStream(
body: ReadableStream<Uint8Array>,
onProgress: (currentStep: string | undefined, progress: number) => void,
): Promise<MigrationResults> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let results: MigrationResults | undefined
const handleLine = (line: string) => {
if (!line.trim()) return
let event: {
kind?: string
currentStep?: string
progress?: number
results?: MigrationResults
}
try {
event = JSON.parse(line)
} catch {
return // torn line from an intermediary flush; terminal lines are whole
}
if (event.kind === 'progress' && typeof event.progress === 'number') {
onProgress(
typeof event.currentStep === 'string' && event.currentStep ? event.currentStep : undefined,
event.progress,
)
} else if (event.kind === 'done') {
results = event.results ?? {}
} else if (event.kind === 'error') {
throw apiError(event, 'Migreringen misslyckades.')
}
}
try {
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) handleLine(line)
}
buffer += decoder.decode()
if (buffer) handleLine(buffer)
} finally {
reader.releaseLock()
}
if (!results) {
// The connection dropped before the terminal line. The migration keeps
// running server-side, so a blind retry could double-import.
throw new UserFacingError(
'Anslutningen bröts innan migreringen bekräftades. Ladda om sidan och kontrollera om kunder och fakturor redan har importerats innan du försöker igen.'
)
}
return results
}
/** Pull the structured error `code` from an envelope, if present. */
function apiErrorCode(data: unknown): string | null {
const err = (data as { error?: unknown } | null)?.error
if (err && typeof err === 'object') {
const code = (err as { code?: unknown }).code
if (typeof code === 'string' && code) return code
}
return null
}
// 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'
import {
applyVatTreatmentReview,
applyVatTreatmentReviewAll,
enrichChangedAccountMappingWithVat,
enrichAccountMappingsWithVat,
} from '@/lib/import/account-vat-treatment'
import type { TheaterModel } from '@/lib/import/theater-model'
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
import type { BASAccount } from '@/types'
// ── Types ────────────────────────────────────────────────────────
type WizardStep = 'provider' | 'connect' | 'preview' | 'mapping' | 'options' | 'migrating' | 'result'
const STEPS: WizardStep[] = ['provider', 'connect', 'preview', 'mapping', 'options', 'migrating', 'result']
const STEP_LABELS: Record<WizardStep, string> = {
provider: 'Välj system',
connect: 'Anslut',
preview: 'Förhandsgranskning',
mapping: 'Kontomappning',
options: 'Alternativ',
migrating: 'Migrerar',
result: 'Resultat',
}
interface MigrationOptions {
importCompanyInfo: boolean
importSIEData: boolean
importCustomers: boolean
importSuppliers: boolean
importSalesInvoices: boolean
importSupplierInvoices: boolean
importAssets: boolean
voucherSeries: string
}
const DEFAULT_OPTIONS: MigrationOptions = {
importCompanyInfo: true,
importSIEData: true,
importCustomers: true,
importSuppliers: true,
importSalesInvoices: true,
importSupplierInvoices: true,
importAssets: true,
voucherSeries: 'B',
}
interface PreviewData {
consent: {
id: string
provider: ArcimProvider
status: number
companyName?: string
}
companyInfo: {
company_name: string | null
org_number: string | null
vat_number: string | null
fiscal_year_start_month: number
address_line1: string | null
postal_code: string | null
city: string | null
phone: string | null
email: string | null
} | null
sieAvailable: boolean
sieStats: {
accountCount: number
transactionCount: number
fiscalYears: number[]
} | null
// Every fiscal year the source has, oldest first, with the default
// selection marked: rendered as the year picker so the user chooses
// before the import runs and no year is left out silently (#2211, #2238).
sourceYears?: SourceFiscalYear[]
// The most years one run may select: /sie-data refuses more. Read from
// the server so the picker never drifts from the route.
maxSelectedYears?: number
assetStats: {
total: number
importable: number
} | null
hasSieData: boolean
}
interface SIEFileStatus {
fiscalYear: number
// Legacy field for older builds: read previousImport instead.
alreadyImported: boolean
importedAt: string | null
// New (period-based) detection. When present, this fiscal year already has a
// completed import in Accounted and a re-sync will replace it (cancelling the
// imported journal entries; user-created entries are untouched).
previousImport: {
importedAt: string | null
fiscalYearStart: string | null
fiscalYearEnd: string | null
} | null
}
interface SIEData {
parsed: ParsedSIEFile
mappings: AccountMapping[]
mappingStats: { total: number; mapped: number; unmapped: number }
rawContent: string[]
fileStatuses: SIEFileStatus[]
allImported: boolean
newFileCount: number
replacedFileCount?: number
// Fiscal years whose provider export failed. Importing the remaining years
// anyway leaves an IB/UB gap: the options step warns before proceeding.
failedYears?: { year: number; error: string }[]
// Source fiscal years outside the selection: not fetched, named in the
// result so nobody believes the books are complete (#2211).
omittedYears?: SourceFiscalYear[]
basAccounts: BASAccount[]
}
/**
* A fiscal year as the source reports it. Mirrors SourceFiscalYear in
* extensions/general/arcim-migration/lib/sie-fetcher.ts (deliberate
* duplication: core must not import from @/extensions/).
*/
interface SourceFiscalYear {
year: number
fromDate: string | null
toDate: string | null
inDefaultSelection: boolean
}
/** "2022-09-01 till 2023-12-31" when the provider gave bounds, else the start year. */
function useFiscalYearSpanLabel(): (fy: SourceFiscalYear) => string {
const t = useTranslations('extensions')
return (fy) =>
fy.fromDate && fy.toDate
? t('ext_arcim_fiscal_year_span', { from: fy.fromDate, to: fy.toDate })
: String(fy.year)
}
// ── Shared step chrome ───────────────────────────────────────────
// Living Paper: step content sits directly on the page. The serif headline
// is the step's one display element; sections are kickers over hairline
// rows; attention is one ochre sentence (AttnLine); the SIE escape hatch is
// a quiet underlined link, never a boxed prompt.
function StepHeading({ title, lede }: { title: string; lede?: string }) {
return (
<div>
<h2 className="font-display text-2xl leading-8 tracking-tight text-balance">{title}</h2>
{lede && <p className="mt-2 text-sm text-muted-foreground">{lede}</p>}
</div>
)
}
function SectionKicker({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{children}
</h3>
)
}
function SieFallbackLine({ message, label = 'Ladda upp SIE-fil' }: { message: string; label?: string }) {
return (
<p className="text-[13px] text-muted-foreground">
{message}{' '}
<Link
href="/import?mode=sie"
className="underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground"
>
{label}
</Link>
</p>
)
}
function SpinnerLine({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<p>{children}</p>
</div>
)
}
/**
* The quiet step indicator that replaces the boxed progress card: the step
* labels as an uppercase tracking kicker row (done steps muted with a small
* check, the current step in foreground ink) over a hairline thread whose
* ink segment is the progress. No card, no fat bar.
*/
function StepRail({ steps, currentIndex }: { steps: WizardStep[]; currentIndex: number }) {
const progressPercent = ((currentIndex + 1) / steps.length) * 100
return (
<nav aria-label="Migreringens steg">
<p className="text-[11px] font-medium uppercase tracking-wider sm:hidden">
Steg {currentIndex + 1} av {steps.length}: {STEP_LABELS[steps[currentIndex]]}
</p>
<ol className="hidden flex-wrap items-center gap-x-6 gap-y-1 sm:flex">
{steps.map((s, i) => (
<li
key={s}
aria-current={i === currentIndex ? 'step' : undefined}
className={cn(
'flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors duration-150',
i === currentIndex
? 'text-foreground'
: i < currentIndex
? 'text-muted-foreground'
: 'text-muted-foreground/60'
)}
>
{i < currentIndex && <Check className="h-3 w-3" aria-hidden="true" />}
{STEP_LABELS[s]}
</li>
))}
</ol>
<div className="mt-3 h-px w-full bg-border">
<div
className="h-px bg-foreground transition-[width] duration-300 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
</nav>
)
}
// ── Provider selection step ──────────────────────────────────────
interface ConnectionStatus {
consents: {
id: string
provider: ArcimProvider
status: number
companyName?: string
createdAt?: string
}[]
sieImports: {
id: string
filename: string
status: string
accounts_count: number | null
transactions_count: number | null
company_name: string | null
fiscal_year_start: string | null
fiscal_year_end: string | null
imported_at: string | null
created_at: string
}[]
entityCounts: {
customers: number
suppliers: number
invoices: number
}
}
// Providers listed here render as a disabled "Kommer snart" card. WINT was
// the last entry: it is released now, so the set is empty. WINT still depends
// on WINT_MIGRATION_ENABLED=true, the server-side /connect gate.
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>([])
const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
fortnox: '/logos/fortnox.svg',
visma: '/logos/visma.jpeg',
bokio: '/logos/bokio.png',
bjornlunden: '/logos/bjornlunden.png',
briox: '/logos/Briox_logo.png',
wint: '/logos/wint.png',
}
function ProviderStep({
onSelect,
onResync,
onDisconnect,
connectionStatus,
isLoadingStatus,
}: {
onSelect: (provider: ArcimProvider) => void
onResync: (provider: ArcimProvider, consentId: string) => void
onDisconnect: (consentId: string) => void
connectionStatus: ConnectionStatus | null
isLoadingStatus: boolean
}) {
const activeConsents = connectionStatus?.consents.filter(c => c.status === 1) ?? []
const hasSieImport = (connectionStatus?.sieImports.filter(i => i.status === 'completed').length ?? 0) > 0
const sieViaApi = (id: ArcimProvider) => ARCIM_PROVIDERS.find(p => p.id === id)?.sieViaApi === true
const allSieViaApi = activeConsents.length > 0 && activeConsents.every(c => sieViaApi(c.provider))
const showSieRequiredBanner = !isLoadingStatus && !hasSieImport && !allSieViaApi
return (
<div className="stagger-enter space-y-8">
<StepHeading
title={activeConsents.length > 0 ? 'Anslut ytterligare system' : 'Välj ditt nuvarande bokföringssystem'}
lede="Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et."
/>
{/* SIE-required attention (not relevant for Fortnox/Briox: they fetch
SIE via API): one ochre sentence with the action embedded, never a
banner. */}
{showSieRequiredBanner && (
<AttnLine action={{ label: 'Ladda upp SIE-fil', href: '/import?mode=sie' }}>
Bokio och Visma hämtar endast kunder, leverantörer och fakturor via API:et: importera
bokföringsdatan (kontoplan, verifikationer och balanser) via SIE-fil först. Gäller inte
Fortnox, Briox, Björn Lundén och WINT, där hämtas bokföringen direkt via API:et.
</AttnLine>
)}
{/* Existing connections: quiet hairline rows, no cards. Being connected
is the normal state here, so it reads as muted text, not a chip. */}
{activeConsents.length > 0 && (
<section className="space-y-3">
<SectionKicker>Aktiva anslutningar</SectionKicker>
<div className="stagger-enter divide-y divide-border" data-no-stagger>
{activeConsents.map((consent) => {
const providerInfo = ARCIM_PROVIDERS.find(p => p.id === consent.provider)
const completedImports = connectionStatus?.sieImports.filter(i => i.status === 'completed') ?? []
const lastImport = completedImports[0]
return (
<div key={consent.id} className="flex flex-wrap items-center gap-3 py-3 sm:flex-nowrap sm:gap-4">
<img
src={PROVIDER_LOGOS[consent.provider]}
alt={providerInfo?.name ?? consent.provider}
className="h-8 w-8 shrink-0 rounded-sm object-contain"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{providerInfo?.name ?? consent.provider}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{consent.companyName && <>{consent.companyName} · </>}
{lastImport ? (
<>
Senaste import {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')}
{lastImport.transactions_count != null && (
<span className="tabular-nums">, {lastImport.transactions_count} verifikationer</span>
)}
</>
) : (
<>Ansluten {consent.createdAt ? new Date(consent.createdAt).toLocaleDateString('sv-SE') : ''}</>
)}
</p>
{(connectionStatus?.entityCounts.customers ?? 0) > 0 && (
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
{connectionStatus?.entityCounts.customers} kunder, {connectionStatus?.entityCounts.suppliers} leverantörer, {connectionStatus?.entityCounts.invoices} fakturor
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onResync(consent.provider, consent.id)}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Synka igen
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
aria-label={`Koppla från ${providerInfo?.name ?? consent.provider}`}
onClick={() => onDisconnect(consent.id)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
})}
</div>
</section>
)}
{/* Provider selection: quiet list rows on the page, hairline-divided. */}
{isLoadingStatus ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="stagger-enter divide-y divide-border" data-no-stagger>
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
// Providers without SIE-over-API only expose entity data
// (customers, suppliers, invoices): the ledger must arrive via
// SIE upload first. Gate the connection entry until a completed
// SIE import exists so users don't authenticate into a flow that
// can't import anything yet. The /migrate route enforces this
// server-side regardless; this is just the matching UX.
const needsSieFirst = !hasSieImport && !provider.sieViaApi
const isDisabled = comingSoon || alreadyConnected || needsSieFirst
return (
<button
key={provider.id}
type="button"
disabled={isDisabled}
className={cn(
'group flex w-full items-center gap-4 py-3 text-left transition-colors duration-150',
isDisabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-secondary/35'
)}
onClick={() => !isDisabled && onSelect(provider.id)}
>
<img
src={PROVIDER_LOGOS[provider.id]}
alt={provider.name}
className="h-8 w-8 shrink-0 rounded-sm object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{provider.name}</p>
{comingSoon && (
<Badge variant="secondary">Kommer snart</Badge>
)}
{alreadyConnected && (
<Badge variant="success">Ansluten</Badge>
)}
{needsSieFirst && !comingSoon && !alreadyConnected && (
<Badge variant="warning">SIE krävs först</Badge>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{alreadyConnected
? 'Använd "Synka igen" ovan'
: needsSieFirst
? 'Importera SIE-fil först'
: provider.authType === 'oauth'
? 'Anslut via inloggning'
: provider.id === 'bjornlunden'
? 'Anslut med företagsnyckel'
: 'Anslut med API-nyckel'}
</p>
</div>
{!isDisabled && (
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground"
aria-hidden="true"
/>
)}
</button>
)
})}
</div>
)}
</div>
)
}
// ── Connect step (OAuth redirect or token input) ────────────────
function ConnectStep({
provider,
authType,
isLoading,
error,
authUrl,
activationUrl,
consentId,
onTokenSubmit,
onBack,
}: {
provider: ArcimProvider
authType: 'oauth' | 'token' | null
isLoading: boolean
error: string | null
authUrl: string | null
/** Björn Lundén only: Lundify's activation redirect, when BL issued us a key. */
activationUrl: string | null
consentId: string | null
onTokenSubmit: (apiToken: string, companyId: string) => void
onBack: () => void
}) {
const t = useTranslations('extensions')
const providerName = ARCIM_PROVIDERS.find(p => p.id === provider)?.name ?? provider
const [apiToken, setApiToken] = useState('')
const [companyId, setCompanyId] = useState('')
// With the Lundify redirect on offer, the User-Key field is the fallback for
// a customer who activated inside Lundify already, so it starts folded.
const [showManualKey, setShowManualKey] = useState(false)
// BL uses server-side client credentials: only needs company ID, no API key
const isClientCredentials = provider === 'bjornlunden'
const hasLundifyActivation = isClientCredentials && !!activationUrl
const manualKeyVisible = !hasLundifyActivation || showManualKey
const openProviderWindow = (url: string) => {
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open(url, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
if (!popup) {
// Popup blocked: with the return value discarded, a blocked
// popup looked exactly like a successful one (nothing opens,
// nothing is said, the user clicks again). Fall back to the
// full-page flow instead. The callback already supports it:
// with no window.opener it redirects to
// /import?migration=connected&consentId=..., which
// handleOAuthReturn consumes and resumes the wizard at the
// preview step. Same treatment as SkatteverketConnectPanel.
window.location.href = url
}
}
// WINT has no API keys: the "token" is the user's WINT login (e-post +
// lösenord), exchanged server-side for ett tokenpar; lösenordet sparas aldrig.
const isWintLogin = provider === 'wint'
const needsApiToken = !isClientCredentials
// Briox: the account ID is the `clientid` half of the token exchange;
// WINT reuses the same field for the login e-mail.
const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox' || provider === 'wint'
const companyIdLabel = provider === 'briox'
? 'Konto-ID'
: provider === 'bjornlunden'
? 'Företagsnyckel (User-Key)'
: provider === 'wint'
? 'E-postadress'
: provider === 'bokio'
? t('ext_arcim_bokio_company_id_label')
: 'Företags-ID'
const tokenDescription = hasLundifyActivation
? t('ext_arcim_bl_activate_description', { appName: branding.appName })
: isClientCredentials
? t('ext_arcim_bl_token_description', { appName: branding.appName })
: isWintLogin
? `Logga in med dina WINT-uppgifter för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata. Lösenordet används en gång för att skapa anslutningen och sparas aldrig.`
: provider === 'briox'
? `Ange ditt konto-ID och din applikationstoken från Briox för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
: provider === 'bokio'
? t('ext_arcim_bokio_token_description', {
appName: branding.appName.toLowerCase(),
})
: `Ange din API-nyckel från ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
const tokenHelpText = isClientCredentials
? t('ext_arcim_bl_token_help')
: isWintLogin
? `Använd samma e-postadress och lösenord som när du loggar in på app.wint.se. Kräver ditt WINT-konto BankID-inloggning kan anslutningen inte skapas ännu: be i så fall WINT om en SIE-fil och importera den manuellt.`
: provider === 'bokio'
? t('ext_arcim_bokio_token_help')
: provider === 'briox'
? `Skapa din applikationstoken i Briox under Admin \u2192 Anv\u00e4ndare \u2192 kugghjulet vid din anv\u00e4ndare \u2192 Applikationstoken. Ditt konto-ID \u00e4r det l\u00e5nga numret inom parentes bredvid f\u00f6retagsnamnet under "Ditt konto" i menyn till h\u00f6ger.`
: `Du hittar din applikationstoken i ${providerName} under Administration \u2192 Integrationer.`
const canSubmit = isClientCredentials
? !!companyId
: !!(apiToken && (!needsCompanyId || companyId))
return (
<div className="stagger-enter space-y-8">
<StepHeading
title={`Anslut till ${providerName}`}
lede={authType === 'token'
? tokenDescription
: `Logga in i ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`}
/>
{isLoading && <SpinnerLine>Förbereder anslutning...</SpinnerLine>}
{error && (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Anslutning misslyckades</p>
<p className="text-sm text-muted-foreground">{error}</p>
{provider === 'fortnox' && (
<p className="text-sm text-muted-foreground">
Obs: Fortnox kräver ett aktivt integrationstillägg (tillkostnadsbelagd tilläggstjänst) för att kunna använda integrationer. Kontrollera att detta är aktiverat i ditt Fortnox-konto.
</p>
)}
</div>
<SieFallbackLine message="Du kan också importera din bokföringsdata manuellt via en SIE-fil." />
</div>
)}
{/* OAuth flow */}
{authType === 'oauth' && authUrl && !isLoading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Klicka nedan för att logga in i {providerName}.
Fönstret stängs automatiskt när du är klar.
</p>
<Button className="min-h-11" onClick={() => openProviderWindow(authUrl)}>
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
</div>
)}
{/* Björn Lundén: Lundify's activation redirect returns the User-Key
itself. The popup posts arcim-oauth-success like the OAuth
providers, so the same listener resumes the wizard. */}
{authType === 'token' && consentId && !isLoading && hasLundifyActivation && activationUrl && (
<div className="space-y-4">
<Button className="min-h-11" onClick={() => openProviderWindow(activationUrl)}>
{t('ext_arcim_bl_activate_button')}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
{!showManualKey && (
<Button
variant="link"
className="h-auto px-0 text-sm text-muted-foreground"
onClick={() => setShowManualKey(true)}
>
{t('ext_arcim_bl_manual_key_toggle')}
</Button>
)}
</div>
)}
{/* Token-based flow */}
{authType === 'token' && consentId && !isLoading && manualKeyVisible && (
<div className="max-w-md space-y-4">
<p className="text-sm text-muted-foreground">
{tokenHelpText}
</p>
{/* WINT is a login form: e-mail reads above password (CSS order;
the button keeps its place). Other token providers keep
token-first order. */}
<div className={cn('space-y-3', isWintLogin && 'flex flex-col gap-3 space-y-0')}>
{needsApiToken && (
<div className={cn(isWintLogin && 'order-2')}>
<label htmlFor="apiToken" className="text-sm font-medium">
{provider === 'briox'
? 'Applikationstoken'
: isWintLogin
? 'Lösenord'
: provider === 'bokio'
? t('ext_arcim_bokio_token_label')
: 'API-nyckel'}
</label>
<Input
id="apiToken"
name="apiToken_nocomplete"
type="password"
autoComplete="new-password"
placeholder={
provider === 'briox'
? 'Klistra in din applikationstoken'
: isWintLogin
? 'Ditt lösenord hos WINT'
: provider === 'bokio'
? t('ext_arcim_bokio_token_placeholder')
: 'Klistra in din API-nyckel'
}
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
/>
</div>
)}
{needsCompanyId && (
<div className={cn(isWintLogin && 'order-1')}>
<label htmlFor="companyId" className="text-sm font-medium">
{companyIdLabel}
</label>
<Input
id="companyId"
name="companyId_nocomplete"
type={isWintLogin ? 'email' : 'text'}
autoComplete="new-password"
placeholder={
isClientCredentials
? 'Företagsnyckel, t.ex. 1f0e2d3c-4b5a-...'
: provider === 'briox'
? 'Det långa numret inom parentes, t.ex. 35649125'
: isWintLogin
? 'namn@foretaget.se'
: 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'
}
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
/>
</div>
)}
<Button
className={cn('min-h-11', isWintLogin && 'order-3')}
onClick={() => onTokenSubmit(apiToken, companyId)}
disabled={!canSubmit}
>
Anslut
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="flex border-t border-border pt-6">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
</div>
)
}
// ── Preview step ────────────────────────────────────────────────
function PreviewStep({
preview,
isLoading,
error,
authExpired,
licenseMissing,
selectedYears,
onSelectedYearsChange,
onReconnect,
onContinue,
onBack,
}: {
preview: PreviewData | null
isLoading: boolean
error: string | null
authExpired: boolean
licenseMissing: boolean
/** Fiscal years (start years) ticked in the picker; default = the three latest. */
selectedYears: number[]
onSelectedYearsChange: (years: number[]) => void
onReconnect: () => void
onContinue: () => void
onBack: () => void
}) {
const t = useTranslations('extensions')
const fiscalYearSpanLabel = useFiscalYearSpanLabel()
const providerName = preview
? ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? preview.consent.provider
: ''
const sourceYears = preview?.sieAvailable ? preview.sourceYears ?? [] : []
const showYearPicker = sourceYears.length > 0 && !isLoading
const noYearSelected = showYearPicker && selectedYears.length === 0
const maxSelectable = preview?.maxSelectedYears ?? null
const tooManySelected = showYearPicker && maxSelectable != null && selectedYears.length > maxSelectable
const toggleYear = (year: number) => {
onSelectedYearsChange(
selectedYears.includes(year)
? selectedYears.filter((y) => y !== year)
: [...selectedYears, year].sort((a, b) => a - b),
)
}
return (
<div className="stagger-enter space-y-8">
<div>
<h2 className="font-display text-2xl leading-8 tracking-tight text-balance">
{preview ? `Anslutet till ${providerName}` : 'Förhandsgranskning'}
</h2>
{isLoading && (
<div className="mt-3">
<SpinnerLine>Hämtar bokföringsdata...</SpinnerLine>
</div>
)}
{/* 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">
Bokföringsdatan är redan importerad via SIE-fil. Du kan fortsätta med att importera
kunder, leverantörer och fakturor.
</p>
)}
</div>
{/* ── The year picker (issues #2211, #2238) ──
Every fiscal year the source has, as hairline rows with a checkbox.
The three latest are ticked by default (the limit that used to be a
silent cap); older years are the user's own choice and their own
wait: each one is another SIE export fetched in the next step. */}
{showYearPicker && (
<section className="space-y-3">
<SectionKicker>{t('ext_arcim_year_select_kicker')}</SectionKicker>
<p className="text-[13px] text-muted-foreground">{t('ext_arcim_year_select_lede')}</p>
<div className="stagger-enter divide-y divide-border" data-no-stagger>
{sourceYears.map((fy) => {
const id = `arcim-year-${fy.year}-${fy.fromDate ?? ''}`
return (
<label
key={id}
htmlFor={id}
className="flex min-h-10 cursor-pointer items-center gap-3 py-2 text-sm transition-colors duration-150 hover:bg-secondary/35"
>
<Checkbox
id={id}
checked={selectedYears.includes(fy.year)}
onCheckedChange={() => toggleYear(fy.year)}
aria-label={fiscalYearSpanLabel(fy)}
/>
<span className="tabular-nums">{fiscalYearSpanLabel(fy)}</span>
{!fy.inDefaultSelection && (
<span className="text-xs text-muted-foreground">{t('ext_arcim_year_select_older')}</span>
)}
</label>
)
})}
</div>
{noYearSelected && <AttnLine>{t('ext_arcim_year_select_none')}</AttnLine>}
{tooManySelected && maxSelectable != null && (
<AttnLine>{t('ext_arcim_year_select_too_many', { max: maxSelectable })}</AttnLine>
)}
</section>
)}
{error && (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Kunde inte hämta bokföringsdata</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
{authExpired && (
<Button size="sm" className="min-h-9" onClick={onReconnect} disabled={isLoading}>
<RotateCcw className="mr-2 h-4 w-4" />
Återanslut {providerName}
</Button>
)}
{/* License-missing keeps the SIE fallback visible: re-auth loops
until the customer re-orders the Fortnox Integration license,
so a manual SIE import is the reliable escape hatch. */}
{(!authExpired || licenseMissing) && (
<SieFallbackLine message="Du kan också importera din bokföringsdata manuellt via en SIE-fil." />
)}
</div>
)}
{preview && !preview.sieAvailable && !isLoading && !preview.hasSieData && (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">SIE-import krävs</p>
<p className="text-sm text-muted-foreground">
Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil innan kunder, leverantörer och fakturor kan hämtas. Exportera en SIE-fil från {ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? 'ditt bokföringssystem'} och ladda upp den i {branding.appName.toLowerCase()}.
</p>
</div>
<SieFallbackLine message="När filen är exporterad:" label="Gå till SIE-importen" />
</div>
)}
<div className="flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
className="min-h-11"
onClick={onContinue}
disabled={isLoading || noYearSelected || tooManySelected || (!!preview && !preview.sieAvailable && !preview.hasSieData)}
>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
// ── Mapping step (wraps AccountMappingStep) ─────────────────────
function MappingStep({
sieData,
isLoading,
error,
errorDetails,
onMappingChange,
onVatTreatmentChange,
onConfirmAllVatTreatments,
onContinue,
onBack,
}: {
sieData: SIEData | null
isLoading: boolean
error: string | null
errorDetails: string[] | null
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => void
onConfirmAllVatTreatments: () => void
onContinue: () => void
onBack: () => void
}) {
if (isLoading) {
return <SpinnerLine>Analyserar bokföringsdata och förbereder kontomappning...</SpinnerLine>
}
if (error) {
return (
<div className="stagger-enter space-y-8">
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Kunde inte ladda SIE-data</p>
<p className="text-sm text-muted-foreground">{error}</p>
{errorDetails && errorDetails.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-4 text-sm text-muted-foreground">
{errorDetails.slice(0, 8).map((detail, i) => (
<li key={i} className="break-words">{detail}</li>
))}
{errorDetails.length > 8 && (
<li> och {errorDetails.length - 8} fel till</li>
)}
</ul>
)}
</div>
<SieFallbackLine message="Om problemet kvarstår kan du importera din SIE-fil manuellt istället." />
<div className="flex border-t border-border pt-6">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
</div>
)
}
if (!sieData) return null
return (
<AccountMappingStep
mappings={sieData.mappings}
basAccounts={sieData.basAccounts}
onMappingChange={onMappingChange}
onVatTreatmentChange={onVatTreatmentChange}
onConfirmAllVatTreatments={onConfirmAllVatTreatments}
onContinue={onContinue}
onBack={onBack}
/>
)
}
// ── Options step ────────────────────────────────────────────────
function OptionsStep({
options,
sieAvailable,
sieData,
hasSieData,
provider,
onChange,
onStart,
onBack,
}: {
options: MigrationOptions
sieAvailable: boolean
sieData: SIEData | null
/** The company already has a completed SIE import (any origin). */
hasSieData: boolean
provider: ArcimProvider | null
onChange: (options: MigrationOptions) => void
onStart: () => void
onBack: () => void
}) {
const t = useTranslations('extensions')
const [showConfirm, setShowConfirm] = useState(false)
const toggleOption = (key: keyof MigrationOptions) => {
onChange({ ...options, [key]: !options[key] })
}
const fileStatuses = sieData?.fileStatuses ?? []
const newFileCount = sieData?.newFileCount ?? 0
const replacedFileCount = fileStatuses.filter(fs => fs.previousImport).length
const yearsToReplace = fileStatuses
.filter(fs => fs.previousImport)
.map(fs => fs.fiscalYear)
const failedYears = sieData?.failedYears ?? []
const selectedItems: string[] = []
if (options.importCompanyInfo) selectedItems.push('Företagsinformation')
if (sieAvailable && options.importSIEData) selectedItems.push('Bokföringsdata (SIE)')
if (options.importCustomers) selectedItems.push('Kunder')
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
// SIE import exists. Say so here, before the run, when the user has
// unchecked SIE for a company that has never imported it (#2000).
// Company info (name, org number, VAT number) writes no ledger data and is
// not gated, matching the route.
const hasApiImport = options.importCustomers ||
options.importSuppliers ||
options.importSalesInvoices ||
options.importSupplierInvoices
const sieRequiredButUnchecked = sieAvailable && !options.importSIEData && !hasSieData && hasApiImport
return (
<div className="stagger-enter space-y-8">
<StepHeading
title="Vad vill du importera?"
lede="Bokföringsdata importeras via SIE-fil. Kunder, leverantörer och fakturor hämtas via API:et."
/>
{/* Years whose provider export failed: must be visible before the user
proceeds, otherwise an IB/UB gap slips through. One ochre sentence.
Yields to the SIE-required line below: with SIE unchecked no year is
imported, and the page carries at most one attn line. */}
{sieAvailable && failedYears.length > 0 && !sieRequiredButUnchecked && (
<AttnLine>
{failedYears.length === 1
? `Räkenskapsår ${failedYears[0].year} kunde inte hämtas från källsystemet: om du fortsätter importeras övriga år, men ingående och utgående balanser kan sakna kontinuitet. Försök igen senare eller ladda upp en SIE-fil för det saknade året manuellt.`
: `Räkenskapsår ${failedYears.map(f => f.year).join(', ')} kunde inte hämtas från källsystemet: om du fortsätter importeras övriga år, men ingående och utgående balanser kan sakna kontinuitet. Försök igen senare eller ladda upp SIE-filer för de saknade åren manuellt.`}
</AttnLine>
)}
{/* Clean hairline rows with the toggle on the right: no bordered box
per row, no nested boxes. */}
<div className="stagger-enter divide-y divide-border" data-no-stagger>
<OptionRow
label="Företagsinformation"
description="Namn, organisationsnummer, adress"
checked={options.importCompanyInfo}
onChange={() => toggleOption('importCompanyInfo')}
/>
{sieAvailable && (
<div>
<OptionRow
label="Bokföringsdata (SIE)"
description={
replacedFileCount > 0 && newFileCount > 0
? `${newFileCount} nya och ${replacedFileCount} uppdaterade räkenskapsår`
: replacedFileCount > 0
? `${replacedFileCount} räkenskapsår med uppdaterad data: tidigare import ersätts`
: newFileCount > 0
? `${newFileCount} ny(a) räkenskapsår att importera`
: 'Kontoplan, ingående balanser och verifikationer'
}
checked={options.importSIEData}
onChange={() => toggleOption('importSIEData')}
/>
{/* Per-file import status: quiet muted lines. */}
{fileStatuses.length > 0 && (
<div className="space-y-1 pb-3">
{fileStatuses.map((fs) => (
<p key={fs.fiscalYear} className="text-xs text-muted-foreground tabular-nums">
{fs.previousImport
? `Räkenskapsår ${fs.fiscalYear}: ersätter tidigare import${
fs.previousImport.importedAt
? ` från ${new Date(fs.previousImport.importedAt).toLocaleDateString('sv-SE')}`
: ''
}`
: `Räkenskapsår ${fs.fiscalYear}: ny data att importera`}
</p>
))}
</div>
)}
{/* Verifikationsserie: one aligned row, not a nested box. */}
{options.importSIEData && (
<div className="flex items-center gap-3 border-t border-border py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Verifikationsserie</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t('ext_arcim_option_series_help')}</p>
</div>
<Input
className="w-16 text-center"
aria-label="Verifikationsserie"
value={options.voucherSeries}
onChange={(e) => onChange({ ...options, voucherSeries: e.target.value.toUpperCase() || 'B' })}
maxLength={2}
/>
</div>
)}
</div>
)}
<OptionRow
label="Kunder"
description="Kund-register med kontaktuppgifter"
checked={options.importCustomers}
onChange={() => toggleOption('importCustomers')}
/>
<OptionRow
label="Leverantörer"
description="Leverantör-register med bankuppgifter"
checked={options.importSuppliers}
onChange={() => toggleOption('importSuppliers')}
/>
<OptionRow
label="Kundfakturor"
description="Alla kundfakturor (betalda och obetalda)"
checked={options.importSalesInvoices}
onChange={() => toggleOption('importSalesInvoices')}
/>
<OptionRow
label="Leverantörsfakturor"
description={provider === 'fortnox'
? 'Endast obetalda leverantörsfakturor hämtas. Historiska betalda fakturor finns kvar i Fortnox.'
: 'Alla leverantörsfakturor (betalda och obetalda)'}
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 && (
<AttnLine>{t('ext_arcim_option_sie_required_hint')}</AttnLine>
)}
<div className="flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button className="min-h-11" onClick={() => setShowConfirm(true)} disabled={selectedItems.length === 0 || sieRequiredButUnchecked}>
Starta migrering
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<ConfirmationDialog
open={showConfirm}
onOpenChange={setShowConfirm}
onConfirm={() => {
setShowConfirm(false)
onStart()
}}
isSubmitting={false}
title="Starta migrering"
warningText="Se till att ingen annan import pågår."
confirmLabel="Starta migrering"
>
{/* One sentence naming what happens, the selection as a compact muted
line list; the ochre caution above the actions is the dialog's
only colored element. */}
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm">
Det här hämtas från källsystemet och importeras till {branding.appName.toLowerCase()}:
</p>
<ul className="space-y-1">
{selectedItems.map((item) => (
<li key={item} className="text-sm text-muted-foreground">{item}</li>
))}
</ul>
</div>
{options.importSIEData && yearsToReplace.length > 0 && (
<p className="text-sm text-muted-foreground">
{yearsToReplace.length === 1
? `Räkenskapsår ${yearsToReplace[0]} ersätts:`
: `Räkenskapsår ${yearsToReplace.join(', ')} ersätts:`}{' '}
tidigare importerade verifikationer markeras som annullerade och ersätts av
uppdaterad data från källsystemet. Verifikationer som du själv skapat i {branding.appName.toLowerCase()}{' '}
(kategoriserade banktransaktioner, fakturor m.m.) påverkas inte.
</p>
)}
</div>
</ConfirmationDialog>
</div>
)
}
function OptionRow({
label,
description,
checked,
onChange,
disabled,
}: {
label: string
description: string
checked: boolean
onChange: () => void
disabled?: boolean
}) {
return (
<div
className={cn(
'flex items-center gap-4 py-3 transition-colors duration-150',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-secondary/35'
)}
onClick={() => !disabled && onChange()}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={() => !disabled && onChange()}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
/>
</div>
)
}
// ── Migrating step (progress) ───────────────────────────────────
function MigratingStep({ currentStep, progress }: { currentStep: string; progress: number }) {
return (
<div className="stagger-enter space-y-8">
<StepHeading
title="Migrering pågår"
lede="Vänta medan vi hämtar och importerar din bokföringsdata. Det kan ta några minuter."
/>
<div className="max-w-md space-y-3">
<div className="flex items-center justify-between gap-4 text-xs text-muted-foreground">
<span className="flex min-w-0 items-center gap-2">
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden="true" />
<span className="truncate" role="status" aria-live="polite">{currentStep}</span>
</span>
<span className="shrink-0 tabular-nums">{progress}%</span>
</div>
<Progress value={progress} className="h-1" />
</div>
</div>
)
}
// ── Result step ─────────────────────────────────────────────────
/** Format a fiscal year label from ISO dates, e.g. "2024-01-01" → "2024" or "2024/2025" */
function formatFiscalYearLabel(start: string, end: string): string {
const startYear = start.slice(0, 4)
const endYear = end.slice(0, 4)
return startYear === endYear ? startYear : `${startYear}/${endYear}`
}
/** Determine the overall status icon and color for a single FY import */
function getFYStatus(r: ImportResult): { icon: 'success' | 'warning' | 'error'; label: string } {
if (r.errors.length > 0 && r.journalEntriesCreated === 0) {
return { icon: 'error', label: 'Misslyckades' }
}
if (r.errors.length > 0 || (r.details?.skippedVouchers && r.details.skippedVouchers.total > 0)) {
return { icon: 'warning', label: 'Delvis importerad' }
}
if (r.details?.untransferredResults && r.details.untransferredResults.length > 0) {
return { icon: 'warning', label: 'Importerad med varning' }
}
return { icon: 'success', label: 'Importerad' }
}
/** Compose the opening-balance adjustment into one quiet sentence. */
function openingBalanceSentence(ob: NonNullable<NonNullable<ImportResult['details']>['openingBalance']>): string {
const amount = `${Math.abs(ob.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK`
if (ob.explanation === 'unallocated_result') {
return `Ingående balanser justerade: differens på ${amount} bokförd på konto ${ob.bookedToAccount}, troligen för att föregående års resultat inte allokerats till eget kapital i källsystemet (vanligt vid byte av bokföringsprogram).`
}
if (ob.explanation === 'excluded_accounts') {
return `Ingående balanser justerade: exkluderade systemkonton (t.ex. Fortnox 0099) hade ingående saldon, differensen (${amount}) bokförd på konto ${ob.bookedToAccount}.`
}
if (ob.explanation === 'rounding') {
return `Ingående balanser justerade: avrundningsdifferens (${amount}) bokförd på konto ${ob.bookedToAccount}.`
}
return `Ingående balanser justerade: differens på ${amount} bokförd på konto ${ob.bookedToAccount}.`
}
/**
* Per-fiscal-year outcome as a line: year, count, status. Warnings are one
* ochre sentence each (AttnLine), neutral adjustments are quiet muted lines,
* errors keep strong color. Nothing folds away, nothing gets a box.
*/
function FiscalYearLine({ result, index }: { result: ImportResult; index: number }) {
const t = useTranslations('extensions')
const status = getFYStatus(result)
const d = result.details
const fyLabel = d?.fiscalYear
? formatFiscalYearLabel(d.fiscalYear.start, d.fiscalYear.end)
: `Räkenskapsår ${index + 1}`
// One ochre sentence per warning (the #1562 idiom).
const warningSentences: string[] = []
if (d?.skippedVouchers && d.skippedVouchers.total > 0) {
const parts: string[] = []
if (d.skippedVouchers.empty > 0) parts.push(`${d.skippedVouchers.empty} tomma`)
if (d.skippedVouchers.unbalanced > 0) parts.push(`${d.skippedVouchers.unbalanced} obalanserade`)
if (d.skippedVouchers.singleLine > 0) parts.push(`${d.skippedVouchers.singleLine} enradiga`)
if (d.skippedVouchers.unmapped > 0) {
// Name the accounts (issue #2212): a count alone sends the user to diff
// the general ledger against the source system by hand.
const perAccount = (d.skippedVouchers.unmappedAccounts ?? [])
.map((a) => `konto ${a.account}: ${a.vouchers}`)
.join(', ')
parts.push(
`${d.skippedVouchers.unmapped} med ej kopplade konton${perAccount ? ` (${perAccount})` : ''}`
)
}
warningSentences.push(
`${d.skippedVouchers.total} verifikationer hoppades över (${parts.join(', ')}): saldon har justerats automatiskt via omföringsverifikation.`
)
}
if (d?.untransferredResults && d.untransferredResults.length > 0) {
for (const u of d.untransferredResults) {
warningSentences.push(
`${u.period_name}: årets resultat på ${u.pl_net.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK är inte omfört till eget kapital, senare års balansräkning visar en differens tills omföringen bokförs (konto 8999 mot t.ex. 2099).`
)
}
}
// Remaining raw warnings; strings covered by the sentences above are
// filtered out.
warningSentences.push(
...result.warnings.filter(
(w) =>
!(d?.skippedVouchers && d.skippedVouchers.total > 0 && w.includes('hoppades över')) &&
!(d?.untransferredResults && d.untransferredResults.length > 0 && w.includes('förts om till eget kapital'))
)
)
const infoLines: string[] = []
// Accounts the import inserted into the chart (self-mapped source accounts
// the company did not have). Said out loud so "did the import go right?"
// has an answer on screen instead of in a chart-of-accounts diff.
if (result.accountsCreated && result.accountsCreated > 0) {
infoLines.push(t('ext_arcim_accounts_created', { count: result.accountsCreated }))
}
if (d?.openingBalance) infoLines.push(openingBalanceSentence(d.openingBalance))
if (d?.migrationAdjustment?.created) {
infoLines.push(
`Omföringsverifikation skapad: ${d.migrationAdjustment.accountsAdjusted} konton justerade så att balansräkning och resultaträkning matchar källsystemet.`
)
}
if (d && d.retriedBatches > 0 && d.failedBatches === 0) {
infoLines.push(`${d.retriedBatches} ${d.retriedBatches === 1 ? 'batch' : 'batcher'} behövde omförsök.`)
}
return (
<div className="py-3">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span className="text-sm font-medium tabular-nums">{fyLabel}</span>
<span className="text-sm text-muted-foreground tabular-nums">
{result.journalEntriesCreated.toLocaleString('sv-SE')} verifikationer
{result.replacedPriorImport && result.replacedPriorImport.deletedEntries > 0 && (
<> · ersatte {result.replacedPriorImport.deletedEntries.toLocaleString('sv-SE')} tidigare importerade</>
)}
</span>
<span
className={cn(
'ml-auto text-xs',
status.icon === 'error'
? 'font-medium text-destructive'
: status.icon === 'warning'
? 'text-attn'
: 'text-muted-foreground'
)}
>
{status.label}
</span>
</div>
{result.errors.length > 0 && (
<div className="mt-1 space-y-1">
{result.errors.map((e, i) => (
<p key={i} className="text-sm text-destructive">{e}</p>
))}
</div>
)}
{warningSentences.map((w, i) => (
<AttnLine key={i} className="mt-1">{w}</AttnLine>
))}
{infoLines.map((l, i) => (
<p key={i} className="mt-1 text-[12.5px] leading-5 text-muted-foreground">{l}</p>
))}
{d && d.failedBatches > 0 && (
<p className="mt-1 text-[12.5px] leading-5 text-destructive">
{d.retriedBatches} {d.retriedBatches === 1 ? 'batch' : 'batcher'} behövde omförsök, {d.failedBatches} misslyckades trots omförsök.
</p>
)}
</div>
)
}
function DocumentImportFollowUp({
state,
onDiscover,
onImport,
onDismiss,
onReconnect,
}: {
state: ArcimDocumentImportState
onDiscover: () => void
onImport: () => void
onDismiss: () => void
onReconnect: () => void
}) {
const t = useTranslations('extensions')
if (state.phase === 'hidden' || state.phase === 'dismissed') return null
const title = <SectionKicker>{t('ext_arcim_documents_title')}</SectionKicker>
if (
state.phase === 'discovering' ||
state.phase === 'importing' ||
state.phase === 'reconnecting'
) {
const label =
state.phase === 'discovering'
? t('ext_arcim_documents_discovering')
: state.phase === 'importing'
? t('ext_arcim_documents_importing')
: t('ext_arcim_documents_reconnecting')
// Running totals arrive after each server slice of a real import; the
// dry-run result that sits in state while the first slice runs is not
// progress, so it stays silent.
const progress =
state.phase === 'importing' && state.result && !state.result.dryRun && state.result.total > 0
? state.result
: null
return (
<section className="space-y-3" aria-live="polite">
{title}
<SpinnerLine>{label}</SpinnerLine>
{progress && (
<p className="text-sm tabular-nums text-muted-foreground">
{t('ext_arcim_documents_import_progress', {
done: progress.scanned,
total: progress.total,
})}
</p>
)}
</section>
)
}
if (state.phase === 'offered') {
return (
<section className="space-y-3" aria-live="polite">
{title}
<p className="text-sm text-muted-foreground">
{t('ext_arcim_documents_prompt', { count: state.found })}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button className="min-h-11" onClick={onImport}>
{t('ext_arcim_documents_import_action')}
</Button>
<Button variant="ghost" className="min-h-11" onClick={onDismiss}>
{t('ext_arcim_documents_not_now')}
</Button>
</div>
</section>
)
}
if (state.phase === 'empty') {
return (
<section className="space-y-3" aria-live="polite">
{title}
<p className="text-sm text-muted-foreground">{t('ext_arcim_documents_empty')}</p>
<Button variant="outline" className="min-h-11" onClick={onDiscover}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('ext_arcim_documents_retry_discovery')}
</Button>
</section>
)
}
if (state.phase === 'complete') {
if (!state.result) {
return (
<section className="space-y-3" aria-live="polite">
{title}
<p className="text-sm text-muted-foreground">{t('ext_arcim_documents_result_description')}</p>
</section>
)
}
const { linked, skipped, unmatched, failed } = state.result
const outcomes = [
{
label: t('ext_arcim_documents_imported'),
value: linked,
valueClassName: 'text-foreground',
},
{
label: t('ext_arcim_documents_skipped'),
value: skipped,
valueClassName: 'text-foreground',
},
{
label: t('ext_arcim_documents_unmatched'),
value: unmatched,
valueClassName: 'text-foreground',
},
{
label: t('ext_arcim_documents_failed'),
value: failed,
valueClassName: failed > 0 ? 'text-destructive' : 'text-foreground',
},
]
return (
<section className="space-y-4" aria-live="polite">
{title}
<p className="text-sm text-muted-foreground">{t('ext_arcim_documents_result_description')}</p>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{outcomes.map(({ label, value, valueClassName }) => (
<div key={label} className="flex flex-col">
<dt className="order-2 text-xs text-muted-foreground">{label}</dt>
<dd className={cn('order-1 font-display text-xl tabular-nums', valueClassName)}>
{value}
</dd>
</div>
))}
</dl>
{unmatched > 0 && (
<p className="text-sm text-muted-foreground">
{t('ext_arcim_documents_unmatched_help')}
</p>
)}
{failed > 0 && (
<div className="space-y-3">
<p className="text-sm text-destructive">
{t('ext_arcim_documents_partial_failure')}
</p>
<Button variant="outline" className="min-h-11" onClick={onImport}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('ext_arcim_documents_retry_import')}
</Button>
</div>
)}
</section>
)
}
const reconnectRequired = state.problem?.reconnectRequired === true
const discoveryFailed = state.phase === 'discovery-error'
// Fortnox has not granted the file permissions to the integration itself, so
// neither reconnecting nor retrying can succeed: state it and offer nothing.
const scopesUnavailable =
state.problem?.code === PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE
return (
<section className="space-y-3" aria-live="polite">
{title}
<p className="text-sm text-destructive">
{scopesUnavailable
? t('ext_arcim_documents_scope_unavailable')
: state.problem?.message
? state.problem.message
: reconnectRequired
? t('ext_arcim_documents_scope_error')
: discoveryFailed
? t('ext_arcim_documents_discovery_error')
: t('ext_arcim_documents_import_error')}
</p>
{state.problem?.providerMessage && (
<p className="text-xs text-muted-foreground">
{t('ext_arcim_documents_provider_message', {
message: state.problem.providerMessage,
})}
</p>
)}
{state.problem?.requestId && (
<p className="text-xs text-muted-foreground">
{t('ext_arcim_documents_error_reference', {
requestId: state.problem.requestId,
})}
</p>
)}
{!scopesUnavailable && (
<Button
className="min-h-11"
onClick={reconnectRequired ? onReconnect : discoveryFailed ? onDiscover : onImport}
>
{reconnectRequired ? (
<RefreshCw className="mr-2 h-4 w-4" />
) : (
<RotateCcw className="mr-2 h-4 w-4" />
)}
{reconnectRequired
? t('ext_arcim_documents_reconnect_action')
: discoveryFailed
? t('ext_arcim_documents_retry_discovery')
: t('ext_arcim_documents_retry_import')}
</Button>
)}
</section>
)
}
const NEXT_STEPS: { title: string; sub: string }[] = [
{ title: 'Granska importerade verifikationer', sub: 'Kontrollera att bokföringen ser korrekt ut i huvudboken' },
{ title: 'Stäm av balansräkningen', sub: 'Jämför ingående balanser och saldon mot ditt tidigare system' },
{ title: 'Kontrollera kunder och leverantörer', sub: 'Verifiera kontaktuppgifter, organisationsnummer och bankinfo' },
]
function ResultStep({
results,
sieResults,
omittedYears,
error,
documentImportState,
theaterModel,
onDone,
onRetry,
onDiscoverDocuments,
onImportDocuments,
onDismissDocuments,
onReconnectDocuments,
}: {
results: MigrationResults | null
sieResults: ImportResult[]
/** Source fiscal years outside the selection: not fetched in this run. */
omittedYears: SourceFiscalYear[]
error: string | null
documentImportState: ArcimDocumentImportState
theaterModel: TheaterModel | null
onDone: () => void
onRetry: () => void
onDiscoverDocuments: () => void
onImportDocuments: () => void
onDismissDocuments: () => void
onReconnectDocuments: () => void
}) {
const t = useTranslations('extensions')
const fiscalYearSpanLabel = useFiscalYearSpanLabel()
if (error) {
return (
<div className="stagger-enter space-y-8">
<div>
<h2 className="font-display text-2xl leading-8 tracking-tight text-balance">
Migreringen misslyckades
</h2>
<p className="mt-3 whitespace-pre-line text-sm text-destructive">{error}</p>
</div>
<SieFallbackLine message="Du kan istället importera din bokföringsdata manuellt via en SIE-fil." />
<div className="flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onDone}>Klar</Button>
<Button className="min-h-11" onClick={onRetry}>
<RotateCcw className="mr-2 h-4 w-4" />
Försök igen
</Button>
</div>
</div>
)
}
const hasResults =
results ||
sieResults.length > 0 ||
(documentImportState.phase !== 'hidden' && documentImportState.phase !== 'dismissed')
if (!hasResults) return null
// Compute combined SIE stats
const totalJournalEntries = sieResults.reduce((sum, r) => sum + r.journalEntriesCreated, 0)
const totalErrors = sieResults.reduce((sum, r) => sum + r.errors.length, 0)
const allSieSucceeded = sieResults.length > 0 && sieResults.every(r => r.success)
const anySieFailed = sieResults.some(r => r.errors.length > 0 && r.journalEntriesCreated === 0)
// Check if anything meaningful was imported via entities
// Company info is always re-fetched (upsert) so it doesn't count as "new"
const entityImported = results && (
(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.assets && (results.assets.imported > 0 || results.assets.skipped > 0 || results.assets.scopesMissing))
)
// Steps that failed against the provider API. An empty sync with failed
// steps must never present as "Allt är uppdaterat": that reading sent a
// real subscription problem to the bug tracker as a sync bug.
const stepErrors = results?.stepErrors ?? []
const apiFailed = stepErrors.length > 0
const nothingNew = sieResults.length === 0 && !entityImported && !apiFailed
// ── The reveal: a serif verdict derived from the real results ──
const fyCount = sieResults.length
const verdict = nothingNew
? 'Allt är redan uppdaterat.'
: (anySieFailed || apiFailed)
? 'Migreringen är delvis genomförd.'
: totalJournalEntries > 0
? fyCount === 1
? `${totalJournalEntries.toLocaleString('sv-SE')} verifikationer är på plats.`
: `${totalJournalEntries.toLocaleString('sv-SE')} verifikationer över ${fyCount} räkenskapsår är på plats.`
: (sieResults.length > 0 && !allSieSucceeded) || totalErrors > 0
? 'Migreringen är klar, med anmärkningar.'
: 'Migreringen är klar.'
const statParts: string[] = []
if (totalJournalEntries > 0) statParts.push(`${totalJournalEntries.toLocaleString('sv-SE')} verifikat`)
if (fyCount > 0) statParts.push(`${fyCount} räkenskapsår`)
const customerCount = (results?.customers?.imported ?? 0) + (results?.customers?.updated ?? 0)
if (customerCount > 0) statParts.push(`${customerCount.toLocaleString('sv-SE')} kunder`)
if ((results?.suppliers?.imported ?? 0) > 0) statParts.push(`${results!.suppliers!.imported.toLocaleString('sv-SE')} leverantörer`)
const invoiceCount = (results?.salesInvoices?.imported ?? 0) + (results?.supplierInvoices?.imported ?? 0)
if (invoiceCount > 0) statParts.push(`${invoiceCount.toLocaleString('sv-SE')} fakturor`)
// The settled constellation only appears over a story that is true:
// it needs the model and actually imported entries.
const showCanvas = !!theaterModel && totalJournalEntries > 0
// Övriga data as a quiet line list, not a card grid.
const entityLines: { label: string; value: string; detail?: string; failed: boolean }[] = []
if (results) {
if (results.companyInfo?.imported) {
entityLines.push({ label: 'Företagsinformation', value: 'Importerad', failed: false })
}
if (results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 0 || results.customers.skipped > 0)) {
entityLines.push({
label: 'Kunder',
value: results.customers.updated
? `${results.customers.imported} importerade, ${results.customers.updated} kompletterade`
: `${results.customers.imported} importerade`,
detail: results.customers.skipped > 0
? formatSkipReasons(results.customers.skipReasons, 'customer', results.customers.errorSample) ?? `${results.customers.skipped} hoppades över`
: undefined,
failed: entityRowStatus(results.customers.imported, results.customers.skipReasons) === 'error',
})
}
if (results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) {
entityLines.push({
label: 'Leverantörer',
value: `${results.suppliers.imported} importerade`,
detail: results.suppliers.skipped > 0
? formatSkipReasons(results.suppliers.skipReasons, 'supplier', results.suppliers.errorSample) ?? `${results.suppliers.skipped} hoppades över`
: undefined,
failed: entityRowStatus(results.suppliers.imported, results.suppliers.skipReasons) === 'error',
})
}
if (results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) {
entityLines.push({
label: 'Kundfakturor',
value: `${results.salesInvoices.imported} importerade`,
detail: results.salesInvoices.skipped > 0
? formatSkipReasons(results.salesInvoices.skipReasons, 'invoice', results.salesInvoices.errorSample) ?? `${results.salesInvoices.skipped} hoppades över`
: undefined,
failed: entityRowStatus(results.salesInvoices.imported, results.salesInvoices.skipReasons) === 'error',
})
}
const lineHydration = results.salesInvoices?.hydration
const linesMissing = lineHydration ? lineHydration.needed - lineHydration.hydrated : 0
if (lineHydration && linesMissing > 0) {
// The detail fetch that carries the rows and the VAT split runs inside
// a fixed budget, open invoices first. Whatever it did not reach was
// imported as a header with a total and no rows; an hourly pass fills
// those in afterwards. Say so here, or the user finds out on the
// invoice page ("fakturorna finns med en total men utan rader").
entityLines.push({
label: t('ext_arcim_invoice_lines_label'),
value: t('ext_arcim_invoice_lines_value', { hydrated: lineHydration.hydrated, needed: lineHydration.needed }),
detail: t('ext_arcim_invoice_lines_pending_detail', { count: linesMissing }),
failed: false,
})
}
if (results.salesInvoices?.creditNotesUnlinked) {
// Credit notes land as ordinary invoice rows with reversed amounts. The
// pairing to the invoice they credit cannot be resolved at import time:
// no provider DTO carries a reference to it. Say so rather than leaving
// the user to notice a kreditfaktura that points at nothing.
const count = results.salesInvoices.creditNotesUnlinked
entityLines.push({
label: t('ext_arcim_credit_notes_label'),
value: `${count} importerade`,
detail: t('ext_arcim_credit_notes_unlinked_detail', { count }),
failed: false,
})
}
if (results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)) {
entityLines.push({
label: 'Leverantörsfakturor',
value: `${results.supplierInvoices.imported} importerade`,
detail: results.supplierInvoices.skipped > 0
? formatSkipReasons(results.supplierInvoices.skipReasons, 'invoice', results.supplierInvoices.errorSample) ?? `${results.supplierInvoices.skipped} hoppades över`
: undefined,
failed: entityRowStatus(results.supplierInvoices.imported, results.supplierInvoices.skipReasons) === 'error',
})
}
if (results.registrationLinks && results.registrationLinks.scanned > 0) {
const links = results.registrationLinks
// An invoice that already carried its link (a rerun over invoices an
// earlier run linked) is done, not failed: it counts towards the
// displayed total and gets its own detail line, so the value and the
// details agree. Without this a rerun read "0 av 2" with no explanation.
const done = links.linked + links.alreadyLinked
const unlinked = links.scanned - done
const details: string[] = []
if (links.alreadyLinked > 0) {
details.push(t('ext_arcim_registration_links_already_linked', { count: links.alreadyLinked }))
}
if (unlinked > 0) {
details.push(t('ext_arcim_registration_links_detail', {
unlinked,
noRef: links.noRef,
refNotFetched: links.refNotFetched ?? 0,
unresolved: links.unresolved + links.ambiguous,
amountMismatch: links.amountMismatch,
}))
}
entityLines.push({
label: t('ext_arcim_registration_links_label'),
value: t('ext_arcim_registration_links_value', { linked: done, scanned: links.scanned }),
detail: details.length > 0 ? details.join('. ') : undefined,
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 (
<div className="stagger-enter space-y-8">
{/* ── The reveal: settled constellation beside the serif verdict ── */}
<div className={cn('grid items-center gap-6', showCanvas && 'md:grid-cols-[minmax(280px,380px)_1fr]')}>
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Migrering
</p>
<h2 className="mt-2 font-display text-2xl leading-8 tracking-tight text-balance">
{verdict}
</h2>
{nothingNew ? (
<p className="mt-3 text-[13px] text-muted-foreground">
Det finns ingen ny data att importera från leverantören.
</p>
) : statParts.length > 0 ? (
<p className="mt-3 text-[13px] text-muted-foreground tabular-nums">
{statParts.join(' · ')}
</p>
) : null}
</div>
{showCanvas && theaterModel && (
<div className="relative hidden min-h-[360px] md:block">
<TheaterCanvas model={theaterModel} settled />
</div>
)}
</div>
{/* ── Steps that failed against the provider API: strong color, no box ── */}
{stepErrors.length > 0 && (
<div className="space-y-3">
{groupStepErrors(stepErrors).map((group, i) => (
<div key={i} className="space-y-1">
<p className="text-sm font-medium text-destructive">
Kunde inte hämta: {group.steps.map((s) => STEP_ERROR_LABELS[s]).join(', ')}
</p>
<p className="text-sm text-muted-foreground">{group.message}</p>
</div>
))}
</div>
)}
{/* ── Per-fiscal-year outcomes as lines ── */}
{sieResults.length > 0 && (
<section className="space-y-3">
<SectionKicker>Bokföringsdata (SIE)</SectionKicker>
<div className="stagger-enter divide-y divide-border" data-no-stagger>
{sieResults.map((r, i) => (
<FiscalYearLine key={i} result={r} index={i} />
))}
</div>
</section>
)}
{/* ── Source fiscal years outside the selection (#2211) ──
Named here so nobody believes the books are complete: a new run
with those years ticked fetches them (documents come along), or
the SIE path does. */}
{sieResults.length > 0 && omittedYears.length > 0 && (
<section className="space-y-3">
<SectionKicker>{t('ext_arcim_omitted_years_kicker')}</SectionKicker>
<div className="stagger-enter divide-y divide-border" data-no-stagger>
{omittedYears.map((fy) => (
<p key={`${fy.year}-${fy.fromDate ?? ''}`} className="py-3 text-sm tabular-nums">
{fiscalYearSpanLabel(fy)}
</p>
))}
</div>
<SieFallbackLine
message={t('ext_arcim_omitted_years_result', { count: omittedYears.length })}
label={t('ext_arcim_omitted_years_sie_link')}
/>
</section>
)}
{/* ── API import results: quiet two-column line list ── */}
{entityLines.length > 0 && (
<section className="space-y-3">
<SectionKicker>Övriga data</SectionKicker>
<div className="stagger-enter grid gap-x-10 sm:grid-cols-2" data-no-stagger>
{entityLines.map((line) => (
<div key={line.label} className="border-b border-border py-2">
<div className="flex items-baseline justify-between gap-4">
<span className="text-sm">{line.label}</span>
<span
className={cn(
'text-right text-sm tabular-nums',
line.failed ? 'font-medium text-destructive' : 'text-muted-foreground'
)}
>
{line.value}
</span>
</div>
{line.detail && (
<p className="mt-0.5 text-xs text-muted-foreground">{line.detail}</p>
)}
</div>
))}
</div>
</section>
)}
<DocumentImportFollowUp
state={documentImportState}
onDiscover={onDiscoverDocuments}
onImport={onImportDocuments}
onDismiss={onDismissDocuments}
onReconnect={onReconnectDocuments}
/>
{/* ── Next steps: quiet numbered lines, no card, no filled discs ── */}
{!nothingNew && (
<section className="space-y-3">
<SectionKicker>Nästa steg</SectionKicker>
<ol className="stagger-enter divide-y divide-border" data-no-stagger>
{NEXT_STEPS.map((step, i) => (
<li key={step.title} className="flex items-baseline gap-4 py-3">
<span className="text-[13px] text-muted-foreground tabular-nums">{i + 1}</span>
<div className="min-w-0">
<p className="text-sm font-medium">{step.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{step.sub}</p>
</div>
</li>
))}
</ol>
</section>
)}
<div className="flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onDone}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny migrering
</Button>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="min-h-11" asChild>
<Link href="/customers">
Visa kunder
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button className="min-h-11" asChild>
<Link href="/bookkeeping">
Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
)
}
const STEP_ERROR_LABELS: Record<MigrationStepError['step'], string> = {
companyInfo: 'Företagsinformation',
customers: 'Kunder',
suppliers: 'Leverantörer',
salesInvoices: 'Kundfakturor',
supplierInvoices: 'Leverantörsfakturor',
assets: 'Anläggningstillgångar',
registrationLinks: 'Koppling till verifikationer',
reconciliation: 'Avstämning av betalningar',
}
/**
* Group step errors that share the same message (a provider outage hits every
* step identically) so the result shows one card per cause, not one per step.
*/
function groupStepErrors(errors: MigrationStepError[]): { message: string; steps: MigrationStepError['step'][] }[] {
const groups = new Map<string, MigrationStepError['step'][]>()
for (const e of errors) {
const steps = groups.get(e.message) ?? []
steps.push(e.step)
groups.set(e.message, steps)
}
return [...groups.entries()].map(([message, steps]) => ({ message, steps }))
}
function formatSkipReasons(
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(
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}`)
}
if (reasons.failed) {
parts.push(
errorSample
? `${reasons.failed} misslyckades: ${errorSample.slice(0, 140)}`
: `${reasons.failed} misslyckades`
)
}
return parts.length > 0 ? parts.join(', ') : undefined
}
/** A step that failed everything it tried is an error, not a quiet count. */
function entityRowStatus(imported: number, reasons?: SkipReasons): 'success' | 'error' {
return imported === 0 && (reasons?.failed ?? 0) > 0 ? 'error' : 'success'
}
// ── Main wizard ─────────────────────────────────────────────────
export default function ArcimMigrationWorkspace({
initialProvider,
}: WorkspaceComponentProps & {
/** Deep-linked old system (onboarding branch question): jump straight to
* its connect step instead of showing the provider list. */
initialProvider?: string
}) {
const { toast } = useToast()
const [step, setStep] = useState<WizardStep>('provider')
const [isLoading, setIsLoading] = useState(false)
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
const [error, setError] = useState<string | null>(null)
// Per-item details behind `error`: e.g. the SIE validation errors from
// /sie-data, which would otherwise be swallowed (the envelope's `error`
// field is just the string "validation").
const [errorDetails, setErrorDetails] = useState<string[] | null>(null)
// Connection status (existing connections + import history)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null)
// Connection state
const [selectedProvider, setSelectedProvider] = useState<ArcimProvider | null>(null)
const [consentId, setConsentId] = useState<string | null>(null)
const [authUrl, setAuthUrl] = useState<string | null>(null)
// Björn Lundén: Lundify activation URL from /connect (null when BL has not
// issued an activation key, in which case only the User-Key field shows).
const [activationUrl, setActivationUrl] = useState<string | null>(null)
const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null)
// Preview state
const [preview, setPreview] = useState<PreviewData | null>(null)
// Fiscal years (start years) ticked in the preview step's picker. Set from
// the preview's default selection on load; sent to /sie-data as `years`.
const [selectedYears, setSelectedYears] = useState<number[]>([])
// Set when a preview/sync fails because the provider connection expired
// (dead refresh token → PROVIDER_AUTH_EXPIRED). Drives the "Återanslut"
// affordance so the user can re-authorize in place instead of disconnecting.
const [authExpired, setAuthExpired] = useState(false)
// Set when the failure is specifically a missing/inactive Fortnox integration
// license (PROVIDER_LICENSE_MISSING). Re-auth alone can't fix it, so the SIE
// fallback stays available alongside the "Återanslut" CTA.
const [licenseMissing, setLicenseMissing] = useState(false)
// SIE data state (held between mapping and execution steps)
const [sieData, setSieData] = useState<SIEData | null>(null)
const companyAccountsForVatRef = useRef<BASAccount[]>([])
// Chart of accounts incl. inactive, from the session cache
// (lib/reference-data). Re-read when the mapping step opens because the
// preview may have created accounts; the SIE import invalidates it too.
const { refresh: refreshCompanyAccounts } = useAccounts(false)
// Options state
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
// Migration state
const [migrationStep, setMigrationStep] = useState('')
const [migrationProgress, setMigrationProgress] = useState(0)
const [migrationResults, setMigrationResults] = useState<MigrationResults | null>(null)
const [sieImportResults, setSieImportResults] = useState<ImportResult[]>([])
const [documentImportState, dispatchDocumentImport] = useReducer(
arcimDocumentImportReducer,
INITIAL_ARCIM_DOCUMENT_IMPORT_STATE,
)
const documentReconnectActionRef = useRef<'discover' | 'import' | null>(null)
const documentReconnectFailureCleanupRef = useRef<number | null>(null)
const stopOAuthPopupWatchRef = useRef<(() => void) | null>(null)
// Knowledge-graph theater for the migrating step, built from the already
// client-held parsed SIE. Null falls back to the plain progress card.
const [theaterModel, setTheaterModel] = useState<TheaterModel | null>(null)
// Wizard progress: only user-interactive steps
const userSteps = STEPS.filter(s => {
if (s === 'migrating' || s === 'result') return false
if (s === 'mapping' && !preview?.sieAvailable) return false
return true
})
const currentUserStepIndex = userSteps.indexOf(step)
const isInteractiveStep = currentUserStepIndex !== -1
// ── Fetch connection status on mount ───────────────────────────
const fetchStatus = useCallback(async () => {
try {
setIsLoadingStatus(true)
const res = await fetch('/api/extensions/ext/arcim-migration/status')
if (res.ok) {
const data = await res.json()
setConnectionStatus(data)
}
} catch {
// Non-critical: just means we can't show existing connections
} finally {
setIsLoadingStatus(false)
}
}, [])
useEffect(() => {
fetchStatus()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Step handlers ──────────────────────────────────────────────
const loadPreview = useCallback(async (cId: string) => {
setStep('preview')
setIsLoading(true)
setError(null)
setAuthExpired(false)
setLicenseMissing(false)
setConsentId(cId)
try {
const res = await fetch(`/api/extensions/ext/arcim-migration/preview?consentId=${cId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
// A dead connection (expired/revoked refresh token) is recoverable in
// place: flag it so the UI offers "Återanslut" instead of a dead end.
// A missing Fortnox integration license or an inactive Visma API
// module shows the same CTA but keeps the SIE fallback, because
// re-auth loops until the customer fixes the subscription (re-orders
// the license / activates the API module).
const code = apiErrorCode(data)
if (
code === 'PROVIDER_AUTH_EXPIRED' ||
code === 'PROVIDER_LICENSE_MISSING' ||
code === 'PROVIDER_API_MODULE_INACTIVE'
) {
setAuthExpired(true)
}
if (code === 'PROVIDER_LICENSE_MISSING' || code === 'PROVIDER_API_MODULE_INACTIVE') {
setLicenseMissing(true)
}
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json() as PreviewData
setPreview(data)
setSelectedYears(
(data.sourceYears ?? []).filter((fy) => fy.inDefaultSelection).map((fy) => fy.year),
)
const previewProvider = data?.consent?.provider
if (ARCIM_PROVIDERS.some((provider) => provider.id === previewProvider)) {
setSelectedProvider(previewProvider as ArcimProvider)
}
// If SIE is not available, disable SIE import by default
if (!data.sieAvailable) {
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
} catch (err) {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta förhandsgranskning')
} finally {
setIsLoading(false)
}
}, [])
const handleSelectProvider = useCallback(async (provider: ArcimProvider) => {
setSelectedProvider(provider)
setStep('connect')
setIsLoading(true)
setError(null)
// Drop the previous attempt's consent and one-time URLs before asking for
// new ones: if /connect fails, the step must not keep offering a stale
// activation link that completes the earlier consent.
setConsentId(null)
setAuthType(null)
setAuthUrl(null)
setActivationUrl(null)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
setConsentId(data.consentId)
setAuthType(data.authType)
setActivationUrl(typeof data.activationUrl === 'string' ? data.activationUrl : null)
if (data.alreadyConnected) {
// Existing connection: skip auth, go straight to preview
await loadPreview(data.consentId)
return
}
if (data.authType === 'oauth' && data.authUrl) {
setAuthUrl(data.authUrl)
}
// Token-based providers stay on connect step for credential input
} catch (err) {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Anslutning misslyckades')
} finally {
setIsLoading(false)
}
}, [loadPreview])
// Re-sync with existing consent: go straight to preview
const handleResync = useCallback(async (provider: ArcimProvider, existingConsentId: string) => {
setSelectedProvider(provider)
setConsentId(existingConsentId)
setMigrationOptions(DEFAULT_OPTIONS)
setMigrationResults(null)
setSieImportResults([])
setSieData(null)
await loadPreview(existingConsentId)
}, [loadPreview])
const clearOAuthPopupWatch = useCallback(() => {
stopOAuthPopupWatchRef.current?.()
stopOAuthPopupWatchRef.current = null
}, [])
const clearDocumentReconnectFailureCleanup = useCallback(() => {
if (documentReconnectFailureCleanupRef.current) {
window.clearTimeout(documentReconnectFailureCleanupRef.current)
documentReconnectFailureCleanupRef.current = null
}
}, [])
useEffect(() => () => {
clearOAuthPopupWatch()
clearDocumentReconnectFailureCleanup()
}, [clearDocumentReconnectFailureCleanup, clearOAuthPopupWatch])
// Re-authorize a dead connection in place. Re-runs provider auth against the
// SAME consent so fresh tokens overwrite the expired pair: no disconnect.
// OAuth providers open the login popup (the existing postMessage listener
// reloads the preview on success); token providers drop to the credential
// form. Triggered from the "Återanslut" CTA after a sync hits
// PROVIDER_AUTH_EXPIRED.
const handleReconnect = useCallback(async (
provider: ArcimProvider,
existingConsentId: string,
options?: { onFailure?: () => void; documentScopes?: boolean },
) => {
setError(null)
setAuthExpired(false)
setLicenseMissing(false)
setIsLoading(true)
setSelectedProvider(provider)
// Pre-open the OAuth popup inside the click's user activation: opening it
// after the fetch below is popup-blocked when the response is slow (the
// activation expires after ~5s). Kept open only for OAuth providers; the
// token path and every failure path close it again. The opener reference
// stays intact: the provider popup posts back via postMessage.
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open('', 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider,
reconnect: true,
documentScopes: options?.documentScopes === true,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
setConsentId(data.consentId ?? existingConsentId)
setAuthType(data.authType)
if (data.authType === 'oauth' && data.authUrl) {
let activePopup: Window | null = null
if (popup && !popup.closed) {
popup.location.href = data.authUrl
activePopup = popup
} else {
// The pre-opened popup was blocked or closed; retrying here is a
// long shot (the activation may be gone) but strictly better than
// dropping the flow. If the retry is blocked too, take the same
// full-page fallback as the first-connect button rather than leaving
// "Återanslut" looking like it worked.
const retry = window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
if (!retry) {
window.location.href = data.authUrl
} else {
activePopup = retry
}
}
if (activePopup) {
clearOAuthPopupWatch()
stopOAuthPopupWatchRef.current = watchArcimOAuthPopup(activePopup, () => {
stopOAuthPopupWatchRef.current = null
if (options?.onFailure) {
options.onFailure()
} else {
setError('Inloggningsfönstret stängdes innan anslutningen var klar. Försök igen.')
setAuthExpired(true)
}
})
}
setAuthUrl(data.authUrl)
} else {
popup?.close()
if (data.authType === 'token') {
// Re-enter credentials for token-based providers
setActivationUrl(typeof data.activationUrl === 'string' ? data.activationUrl : null)
setStep('connect')
}
}
} catch (err) {
popup?.close()
if (options?.onFailure) {
options.onFailure()
} else {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte återansluta')
setAuthExpired(true)
}
} finally {
setIsLoading(false)
}
}, [clearOAuthPopupWatch])
const runDocumentDiscovery = useCallback(async (
currentConsentId: string,
provider: ArcimProvider | null,
migrationSucceeded: boolean,
) => {
dispatchDocumentImport({
type: 'discovery-started',
provider,
migrationSucceeded,
})
if (provider !== 'fortnox' || !migrationSucceeded) return
try {
const result = await requestArcimDocumentImport(currentConsentId, true)
dispatchDocumentImport({ type: 'discovery-succeeded', result })
} catch (documentError) {
dispatchDocumentImport({
type: 'discovery-failed',
problem: documentImportProblem(documentError),
})
}
}, [])
const runDocumentImport = useCallback(async (currentConsentId: string) => {
dispatchDocumentImport({ type: 'import-started' })
try {
// One route call per time-budgeted slice; the helper loops until the
// server reports the end and feeds running totals back for the UI.
const result = await runArcimDocumentImportToCompletion(currentConsentId, {
onProgress: (progress) =>
dispatchDocumentImport({ type: 'import-progress', result: progress }),
})
dispatchDocumentImport({ type: 'import-succeeded', result })
} catch (documentError) {
dispatchDocumentImport({
type: 'import-failed',
problem: documentImportProblem(documentError),
})
}
}, [])
const handleDocumentReconnect = useCallback(() => {
if (!consentId) return
clearDocumentReconnectFailureCleanup()
const reconnectAction =
documentImportState.phase === 'discovery-error' ? 'discover' : 'import'
const priorProblem = documentImportState.problem ?? {
code: null,
requestId: null,
reconnectRequired: false,
}
documentReconnectActionRef.current = reconnectAction
storeDocumentOAuthResume(reconnectAction)
dispatchDocumentImport({ type: 'reconnect-started' })
void handleReconnect('fortnox', consentId, {
// The whole point of this reconnect is the attachment permissions, so
// this is the one path that asks Fortnox for them.
documentScopes: true,
onFailure: () => {
dispatchDocumentImport(
reconnectAction === 'discover'
? { type: 'discovery-failed', problem: priorProblem }
: { type: 'import-failed', problem: priorProblem },
)
// Keep the action briefly after the popup-close grace period. Some
// browsers deliver the successful postMessage after reporting the
// popup as closed; that success must remain authoritative.
documentReconnectFailureCleanupRef.current = window.setTimeout(() => {
documentReconnectActionRef.current = null
clearDocumentOAuthResume()
documentReconnectFailureCleanupRef.current = null
}, 30_000)
},
})
}, [
clearDocumentReconnectFailureCleanup,
consentId,
documentImportState.phase,
documentImportState.problem,
handleReconnect,
])
// Disconnect an existing consent
const handleDisconnect = useCallback(async (consentIdToDelete: string) => {
try {
const res = await fetch('/api/extensions/ext/arcim-migration/disconnect', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consentId: consentIdToDelete }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, 'Kunde inte koppla från'))
}
toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' })
await fetchStatus()
} catch (err) {
toast({ title: err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel', variant: 'destructive' })
}
}, [toast, fetchStatus])
// Handle token submission for token-based providers (Bokio, etc.)
const handleTokenSubmit = useCallback(async (apiToken: string, companyId: string) => {
if (!consentId || !selectedProvider) return
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/submit-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
consentId,
provider: selectedProvider,
apiToken,
companyId: companyId || undefined,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw apiError(data, `HTTP ${res.status}`)
}
// Token stored: consent is now accepted, proceed to preview
await loadPreview(consentId)
} catch (err) {
setError(displayError(err, 'Kunde inte ansluta'))
} finally {
setIsLoading(false)
}
}, [consentId, selectedProvider, loadPreview])
// Handle OAuth callback via URL params
const handleOAuthReturn = useCallback(async () => {
// Check URL for migration callback params
const url = new URL(window.location.href)
const migrationStatus = url.searchParams.get('migration')
const callbackConsentId = url.searchParams.get('consentId')
const documentResume = readDocumentOAuthResume()
if (migrationStatus === 'connected' && callbackConsentId) {
// Clean URL
url.searchParams.delete('migration')
url.searchParams.delete('consentId')
window.history.replaceState({}, '', url.pathname)
clearDocumentOAuthResume()
if (documentResume) {
clearDocumentReconnectFailureCleanup()
documentReconnectActionRef.current = null
setConsentId(callbackConsentId)
setSelectedProvider('fortnox')
setStep('result')
if (documentResume.action === 'discover') {
await runDocumentDiscovery(callbackConsentId, 'fortnox', true)
} else {
await runDocumentImport(callbackConsentId)
}
} else {
await loadPreview(callbackConsentId)
}
} else if (migrationStatus === 'error') {
const callbackProvider = url.searchParams.get('provider') as ArcimProvider | null
const reason = url.searchParams.get('reason') || 'OAuth-anslutningen misslyckades. Försök igen.'
url.searchParams.delete('migration')
url.searchParams.delete('provider')
url.searchParams.delete('reason')
url.searchParams.delete('consentId')
window.history.replaceState({}, '', url.pathname)
clearDocumentOAuthResume()
if (documentResume && callbackConsentId) {
clearDocumentReconnectFailureCleanup()
setConsentId(callbackConsentId)
setSelectedProvider('fortnox')
setStep('result')
const problem = documentOAuthProblemFromReason(reason)
dispatchDocumentImport(
documentResume.action === 'discover'
? { type: 'discovery-failed', problem }
: { type: 'import-failed', problem },
)
return
}
setError(reason)
toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' })
if (callbackProvider) {
setSelectedProvider(callbackProvider)
setStep('connect')
} else {
setStep('provider')
}
}
}, [
clearDocumentReconnectFailureCleanup,
loadPreview,
runDocumentDiscovery,
runDocumentImport,
toast,
])
// Check for OAuth callback on mount (fallback for non-popup flow)
useEffect(() => {
handleOAuthReturn()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Deep-linked provider preselect (onboarding branch question). Only when
// this mount is not an OAuth return (that flow owns the wizard state), and
// only for providers whose SIE comes via API: visma/bokio must first see
// the provider list with its "SIE krävs först" gate, which depends on
// async connection status.
const preselectedRef = useRef(false)
useEffect(() => {
if (preselectedRef.current || !initialProvider) return
if (new URL(window.location.href).searchParams.get('migration')) return
const provider = ARCIM_PROVIDERS.find((p) => p.id === initialProvider)
if (!provider || COMING_SOON_PROVIDERS.has(provider.id) || !provider.sieViaApi) return
preselectedRef.current = true
void handleSelectProvider(provider.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProvider])
// Listen for postMessage from OAuth popup
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return
if (event.data?.type === 'arcim-oauth-success' && event.data.consentId) {
clearOAuthPopupWatch()
clearDocumentReconnectFailureCleanup()
const reconnectAction = documentReconnectActionRef.current
if (reconnectAction) {
documentReconnectActionRef.current = null
clearDocumentOAuthResume()
setConsentId(event.data.consentId)
setSelectedProvider('fortnox')
setStep('result')
if (reconnectAction === 'discover') {
void runDocumentDiscovery(event.data.consentId, 'fortnox', true)
} else {
void runDocumentImport(event.data.consentId)
}
return
}
loadPreview(event.data.consentId)
} else if (event.data?.type === 'arcim-oauth-error') {
clearOAuthPopupWatch()
clearDocumentReconnectFailureCleanup()
const reason = typeof event.data.reason === 'string' && event.data.reason
? event.data.reason
: 'OAuth-anslutningen misslyckades. Försök igen.'
const reconnectAction = documentReconnectActionRef.current
if (reconnectAction) {
documentReconnectActionRef.current = null
clearDocumentOAuthResume()
const problem = documentImportState.problem ?? {
code: null,
requestId: null,
reconnectRequired: true,
}
dispatchDocumentImport(
reconnectAction === 'discover'
? { type: 'discovery-failed', problem }
: { type: 'import-failed', problem },
)
return
}
setError(reason)
toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' })
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [
clearOAuthPopupWatch,
clearDocumentReconnectFailureCleanup,
documentImportState.problem,
loadPreview,
runDocumentDiscovery,
runDocumentImport,
toast,
])
// Load SIE data when entering mapping step
const loadSIEData = useCallback(async () => {
if (!consentId) return
setStep('mapping')
setIsLoading(true)
setError(null)
setErrorDetails(null)
try {
// The picker's selection travels as `years`; without a picker (no
// source years known) the route falls back to its default selection.
const yearsQuery = selectedYears.length > 0 ? `&years=${selectedYears.join(',')}` : ''
const res = await fetch(`/api/extensions/ext/arcim-migration/sie-data?consentId=${consentId}${yearsQuery}`)
if (!res.ok) {
const data = await res.json().catch(() => ({})) as {
error?: unknown
validation?: { errors?: unknown }
}
const validationErrors = data?.error === 'validation' ? data.validation?.errors : undefined
if (Array.isArray(validationErrors)) {
setErrorDetails(validationErrors.filter((e): e is string => typeof e === 'string'))
throw new UserFacingError(
'Bokföringsdatan hos leverantören klarade inte valideringen. Felen nedan måste rättas i källsystemet innan importen kan fortsätta.'
)
}
throw apiError(data, `HTTP ${res.status}`)
}
const data = await res.json() as SIEData
// Bound SWR mutate resolves with the revalidated list.
const companyAccounts = ((await refreshCompanyAccounts()) ?? []) as BASAccount[]
companyAccountsForVatRef.current = companyAccounts
const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, companyAccounts)
setSieData({ ...data, mappings: enrichedMappings })
// If all SIE files are already imported, disable SIE import by default
if (data.allImported) {
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
const needsVatReview = enrichedMappings.some(mapping =>
mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed
)
// Auto-skip only when there is neither account mapping nor VAT review work.
if ((data.mappingStats.unmapped === 0 && !needsVatReview) || data.allImported) {
setStep('options')
}
} catch (err) {
setError(displayError(err, 'Kunde inte hämta SIE-data'))
} finally {
setIsLoading(false)
}
}, [consentId, refreshCompanyAccounts, selectedYears])
const handlePreviewContinue = useCallback(() => {
if (preview?.sieAvailable) {
// Load SIE data for mapping step
loadSIEData()
} else {
// Skip mapping step: no SIE available
setStep('options')
}
}, [preview, loadSIEData])
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
if (!sieData) return
const updatedMappings = enrichChangedAccountMappingWithVat(
sieData.mappings.map(m =>
m.sourceAccount === sourceAccount
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
: m
),
sourceAccount,
companyAccountsForVatRef.current,
)
setSieData(prev => prev ? {
...prev,
mappings: updatedMappings,
mappingStats: {
...prev.mappingStats,
unmapped: updatedMappings.filter(m => !m.targetAccount).length,
mapped: updatedMappings.filter(m => m.targetAccount).length,
},
} : null)
}, [sieData])
const handleVatTreatmentChange = useCallback((
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => {
setSieData(prev => prev ? {
...prev,
mappings: applyVatTreatmentReview(prev.mappings, sourceAccount, treatment, rate),
} : null)
}, [])
const handleConfirmAllVatTreatments = useCallback(() => {
setSieData(prev => prev ? {
...prev,
mappings: applyVatTreatmentReviewAll(prev.mappings),
} : null)
}, [])
const handleMappingContinue = useCallback(() => {
setStep('options')
}, [])
const handleStartMigration = useCallback(async () => {
if (!consentId) return
setStep('migrating')
setMigrationStep('Startar migrering...')
setMigrationProgress(5)
setError(null)
dispatchDocumentImport({ type: 'reset' })
// Build the theater from the parsed SIE the client already holds.
// Best-effort: any failure just leaves the plain progress card.
if (sieData?.parsed) {
try {
const { buildTheaterModel } = await import('@/lib/import/theater-model')
setTheaterModel(buildTheaterModel(sieData.parsed))
} catch {
setTheaterModel(null)
}
} else {
setTheaterModel(null)
}
try {
// ── Phase 1: SIE import ──────────────────────────────────
if (migrationOptions.importSIEData && sieData && sieData.rawContent.length > 0) {
setMigrationStep('Importerar bokföringsdata (SIE)...')
setMigrationProgress(10)
setSieImportResults([])
// Send every file to the engine. The Fortnox endpoint runs in
// replace-mode, so a year that already has a completed import
// gets its prior import marked 'replaced' (imported entries
// deleted, user-created entries untouched) before the new
// SIE is loaded. The per-file result reports replacedPriorImport.
const filesToImport = sieData.rawContent.map((content, i) => ({
content,
status: sieData.fileStatuses?.[i],
}))
for (let i = 0; i < filesToImport.length; i++) {
const progress = 10 + Math.round((i / filesToImport.length) * 40)
setMigrationProgress(progress)
setMigrationStep(`Importerar bokföringsdata (SIE): fil ${i + 1} av ${filesToImport.length}...`)
// Name the year in every per-file failure: a multi-year re-sync
// that dies on ONE year must say which, or the user cannot act on
// it (issue #1667: the current year re-imported, the prior year
// refused, and the error never said so).
const fiscalYear = filesToImport[i].status?.fiscalYear
const yearLabel = fiscalYear ? `Räkenskapsår ${fiscalYear}: ` : ''
const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rawContent: filesToImport[i].content,
mappings: sieData.mappings,
options: {
createFiscalPeriod: true,
importOpeningBalances: true,
importTransactions: true,
voucherSeries: migrationOptions.voucherSeries,
},
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
const err = apiError(data, `SIE import HTTP ${res.status}`)
throw err instanceof UserFacingError
? new UserFacingError(`${yearLabel}${err.message}`)
: err
}
const result = await res.json() as ImportResult
setSieImportResults(prev => [...prev, result])
// The import creates accounts and a räkenskapsår: every cached
// picker must see them.
void invalidateReferenceData(['ref:accounts', 'ref:fiscal-periods'])
// The endpoint returns HTTP 200 with success:false when the import
// itself failed (e.g. räkenskapsår mismatch). Stop here: continuing
// to /migrate would hit its SIE-guard, whose "SIE måste importeras
// först" message masks the real error.
if (!result.success) {
throw new UserFacingError(result.errors.length > 0
? `${yearLabel}${result.errors.join('\n')}`
: `${yearLabel}SIE-importen misslyckades utan felmeddelande.`)
}
}
}
// ── 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 ||
effectiveImportAssets
let hadStepErrors = false
if (hasApiImport) {
setMigrationStep('Importerar kunder, leverantörer och fakturor...')
setMigrationProgress(55)
const res = await fetch('/api/extensions/ext/arcim-migration/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/x-ndjson' },
body: JSON.stringify({
consentId,
importCompanyInfo: migrationOptions.importCompanyInfo,
importCustomers: migrationOptions.importCustomers,
importSuppliers: migrationOptions.importSuppliers,
importSalesInvoices: migrationOptions.importSalesInvoices,
importSupplierInvoices: migrationOptions.importSupplierInvoices,
importAssets: effectiveImportAssets,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw apiError(data, `HTTP ${res.status}`)
}
const contentType = res.headers.get('content-type') ?? ''
let results: MigrationResults | undefined
if (contentType.includes('application/x-ndjson') && res.body) {
results = await consumeMigrationStream(res.body, (currentStep, progress) => {
if (currentStep) setMigrationStep(currentStep)
// The orchestrator reports 0-100 on its own scale; the wizard bar
// reserves 55-100 for the entity phase (SIE holds 10-50).
setMigrationProgress(55 + Math.round(progress * 0.45))
})
} else {
// Pre-stream server (or a proxy that stripped the stream): the
// original single-JSON contract.
const data = await res.json()
results = data.results as MigrationResults | undefined
}
setMigrationResults(results ?? null)
hadStepErrors = (results?.stepErrors?.length ?? 0) > 0
}
// Mark consent as fully accepted now that import is complete
if (consentId) {
await fetch('/api/extensions/ext/arcim-migration/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consentId }),
}).catch(() => { /* best-effort */ })
}
setMigrationProgress(100)
setStep('result')
const documentProvider = resolveArcimDocumentFollowUpProvider(
preview?.consent.provider,
selectedProvider,
)
if (documentProvider) {
void runDocumentDiscovery(consentId, documentProvider, true)
}
if (hadStepErrors) {
toast({
title: 'Migrering delvis genomförd',
description: 'Vissa delar kunde inte hämtas från leverantören. Se detaljerna i resultatet.',
variant: 'destructive',
})
} else {
toast({
title: 'Migrering klar',
description: 'Din bokföringsdata har importerats.',
})
}
} catch (err) {
setError(displayError(err))
setStep('result')
}
}, [consentId, migrationOptions, preview, runDocumentDiscovery, selectedProvider, sieData, toast])
const handleDone = useCallback(() => {
// Reset wizard
setStep('provider')
setSelectedProvider(null)
setConsentId(null)
setAuthUrl(null)
setAuthType(null)
setPreview(null)
setSieData(null)
setMigrationOptions(DEFAULT_OPTIONS)
setMigrationResults(null)
setSieImportResults([])
dispatchDocumentImport({ type: 'reset' })
clearDocumentReconnectFailureCleanup()
documentReconnectActionRef.current = null
setTheaterModel(null)
setError(null)
// Refresh status so provider step shows updated import history
fetchStatus()
}, [clearDocumentReconnectFailureCleanup, fetchStatus])
// ── Render ─────────────────────────────────────────────────────
return (
<div className="space-y-8">
{/* Step indicator: only during interactive steps */}
{step !== 'provider' && isInteractiveStep && (
<StepRail steps={userSteps} currentIndex={currentUserStepIndex} />
)}
{/* Step content */}
{step === 'provider' && (
<ProviderStep
onSelect={handleSelectProvider}
onResync={handleResync}
onDisconnect={handleDisconnect}
connectionStatus={connectionStatus}
isLoadingStatus={isLoadingStatus}
/>
)}
{step === 'connect' && selectedProvider && (
<ConnectStep
provider={selectedProvider}
authType={authType}
isLoading={isLoading}
error={error}
authUrl={authUrl}
activationUrl={activationUrl}
consentId={consentId}
onTokenSubmit={handleTokenSubmit}
onBack={() => {
setStep('provider')
setError(null)
}}
/>
)}
{step === 'preview' && (
<PreviewStep
preview={preview}
isLoading={isLoading}
error={error}
authExpired={authExpired}
licenseMissing={licenseMissing}
selectedYears={selectedYears}
onSelectedYearsChange={setSelectedYears}
onReconnect={() => {
if (selectedProvider && consentId) handleReconnect(selectedProvider, consentId)
}}
onContinue={handlePreviewContinue}
onBack={() => setStep('provider')}
/>
)}
{step === 'mapping' && (
<MappingStep
sieData={sieData}
isLoading={isLoading}
error={error}
errorDetails={errorDetails}
onMappingChange={handleMappingChange}
onVatTreatmentChange={handleVatTreatmentChange}
onConfirmAllVatTreatments={handleConfirmAllVatTreatments}
onContinue={handleMappingContinue}
onBack={() => setStep('preview')}
/>
)}
{step === 'options' && (
<OptionsStep
options={migrationOptions}
sieAvailable={preview?.sieAvailable ?? false}
sieData={sieData}
hasSieData={(preview?.hasSieData ?? false) || sieImportResults.some(r => r.success)}
provider={preview?.consent.provider ?? null}
onChange={setMigrationOptions}
onStart={handleStartMigration}
onBack={() => preview?.sieAvailable ? setStep('mapping') : setStep('preview')}
/>
)}
{step === 'migrating' && (
theaterModel ? (
<ArcimMigrationTheater
model={theaterModel}
currentStep={migrationStep}
progress={migrationProgress}
/>
) : (
<MigratingStep currentStep={migrationStep} progress={migrationProgress} />
)
)}
{step === 'result' && (
<ResultStep
results={migrationResults}
sieResults={sieImportResults}
omittedYears={sieData?.omittedYears ?? []}
error={error}
documentImportState={documentImportState}
theaterModel={theaterModel}
onDone={handleDone}
onRetry={() => {
setError(null)
setStep('options')
}}
onDiscoverDocuments={() => {
if (consentId) void runDocumentDiscovery(consentId, 'fortnox', true)
}}
onImportDocuments={() => {
if (consentId) void runDocumentImport(consentId)
}}
onDismissDocuments={() => dispatchDocumentImport({ type: 'dismissed' })}
onReconnectDocuments={handleDocumentReconnect}
/>
)}
</div>
)
}