diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx
index 2f993c01..14aa7deb 100644
--- a/app/(dashboard)/import/page.tsx
+++ b/app/(dashboard)/import/page.tsx
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
-import { ArrowLeftRight, FileText, ArrowLeft, Landmark, Loader2 } from 'lucide-react'
+import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2 } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
@@ -39,6 +39,12 @@ import type {
} from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
+import dynamic from 'next/dynamic'
+
+const MigrationWizard = dynamic(
+ () => import('@/components/extensions/general/ArcimMigrationWorkspace'),
+ { ssr: false, loading: () =>
Laddar migreringsverktyg...
}
+)
// ============================================================
// Bank File Import Wizard Steps
@@ -730,16 +736,36 @@ function PSD2ConnectWizard() {
// Import Page with Selection Cards
// ============================================================
-type ImportMode = null | 'psd2' | 'bank' | 'sie'
+type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration'
export default function ImportPage() {
const [mode, setMode] = useState(null)
+ const [userId, setUserId] = useState('')
+
+ // Fetch authenticated user ID for migration wizard
+ useEffect(() => {
+ const supabase = createClient()
+ supabase.auth.getUser().then(({ data: { user } }) => {
+ if (user) setUserId(user.id)
+ })
+ }, [])
+
+ // Auto-detect OAuth callback from migration extension
+ useEffect(() => {
+ if (new URLSearchParams(window.location.search).get('migration')) {
+ setMode('migration')
+ }
+ }, [])
// If extension isn't compiled in, we know synchronously it's unavailable
const bankingCompiledIn = ENABLED_EXTENSION_IDS.has('enable-banking')
const [hasBankingExtension, setHasBankingExtension] = useState(
bankingCompiledIn ? null : false
)
+ // Migration extension: show card when compiled in (no DB toggle needed,
+ // since the extensions marketplace is not exposed in the UI)
+ const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration')
+
useEffect(() => {
if (!bankingCompiledIn) return
fetch('/api/extensions/toggles/general/enable-banking')
@@ -761,7 +787,7 @@ export default function ImportPage() {
{mode === null && (
-
+
{hasBankingExtension === null && (
@@ -844,6 +870,31 @@ export default function ImportPage() {
+
+ {hasMigrationExtension === true && (
+
setMode('migration')}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('migration') } }}
+ >
+
+
+
+
Migrera från annat system
+
+ Flytta bokföring, kunder, leverantörer och fakturor från Fortnox, Visma, Bokio, Björn Lundén eller Briox.
+
+
+
+ SIE-data, kunder, leverantörer, fakturor
+
+
+
+ )}
)}
@@ -857,6 +908,7 @@ export default function ImportPage() {
{mode === 'psd2' &&
}
{mode === 'bank' &&
}
{mode === 'sie' &&
}
+ {mode === 'migration' &&
}
)
}
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index b1f30cc5..ff0d01fb 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { RecaptIdentify } from '@/components/RecaptIdentify'
+import { SentryIdentify } from '@/components/SentryIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import type { EntityType } from '@/types'
@@ -60,6 +61,7 @@ export default async function DashboardLayout({
{children}
+
{!isSandbox && (
0,
hasInvoices: (invoiceCount || 0) > 0,
- hasReceipts: (receiptCount || 0) > 0,
hasBankConnected: (transactionCount || 0) > 0,
}
diff --git a/app/api/import/sie/[id]/route.ts b/app/api/import/sie/[id]/route.ts
index 028579d0..23c5ed8f 100644
--- a/app/api/import/sie/[id]/route.ts
+++ b/app/api/import/sie/[id]/route.ts
@@ -40,7 +40,12 @@ export async function GET(
/**
* DELETE /api/import/sie/[id]
- * Delete an import record (does not delete created journal entries)
+ * Delete an import record.
+ *
+ * Only failed or pending imports can be deleted. Completed imports have created
+ * journal entries that are part of räkenskapsinformation — deleting the metadata
+ * without reversing entries would leave orphaned bookkeeping data, and deleting
+ * both is prohibited under BFL 7 kap (7-year retention).
*/
export async function DELETE(
request: Request,
@@ -57,6 +62,24 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check current status before deleting
+ const { data: importRecord } = await supabase
+ .from('sie_imports')
+ .select('status')
+ .eq('id', id)
+ .eq('user_id', user.id)
+ .single()
+
+ if (!importRecord) {
+ return NextResponse.json({ error: 'Import not found' }, { status: 404 })
+ }
+
+ if (importRecord.status === 'completed') {
+ return NextResponse.json({
+ error: 'Slutförd import kan inte raderas. Importerade verifikationer ingår i räkenskapsinformationen (BFL 7 kap).',
+ }, { status: 403 })
+ }
+
const { error } = await supabase
.from('sie_imports')
.delete()
diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts
index 4655612d..667d8f51 100644
--- a/app/api/import/sie/parse/route.ts
+++ b/app/api/import/sie/parse/route.ts
@@ -7,7 +7,7 @@ import {
decodeBuffer,
calculateFileHash,
} from '@/lib/import/sie-parser'
-import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper'
+import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
@@ -78,6 +78,15 @@ export async function POST(request: Request) {
}, { status: 400 })
}
+ // Separate source-system internal accounts (e.g. Fortnox 0099) from
+ // real bookkeeping accounts. System accounts have no BAS equivalent and
+ // should not appear in the mapping step.
+ const excludedSystemAccounts = parsed.accounts
+ .filter((a) => isSystemAccount(a.number))
+ .map((a) => ({ number: a.number, name: a.name }))
+ const bookkeepingAccounts = parsed.accounts
+ .filter((a) => !isSystemAccount(a.number))
+
// Fetch stored mappings from database
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
@@ -88,13 +97,15 @@ export async function POST(request: Request) {
// the user's active chart (~40 accounts). Accounts that match will be
// auto-activated during the execute step.
const mappings = suggestMappings(
- parsed.accounts,
+ bookkeepingAccounts,
BAS_REFERENCE,
(storedMappings as SIEAccountMappingRecord[]) || undefined
)
// Generate preview
const preview = generateImportPreview(parsed, mappings)
+ preview.excludedSystemAccounts = excludedSystemAccounts
+ preview.accountCount = bookkeepingAccounts.length
// Calculate file hash for storage
const fileHash = await calculateFileHash(content)
diff --git a/app/global-error.tsx b/app/global-error.tsx
new file mode 100644
index 00000000..b76eaa64
--- /dev/null
+++ b/app/global-error.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import * as Sentry from "@sentry/nextjs";
+import { useEffect } from "react";
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ }, [error]);
+
+ return (
+
+
+
+
+
Något gick fel
+
+ Ett oväntat fel inträffade. Försök igen.
+
+
+ Försök igen
+
+
+
+
+
+ );
+}
diff --git a/app/sentry-example-page/page.tsx b/app/sentry-example-page/page.tsx
new file mode 100644
index 00000000..ab18fdf1
--- /dev/null
+++ b/app/sentry-example-page/page.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import * as Sentry from "@sentry/nextjs";
+
+export default function SentryExamplePage() {
+ return (
+
+
+
Sentry Test
+
+ Click the button to send a test error to Sentry.
+
+
{
+ Sentry.captureException(
+ new Error("Sentry test error from gnubok")
+ );
+ alert("Test error sent to Sentry!");
+ }}
+ >
+ Throw Test Error
+
+
+
+ );
+}
diff --git a/components/SentryIdentify.tsx b/components/SentryIdentify.tsx
new file mode 100644
index 00000000..4548c70e
--- /dev/null
+++ b/components/SentryIdentify.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import * as Sentry from "@sentry/nextjs";
+import { useEffect } from "react";
+
+export function SentryIdentify({
+ userId,
+ email,
+}: {
+ userId: string;
+ email?: string;
+}) {
+ useEffect(() => {
+ Sentry.setUser({ id: userId, email });
+ return () => {
+ Sentry.setUser(null);
+ };
+ }, [userId, email]);
+
+ return null;
+}
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 07b12ece..4564521c 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -252,7 +252,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
hasCustomers={onboardingProgress.hasCustomers}
hasInvoices={onboardingProgress.hasInvoices}
hasBankConnected={onboardingProgress.hasBankConnected}
- hasReceipts={onboardingProgress.hasReceipts}
/>
)}
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx
new file mode 100644
index 00000000..739441e9
--- /dev/null
+++ b/components/extensions/general/ArcimMigrationWorkspace.tsx
@@ -0,0 +1,1380 @@
+'use client'
+
+import { useState, useCallback, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
+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 { useToast } from '@/components/ui/use-toast'
+import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
+import Link from 'next/link'
+import {
+ ArrowLeft,
+ ArrowRight,
+ Loader2,
+ AlertCircle,
+ CheckCircle,
+ Building2,
+ Users,
+ Truck,
+ FileText,
+ Database,
+ ExternalLink,
+ Info,
+ RotateCcw,
+} from 'lucide-react'
+import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
+
+type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
+
+const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
+ { id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
+ { id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
+ { id: 'bokio', name: 'Bokio', authType: 'token' },
+ { id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
+ { id: 'briox', name: 'Briox', authType: 'token' },
+]
+
+interface MigrationResults {
+ companyInfo?: { imported: boolean }
+ customers?: { total: number; imported: number; skipped: number }
+ suppliers?: { total: number; imported: number; skipped: number }
+ salesInvoices?: { total: number; imported: number; skipped: number }
+ supplierInvoices?: { total: number; imported: number; skipped: number }
+}
+import AccountMappingStep from '@/components/import/AccountMappingStep'
+import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
+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 = {
+ provider: 'Välj system',
+ connect: 'Anslut',
+ preview: 'Förhandsgranskning',
+ mapping: 'Kontomappning',
+ options: 'Alternativ',
+ migrating: 'Migrerar',
+ result: 'Resultat',
+}
+
+const MONTH_NAMES = [
+ 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
+ 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
+]
+
+interface MigrationOptions {
+ importCompanyInfo: boolean
+ importSIEData: boolean
+ importCustomers: boolean
+ importSuppliers: boolean
+ importSalesInvoices: boolean
+ importSupplierInvoices: boolean
+ voucherSeries: string
+}
+
+const DEFAULT_OPTIONS: MigrationOptions = {
+ importCompanyInfo: true,
+ importSIEData: true,
+ importCustomers: true,
+ importSuppliers: true,
+ importSalesInvoices: true,
+ importSupplierInvoices: 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
+}
+
+interface SIEData {
+ parsed: ParsedSIEFile
+ mappings: AccountMapping[]
+ mappingStats: { total: number; mapped: number; unmapped: number }
+ rawContent: string[]
+ basAccounts: BASAccount[]
+}
+
+// ── Provider selection step ──────────────────────────────────────
+
+const COMING_SOON_PROVIDERS = new Set(['visma', 'bjornlunden', 'briox'])
+
+const PROVIDER_LOGOS: Record = {
+ fortnox: '/logos/fortnox.svg',
+ visma: '/logos/visma.jpeg',
+ bokio: '/logos/bokio.png',
+ bjornlunden: '/logos/bjornlunden.png',
+ briox: '/logos/Briox_logo.png',
+}
+
+function ProviderStep({ onSelect }: { onSelect: (provider: ArcimProvider) => void }) {
+ return (
+
+
+
+ Välj ditt nuvarande bokföringssystem
+
+ Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et.
+
+
+
+
+ {ARCIM_PROVIDERS.map((provider) => {
+ const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
+ return (
+
!comingSoon && onSelect(provider.id)}
+ >
+
+
+
+
{provider.name}
+ {comingSoon && (
+
+ Kommer snart
+
+ )}
+
+
+ {provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
+
+// ── Connect step (OAuth redirect or token input) ────────────────
+
+function ConnectStep({
+ provider,
+ authType,
+ isLoading,
+ error,
+ authUrl,
+ consentId,
+ onTokenSubmit,
+ onBack,
+}: {
+ provider: ArcimProvider
+ authType: 'oauth' | 'token' | null
+ isLoading: boolean
+ error: string | null
+ authUrl: string | null
+ consentId: string | null
+ onTokenSubmit: (apiToken: string, companyId: string) => void
+ onBack: () => void
+}) {
+ const providerName = ARCIM_PROVIDERS.find(p => p.id === provider)?.name ?? provider
+ const [apiToken, setApiToken] = useState('')
+ const [companyId, setCompanyId] = useState('')
+
+ // BL uses server-side client credentials — only needs company ID, no API key
+ const isClientCredentials = provider === 'bjornlunden'
+ const needsApiToken = !isClientCredentials
+ const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden'
+
+ const tokenDescription = isClientCredentials
+ ? `Ange ditt företags-ID (GUID) från Björn Lundén. gnubok ansluter automatiskt via sin integrationspartner-åtkomst.`
+ : `Ange din API-nyckel från ${providerName} för att ge gnubok tillgång att läsa din bokföringsdata.`
+
+ const tokenHelpText = isClientCredentials
+ ? `Hittas i Björn Lundén under Inställningar \u2192 Företagsinformation (GUID-format).`
+ : provider === 'bokio'
+ ? `Du hittar din API-nyckel i ${providerName} under Inställningar \u2192 Integrationer \u2192 API. Ditt företags-ID är det GUID som syns i URL:en när du är inloggad, t.ex. https://app.bokio.se/ditt-företags-id/settings-r/private-integrations.`
+ : `Du hittar din applikationstoken i ${providerName} under Administration \u2192 Integrationer.`
+
+ const canSubmit = isClientCredentials
+ ? !!companyId
+ : !!(apiToken && (!needsCompanyId || companyId))
+
+ return (
+
+
+
+ Anslut till {providerName}
+
+ {authType === 'token'
+ ? tokenDescription
+ : `Logga in i ${providerName} för att ge gnubok tillgång att läsa din bokföringsdata.`
+ }
+
+
+
+ {isLoading && (
+
+
+
Förbereder anslutning...
+
+ )}
+
+ {error && (
+
+
+
+
Anslutning misslyckades
+
{error}
+ {provider === 'fortnox' && (
+
+ 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.
+
+ )}
+
+
+ )}
+
+ {/* OAuth flow */}
+ {authType === 'oauth' && authUrl && !isLoading && (
+
+
+ Klicka nedan för att logga in i {providerName} i ett nytt fönster.
+ När du är klar skickas du tillbaka hit automatiskt.
+
+
+
+ Logga in i {providerName}
+
+
+
+
+ )}
+
+ {/* Token-based flow */}
+ {authType === 'token' && consentId && !isLoading && (
+
+
+ {tokenHelpText}
+
+
+ {needsApiToken && (
+
+
+ {provider === 'briox' ? 'Applikationstoken' : 'API-nyckel'}
+
+ setApiToken(e.target.value)}
+ />
+
+ )}
+ {needsCompanyId && (
+
+
+ Företags-ID
+
+ setCompanyId(e.target.value)}
+ />
+
+ )}
+
onTokenSubmit(apiToken, companyId)}
+ disabled={!canSubmit}
+ >
+ Anslut
+
+
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+// ── Preview step ────────────────────────────────────────────────
+
+function PreviewStep({
+ preview,
+ isLoading,
+ error,
+ onContinue,
+ onBack,
+}: {
+ preview: PreviewData | null
+ isLoading: boolean
+ error: string | null
+ onContinue: () => void
+ onBack: () => void
+}) {
+ const providerName = preview
+ ? ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? preview.consent.provider
+ : ''
+
+ return (
+
+
+
+ Anslutet till {providerName}
+
+ Vi har hämtat information om ditt företag. Kontrollera att det stämmer.
+
+
+
+ {isLoading && (
+
+
+
Hämtar företagsinformation och bokföringsdata...
+
+ )}
+
+ {error && (
+
+ )}
+
+ {preview?.companyInfo && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {preview && !preview.companyInfo && !isLoading && (
+
+ Ingen företagsinformation kunde hämtas. Du kan fylla i uppgifterna manuellt under Inställningar.
+
+ )}
+
+ {/* SIE stats summary */}
+ {preview?.sieAvailable && preview.sieStats && (
+
+
+
+
+ Hittade {preview.sieStats.accountCount} konton och {preview.sieStats.transactionCount} verifikationer
+
+
+ {preview.sieStats.fiscalYears.length === 1
+ ? `Räkenskapsår ${preview.sieStats.fiscalYears[0]}`
+ : `${preview.sieStats.fiscalYears.length} räkenskapsår: ${preview.sieStats.fiscalYears.join(', ')}`
+ }
+
+
+
+ )}
+
+ {preview && !preview.sieAvailable && !isLoading && (
+
+
+
+
SIE-hämtning inte tillgänglig
+
+ SIE-hämtning är inte tillgänglig för denna leverantör ännu. Du kan importera SIE-filen manuellt via SIE-importen.
+
+
+
+ )}
+
+
+
+
+
+
+ Tillbaka
+
+
+ Fortsätt
+
+
+
+
+ )
+}
+
+function InfoItem({ label, value }: { label: string; value: string | null }) {
+ return (
+
+
{label}
+
{value || '—'}
+
+ )
+}
+
+// ── Mapping step (wraps AccountMappingStep) ─────────────────────
+
+function MappingStep({
+ sieData,
+ isLoading,
+ error,
+ onMappingChange,
+ onContinue,
+ onBack,
+}: {
+ sieData: SIEData | null
+ isLoading: boolean
+ error: string | null
+ onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
+ onContinue: () => void
+ onBack: () => void
+}) {
+ if (isLoading) {
+ return (
+
+
+
+
+
Analyserar bokföringsdata och förbereder kontomappning...
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+
+
Kunde inte ladda SIE-data
+
{error}
+
+
+
+
+
+
+ Tillbaka
+
+
+ )
+ }
+
+ if (!sieData) return null
+
+ return (
+
+ )
+}
+
+// ── Options step ────────────────────────────────────────────────
+
+function OptionsStep({
+ options,
+ sieAvailable,
+ onChange,
+ onStart,
+ onBack,
+}: {
+ options: MigrationOptions
+ sieAvailable: boolean
+ onChange: (options: MigrationOptions) => void
+ onStart: () => void
+ onBack: () => void
+}) {
+ const [showConfirm, setShowConfirm] = useState(false)
+
+ const toggleOption = (key: keyof MigrationOptions) => {
+ onChange({ ...options, [key]: !options[key] })
+ }
+
+ 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')
+
+ return (
+
+
+
+ Vad vill du importera?
+
+ Bokföringsdata importeras via SIE-fil. Kunder, leverantörer och fakturor hämtas via API:et.
+
+
+
+ }
+ label="Företagsinformation"
+ description="Namn, organisationsnummer, adress"
+ checked={options.importCompanyInfo}
+ onChange={() => toggleOption('importCompanyInfo')}
+ />
+
+ {sieAvailable && (
+ <>
+ }
+ label="Bokföringsdata (SIE)"
+ description="Kontoplan, ingående balanser och verifikationer"
+ checked={options.importSIEData}
+ onChange={() => toggleOption('importSIEData')}
+ />
+ {options.importSIEData && (
+
+
+
+
+
+
Verifikationsserie
+
Serie för importerade verifikationer
+
+
onChange({ ...options, voucherSeries: e.target.value.toUpperCase() || 'B' })}
+ maxLength={2}
+ />
+
+ )}
+ >
+ )}
+
+ }
+ label="Kunder"
+ description="Kund-register med kontaktuppgifter"
+ checked={options.importCustomers}
+ onChange={() => toggleOption('importCustomers')}
+ />
+ }
+ label="Leverantörer"
+ description="Leverantör-register med bankuppgifter"
+ checked={options.importSuppliers}
+ onChange={() => toggleOption('importSuppliers')}
+ />
+ }
+ label="Kundfakturor (öppna)"
+ description="Obetalda kundfakturor"
+ checked={options.importSalesInvoices}
+ onChange={() => toggleOption('importSalesInvoices')}
+ />
+ }
+ label="Leverantörsfakturor (öppna)"
+ description="Obetalda leverantörsfakturor"
+ checked={options.importSupplierInvoices}
+ onChange={() => toggleOption('importSupplierInvoices')}
+ />
+
+
+
+
+
+
+ Tillbaka
+
+
setShowConfirm(true)} disabled={selectedItems.length === 0}>
+ Starta migrering
+
+
+
+
+
{
+ setShowConfirm(false)
+ onStart()
+ }}
+ isSubmitting={false}
+ title="Starta migrering"
+ warningText="Bokföringsdata, kunder, leverantörer och fakturor importeras till gnubok. Se till att ingen annan import pågår."
+ confirmLabel="Starta migrering"
+ >
+
+
Följande importeras:
+
+ {selectedItems.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+
+ )
+}
+
+function OptionRow({
+ icon,
+ label,
+ description,
+ checked,
+ onChange,
+}: {
+ icon: React.ReactNode
+ label: string
+ description: string
+ checked: boolean
+ onChange: () => void
+}) {
+ return (
+
+
{icon}
+
+
{label}
+
{description}
+
+
e.stopPropagation()}
+ />
+
+ )
+}
+
+// ── Migrating step (progress) ───────────────────────────────────
+
+function MigratingStep({ currentStep, progress }: { currentStep: string; progress: number }) {
+ return (
+
+
+ Migrering pågår
+
+ Vänta medan vi hämtar och importerar din bokföringsdata. Det kan ta några minuter.
+
+
+
+
+
+
+
+ )
+}
+
+// ── Result step ─────────────────────────────────────────────────
+
+function ResultStep({
+ results,
+ sieResults,
+ error,
+ onDone,
+ onRetry,
+}: {
+ results: MigrationResults | null
+ sieResults: ImportResult[]
+ error: string | null
+ onDone: () => void
+ onRetry: () => void
+}) {
+ if (error) {
+ return (
+
+
+
+
+
+
+
Migreringen misslyckades
+
{error}
+
+
+
+
+
+ Klar
+
+
+ Försök igen
+
+
+
+ )
+ }
+
+ const hasResults = results || sieResults.length > 0
+ if (!hasResults) return null
+
+ // Compute combined SIE totals from all FY imports
+ const totalJournalEntries = sieResults.reduce((sum, r) => sum + r.journalEntriesCreated, 0)
+ const allSieErrors = sieResults.flatMap(r => r.errors)
+ const allSieWarnings = sieResults.flatMap(r => r.warnings)
+ const allSieSucceeded = sieResults.length > 0 && sieResults.every(r => r.success)
+
+ return (
+
+
+
+
+
+ Migrering klar
+
+
+ Din bokföringsdata har importerats till gnubok.
+
+
+
+
+ {/* SIE import results — combined summary */}
+ {sieResults.length > 0 && (
+ 0
+ ? `${totalJournalEntries} verifikationer skapade (${sieResults.length} räkenskapsår)`
+ : `${sieResults.length} räkenskapsår importerade`
+ }
+ errors={allSieErrors}
+ warnings={allSieWarnings}
+ />
+ )}
+
+ {/* API import results */}
+ {results?.companyInfo && (
+
+ )}
+ {results?.customers && (
+ 0 ? `${results.customers.skipped} fanns redan` : undefined}
+ />
+ )}
+ {results?.suppliers && (
+ 0 ? `${results.suppliers.skipped} fanns redan` : undefined}
+ />
+ )}
+ {results?.salesInvoices && (
+ 0 ? `${results.salesInvoices.skipped} hoppade` : undefined}
+ />
+ )}
+ {results?.supplierInvoices && (
+ 0 ? `${results.supplierInvoices.skipped} hoppade` : undefined}
+ />
+ )}
+
+
+
+
+ {/* Next steps guidance */}
+
+
+ Nästa steg
+
+
+
+
+ 1
+
+
+
Granska importerade verifikationer
+
Kontrollera att bokföringen ser korrekt ut
+
+
+
+
+ 2
+
+
+
Kontrollera kunder och leverantörer
+
Verifiera att kontaktuppgifter och bankinfo stämmer
+
+
+
+
+ 3
+
+
+
Verifiera balanserna i rapporterna
+
Jämför med ditt tidigare system
+
+
+
+
+
+
+
+
+ Ny migrering
+
+
+
+
+ Visa kunder
+
+
+
+
+
+ Visa bokföring
+
+
+
+
+
+
+ )
+}
+
+function ResultRow({
+ label,
+ value,
+ detail,
+ errors,
+ warnings,
+}: {
+ label: string
+ value: string
+ detail?: string
+ errors?: string[]
+ warnings?: string[]
+}) {
+ return (
+
+
{label}
+
{value}
+ {detail &&
{detail}
}
+ {warnings && warnings.length > 0 && (
+
{warnings.join('. ')}
+ )}
+ {errors && errors.length > 0 && (
+
+
+ {errors.length} fel
+
+
+ {errors.slice(0, 5).map((e, i) => {e} )}
+ {errors.length > 5 && ...och {errors.length - 5} till }
+
+
+ )}
+
+ )
+}
+
+// ── Main wizard ─────────────────────────────────────────────────
+
+export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) {
+ const { toast } = useToast()
+
+ const [step, setStep] = useState('provider')
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Connection state
+ const [selectedProvider, setSelectedProvider] = useState(null)
+ const [consentId, setConsentId] = useState(null)
+ const [authUrl, setAuthUrl] = useState(null)
+ const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null)
+
+ // Preview state
+ const [preview, setPreview] = useState(null)
+
+ // SIE data state (held between mapping and execution steps)
+ const [sieData, setSieData] = useState(null)
+
+ // Options state
+ const [migrationOptions, setMigrationOptions] = useState(DEFAULT_OPTIONS)
+
+ // Migration state
+ const [migrationStep, setMigrationStep] = useState('')
+ const [migrationProgress, setMigrationProgress] = useState(0)
+ const [migrationResults, setMigrationResults] = useState(null)
+ const [sieImportResults, setSieImportResults] = useState([])
+
+ // 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
+ const progressPercent = isInteractiveStep
+ ? ((currentUserStepIndex + 1) / userSteps.length) * 100
+ : 100
+
+ // ── Step handlers ──────────────────────────────────────────────
+
+ const loadPreview = useCallback(async (cId: string) => {
+ setStep('preview')
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const res = await fetch(`/api/extensions/ext/arcim-migration/preview?consentId=${cId}`)
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `HTTP ${res.status}`)
+ }
+
+ const data = await res.json()
+ setPreview(data)
+ setConsentId(cId)
+
+ // If SIE is not available, disable SIE import by default
+ if (!data.sieAvailable) {
+ setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Kunde inte hämta förhandsgranskning')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ const handleSelectProvider = useCallback(async (provider: ArcimProvider) => {
+ setSelectedProvider(provider)
+ setStep('connect')
+ setIsLoading(true)
+ setError(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(data.error || `HTTP ${res.status}`)
+ }
+
+ const data = await res.json()
+ setConsentId(data.consentId)
+ setAuthType(data.authType)
+
+ 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 ? err.message : 'Anslutning misslyckades')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ // 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 new Error(data.error || `HTTP ${res.status}`)
+ }
+
+ // Token stored — consent is now accepted, proceed to preview
+ await loadPreview(consentId)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : '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')
+
+ if (migrationStatus === 'connected' && callbackConsentId) {
+ // Clean URL
+ url.searchParams.delete('migration')
+ url.searchParams.delete('consentId')
+ window.history.replaceState({}, '', url.pathname)
+
+ await loadPreview(callbackConsentId)
+ } else if (migrationStatus === 'error') {
+ const callbackProvider = url.searchParams.get('provider') as ArcimProvider | null
+ url.searchParams.delete('migration')
+ url.searchParams.delete('provider')
+ window.history.replaceState({}, '', url.pathname)
+ setError('OAuth-anslutningen misslyckades. Försök igen.')
+ if (callbackProvider) {
+ setSelectedProvider(callbackProvider)
+ setStep('connect')
+ } else {
+ setStep('provider')
+ }
+ }
+ }, [loadPreview])
+
+ // Check for OAuth callback on mount
+ useEffect(() => {
+ handleOAuthReturn()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // Load SIE data when entering mapping step
+ const loadSIEData = useCallback(async () => {
+ if (!consentId) return
+
+ setStep('mapping')
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const res = await fetch(`/api/extensions/ext/arcim-migration/sie-data?consentId=${consentId}`)
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `HTTP ${res.status}`)
+ }
+
+ const data = await res.json()
+ setSieData(data)
+
+ // Auto-skip mapping step if all accounts are mapped
+ if (data.mappingStats.unmapped === 0) {
+ setStep('options')
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Kunde inte hämta SIE-data')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [consentId])
+
+ 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 = sieData.mappings.map(m =>
+ m.sourceAccount === sourceAccount
+ ? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
+ : m
+ )
+ 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 handleStartMigration = useCallback(async () => {
+ if (!consentId) return
+
+ setStep('migrating')
+ setMigrationStep('Startar migrering...')
+ setMigrationProgress(5)
+ setError(null)
+
+ try {
+ // ── Phase 1: SIE import ──────────────────────────────────
+ if (migrationOptions.importSIEData && sieData && sieData.rawContent.length > 0) {
+ setMigrationStep('Importerar bokföringsdata (SIE)...')
+ setMigrationProgress(10)
+ setSieImportResults([])
+
+ // Import all fiscal years' SIE content
+ for (let i = 0; i < sieData.rawContent.length; i++) {
+ const progress = 10 + Math.round((i / sieData.rawContent.length) * 40)
+ setMigrationProgress(progress)
+ setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${sieData.rawContent.length}...`)
+
+ const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ rawContent: sieData.rawContent[i],
+ mappings: sieData.mappings,
+ options: {
+ createFiscalPeriod: true,
+ importOpeningBalances: true,
+ importTransactions: true,
+ voucherSeries: migrationOptions.voucherSeries,
+ },
+ }),
+ })
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `SIE import HTTP ${res.status}`)
+ }
+
+ const result = await res.json() as ImportResult
+ setSieImportResults(prev => [...prev, result])
+
+ if (!result.success && result.errors.length > 0) {
+ // Log but don't fail — continue with API import
+ console.warn('SIE import warnings:', result.errors)
+ }
+ }
+ }
+
+ // ── Phase 2: API import (customers, suppliers, invoices) ──
+ const hasApiImport = migrationOptions.importCompanyInfo ||
+ migrationOptions.importCustomers ||
+ migrationOptions.importSuppliers ||
+ migrationOptions.importSalesInvoices ||
+ migrationOptions.importSupplierInvoices
+
+ 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' },
+ body: JSON.stringify({
+ consentId,
+ importCompanyInfo: migrationOptions.importCompanyInfo,
+ importCustomers: migrationOptions.importCustomers,
+ importSuppliers: migrationOptions.importSuppliers,
+ importSalesInvoices: migrationOptions.importSalesInvoices,
+ importSupplierInvoices: migrationOptions.importSupplierInvoices,
+ }),
+ })
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `HTTP ${res.status}`)
+ }
+
+ const data = await res.json()
+ setMigrationResults(data.results)
+ }
+
+ setMigrationProgress(100)
+ setStep('result')
+
+ toast({
+ title: 'Migrering klar',
+ description: 'Din bokföringsdata har importerats.',
+ })
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Migrering misslyckades'
+ setError(msg)
+ setStep('result')
+ }
+ }, [consentId, migrationOptions, 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([])
+ setError(null)
+ }, [])
+
+ // ── Render ─────────────────────────────────────────────────────
+
+ return (
+
+ {/* Progress bar — only during interactive steps */}
+ {step !== 'provider' && isInteractiveStep && (
+
+
+
+
+ {userSteps.map((s) => (
+
+ {STEP_LABELS[s]}
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Step content */}
+ {step === 'provider' && (
+
+ )}
+
+ {step === 'connect' && selectedProvider && (
+
{
+ setStep('provider')
+ setError(null)
+ }}
+ />
+ )}
+
+ {step === 'preview' && (
+ setStep('provider')}
+ />
+ )}
+
+ {step === 'mapping' && (
+ setStep('options')}
+ onBack={() => setStep('preview')}
+ />
+ )}
+
+ {step === 'options' && (
+ preview?.sieAvailable ? setStep('mapping') : setStep('preview')}
+ />
+ )}
+
+ {step === 'migrating' && (
+
+ )}
+
+ {step === 'result' && (
+ {
+ setError(null)
+ setStep('options')
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/components/import/SIEPreviewStep.tsx b/components/import/SIEPreviewStep.tsx
index 6ecf72b9..c9a92ddd 100644
--- a/components/import/SIEPreviewStep.tsx
+++ b/components/import/SIEPreviewStep.tsx
@@ -13,6 +13,7 @@ import {
XCircle,
ArrowRight,
BarChart3,
+ Info,
} from 'lucide-react'
import type { ImportPreview, ParseIssue } from '@/lib/import/types'
@@ -226,6 +227,16 @@ export default function SIEPreviewStep({
+ {/* Excluded system accounts info */}
+ {preview.excludedSystemAccounts.length > 0 && (
+
+
+
+ {preview.excludedSystemAccounts.length} internt systemkonto från källsystemet exkluderades ({preview.excludedSystemAccounts.map((a) => a.number).join(', ')}) — inte bokföringskonton
+
+
+ )}
+
{/* Create missing accounts */}
{missingAccounts.length > 0 && (
diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx
index ac28a95d..51cbe482 100644
--- a/components/onboarding/NewUserChecklist.tsx
+++ b/components/onboarding/NewUserChecklist.tsx
@@ -25,7 +25,6 @@ interface NewUserChecklistProps {
hasCustomers: boolean
hasInvoices: boolean
hasBankConnected: boolean
- hasReceipts: boolean
onDismiss?: () => void
className?: string
}
@@ -36,7 +35,6 @@ export default function NewUserChecklist({
hasCustomers,
hasInvoices,
hasBankConnected,
- hasReceipts,
onDismiss,
className,
}: NewUserChecklistProps) {
@@ -78,13 +76,6 @@ export default function NewUserChecklist({
href: '/import',
completed: hasBankConnected,
},
- {
- id: 'receipt',
- label: 'Skanna ditt första kvitto',
- description: 'Fotografera för automatisk bokföring',
- href: '/receipts/scan',
- completed: hasReceipts,
- },
]
const completedCount = items.filter((item) => item.completed).length
diff --git a/extensions.config.json b/extensions.config.json
index 1d58942c..6a80320b 100644
--- a/extensions.config.json
+++ b/extensions.config.json
@@ -1 +1 @@
-{"$schema":"./extensions.schema.json","extensions":["enable-banking","email"]}
+{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration"]}
diff --git a/extensions.schema.json b/extensions.schema.json
index cddeed4f..35dfb8ef 100644
--- a/extensions.schema.json
+++ b/extensions.schema.json
@@ -23,7 +23,8 @@
"invoice-inbox",
"calendar",
"enable-banking",
- "email"
+ "email",
+ "arcim-migration"
]
},
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts
new file mode 100644
index 00000000..c3658178
--- /dev/null
+++ b/extensions/general/arcim-migration/index.ts
@@ -0,0 +1,572 @@
+import type { Extension, ExtensionContext } from '@/lib/extensions/types'
+import { NextResponse } from 'next/server'
+import {
+ createConsent,
+ getConsent,
+ generateOtc,
+ getAuthUrl,
+ exchangeAuthToken,
+ submitProviderToken,
+ deleteConsent,
+ fetchCompanyInfo,
+ fetchSIEExport,
+} from './lib/arcim-client'
+import { mapCompanyInfo } from './lib/entity-mapper'
+import { executeMigration } from './lib/migration-orchestrator'
+import type { ArcimProvider } from './types'
+import { ARCIM_PROVIDERS } from './types'
+import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
+import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
+import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
+import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
+
+/**
+ * Arcim Migration extension
+ *
+ * Migrates bookkeeping data from external Swedish accounting systems
+ * (Fortnox, Visma, Bokio, Björn Lundén, Briox) into gnubok via
+ * the Arcim Sync unified API gateway.
+ *
+ * Bookkeeping data (accounts, balances, vouchers) is imported via SIE
+ * files fetched from the gateway. Entity data (customers, suppliers,
+ * invoices) is imported via the REST API.
+ *
+ * Required environment variables:
+ * - ARCIM_SYNC_GATEWAY_URL
+ * - ARCIM_SYNC_API_KEY
+ */
+export const arcimMigrationExtension: Extension = {
+ id: 'arcim-migration',
+ name: 'Systemmigration (Arcim Sync)',
+ version: '1.0.0',
+
+ apiRoutes: [
+ // ── List available providers ───────────────────────────────────
+ {
+ method: 'GET',
+ path: '/providers',
+ handler: async () => {
+ return NextResponse.json({ providers: ARCIM_PROVIDERS })
+ },
+ },
+
+ // ── Start consent flow (create consent + OTC) ─────────────────
+ {
+ method: 'POST',
+ path: '/connect',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { provider, companyName, orgNumber } = await request.json() as {
+ provider: ArcimProvider
+ companyName?: string
+ orgNumber?: string
+ }
+
+ if (!provider) {
+ return NextResponse.json({ error: 'provider is required' }, { status: 400 })
+ }
+
+ const providerInfo = ARCIM_PROVIDERS.find(p => p.id === provider)
+ if (!providerInfo) {
+ return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })
+ }
+
+ try {
+ // Create consent in Arcim Sync
+ const consent = await createConsent(
+ provider,
+ `gnubok-migration-${user.id}`,
+ orgNumber,
+ companyName
+ )
+
+ // Store consent ID in extension settings for this user
+ if (ctx?.settings) {
+ await ctx.settings.set('consent_id', consent.id)
+ await ctx.settings.set('provider', provider)
+ }
+
+ if (providerInfo.authType === 'oauth') {
+ // Generate OTC for OAuth flow
+ const otc = await generateOtc(consent.id)
+
+ // Get OAuth URL from Arcim (redirect URI is configured server-side in the gateway)
+ const { url } = await getAuthUrl(provider, otc.code)
+
+ return NextResponse.json({
+ consentId: consent.id,
+ authType: 'oauth',
+ authUrl: url,
+ otcCode: otc.code,
+ })
+ } else {
+ // Token-based providers: consent is ready for direct use
+ return NextResponse.json({
+ consentId: consent.id,
+ authType: 'token',
+ })
+ }
+ } catch (error) {
+ log.error('Failed to create consent:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Failed to connect' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+
+ // ── Submit API token for token-based providers (Bokio, etc.) ──
+ {
+ method: 'POST',
+ path: '/submit-token',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { consentId, provider, apiToken, companyId } = await request.json() as {
+ consentId: string
+ provider: ArcimProvider
+ apiToken: string
+ companyId?: string
+ }
+
+ if (!consentId || !provider) {
+ return NextResponse.json(
+ { error: 'consentId and provider are required' },
+ { status: 400 }
+ )
+ }
+
+ // BL uses server-side client credentials — only needs companyId
+ // Bokio and Briox need an API token
+ if (provider !== 'bjornlunden' && !apiToken) {
+ return NextResponse.json(
+ { error: 'apiToken is required for this provider' },
+ { status: 400 }
+ )
+ }
+
+ // Bokio and BL require companyId
+ if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) {
+ return NextResponse.json(
+ { error: 'companyId is required for this provider' },
+ { status: 400 }
+ )
+ }
+
+ try {
+ await submitProviderToken(consentId, provider, apiToken || 'client_credentials', companyId)
+ return NextResponse.json({ success: true, consentId })
+ } catch (error) {
+ log.error('Submit token error:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Failed to submit token' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+
+ // ── OAuth callback ────────────────────────────────────────────
+ {
+ method: 'GET',
+ path: '/callback',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const url = new URL(request.url)
+ const code = url.searchParams.get('code')
+ const state = url.searchParams.get('state') // OTC code
+
+ if (!code || !state) {
+ return NextResponse.json({ error: 'Missing code or state' }, { status: 400 })
+ }
+
+ try {
+ // The state is the OTC code, and the code is the OAuth auth code
+ // Exchange with the Arcim gateway
+ const consentId = ctx?.settings
+ ? await ctx.settings.get('consent_id')
+ : null
+ const provider = ctx?.settings
+ ? await ctx.settings.get('provider')
+ : null
+
+ if (!consentId || !provider) {
+ return NextResponse.json({ error: 'No active migration session' }, { status: 400 })
+ }
+
+ await exchangeAuthToken(consentId, provider, state, code)
+
+ // Redirect to import page with success
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
+ return NextResponse.redirect(`${appUrl}/import?migration=connected&consentId=${consentId}`)
+ } catch (error) {
+ log.error('OAuth callback error:', error)
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
+ return NextResponse.redirect(`${appUrl}/import?migration=error`)
+ }
+ },
+ },
+
+ // ── Preview: fetch company info + SIE stats before migration ──
+ {
+ method: 'GET',
+ path: '/preview',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const url = new URL(request.url)
+ const consentId = url.searchParams.get('consentId')
+
+ if (!consentId) {
+ return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
+ }
+
+ try {
+ // Verify consent is accepted
+ const consent = await getConsent(consentId)
+ if (consent.status !== 1) {
+ return NextResponse.json(
+ { error: 'Consent is not accepted. Complete OAuth first.' },
+ { status: 400 }
+ )
+ }
+
+ // Fetch company info for preview
+ const companyInfo = await fetchCompanyInfo(consentId)
+ const mapped = companyInfo ? mapCompanyInfo(companyInfo) : null
+
+ // Try to fetch SIE stats
+ let sieAvailable = false
+ let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
+
+ try {
+ log.info(`Fetching SIE export for consent ${consentId}...`)
+ const sieResult = await fetchSIEExport(consentId, 4)
+ log.info(`SIE export response: ${sieResult.files.length} files returned`)
+ if (sieResult.files.length > 0) {
+ sieAvailable = true
+ const totalAccounts = Math.max(...sieResult.files.map(f => f.accountCount))
+ const totalTransactions = sieResult.files.reduce((sum, f) => sum + f.transactionCount, 0)
+ const fiscalYears = sieResult.files.map(f => f.fiscalYear).sort()
+ sieStats = { accountCount: totalAccounts, transactionCount: totalTransactions, fiscalYears }
+ log.info(`SIE stats: ${totalAccounts} accounts, ${totalTransactions} transactions, years: ${fiscalYears.join(', ')}`)
+ } else {
+ log.info('SIE export returned empty files array')
+ }
+ } catch (err) {
+ log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
+ }
+
+ return NextResponse.json({
+ consent: {
+ id: consent.id,
+ provider: consent.provider,
+ status: consent.status,
+ companyName: consent.companyName,
+ },
+ companyInfo: mapped,
+ sieAvailable,
+ sieStats,
+ })
+ } catch (error) {
+ log.error('Preview error:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Preview failed' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+
+ // ── Fetch + parse SIE data for mapping step ───────────────────
+ {
+ method: 'GET',
+ path: '/sie-data',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const url = new URL(request.url)
+ const consentId = url.searchParams.get('consentId')
+
+ if (!consentId) {
+ return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
+ }
+
+ try {
+ // Fetch SIE from gateway
+ const sieResult = await fetchSIEExport(consentId, 4)
+ if (sieResult.files.length === 0) {
+ return NextResponse.json({ error: 'No SIE data available' }, { status: 404 })
+ }
+
+ // Parse most recent file for preview/validation
+ const sieFile = sieResult.files[sieResult.files.length - 1]
+ const parsed = parseSIEFile(sieFile.rawContent)
+ const validation = validateSIEFile(parsed)
+
+ // Collect ALL unique accounts across ALL fiscal year files
+ // so mappings cover every account that will be imported
+ const allAccountsMap = new Map()
+ for (const file of sieResult.files) {
+ const fileParsed = parseSIEFile(file.rawContent)
+ for (const acc of fileParsed.accounts) {
+ if (!allAccountsMap.has(acc.number)) {
+ allAccountsMap.set(acc.number, { number: acc.number, name: acc.name })
+ }
+ }
+ }
+ // Filter out source-system internal accounts (e.g. Fortnox 0099)
+ // that have no BAS equivalent — same as core SIE import
+ const allAccounts = [...allAccountsMap.values()]
+ .filter(a => !isSystemAccount(a.number))
+ .map(a => ({ number: a.number, name: a.name }))
+
+ // Load existing user mappings
+ const existingMappings = await loadMappings(supabase, user.id)
+ const existingRecords = [...existingMappings.values()].map(m => ({
+ id: '',
+ user_id: user.id,
+ source_account: m.sourceAccount,
+ source_name: m.sourceName,
+ target_account: m.targetAccount,
+ confidence: m.confidence,
+ match_type: m.matchType,
+ created_at: '',
+ updated_at: '',
+ }))
+
+ // Suggest mappings using accounts from ALL fiscal years
+ const basAccounts = BAS_REFERENCE.map(b => ({
+ account_number: b.account_number,
+ account_name: b.account_name,
+ }))
+ const mappings = suggestMappings(allAccounts, basAccounts, existingRecords)
+ const mappingStats = getMappingStats(mappings)
+
+ log.info(`Account mapping: ${allAccounts.length} unique accounts across ${sieResult.files.length} files, ${mappingStats.unmapped} unmapped`)
+
+ // Generate preview
+ const preview = generateImportPreview(parsed, mappings)
+
+ // Collect all raw SIE content (all fiscal years)
+ const allRawContent = sieResult.files.map(f => f.rawContent)
+
+ return NextResponse.json({
+ parsed,
+ mappings,
+ mappingStats,
+ preview,
+ validation,
+ rawContent: allRawContent,
+ basAccounts: BAS_REFERENCE,
+ })
+ } catch (error) {
+ log.error('SIE data fetch error:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Failed to fetch SIE data' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+
+ // ── Import SIE data (accounts, balances, vouchers) ────────────
+ {
+ method: 'POST',
+ path: '/import-sie',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { rawContent, mappings, options } = await request.json() as {
+ rawContent: string
+ mappings: import('@/lib/import/types').AccountMapping[]
+ options: {
+ createFiscalPeriod: boolean
+ importOpeningBalances: boolean
+ importTransactions: boolean
+ voucherSeries?: string
+ }
+ }
+
+ if (!rawContent || !mappings) {
+ return NextResponse.json({ error: 'rawContent and mappings are required' }, { status: 400 })
+ }
+
+ try {
+ // Parse the SIE content
+ const parsed = parseSIEFile(rawContent)
+
+ // Save the user's mappings for future use
+ await saveMappings(supabase, user.id, mappings)
+
+ // Execute the import via core engine
+ const result = await executeSIEImport(supabase, user.id, parsed, mappings, {
+ filename: `migration-sie-${Date.now()}.se`,
+ fileContent: rawContent,
+ createFiscalPeriod: options.createFiscalPeriod,
+ importOpeningBalances: options.importOpeningBalances,
+ importTransactions: options.importTransactions,
+ voucherSeries: options.voucherSeries,
+ })
+
+ log.info('SIE import completed:', {
+ success: result.success,
+ journalEntriesCreated: result.journalEntriesCreated,
+ errors: result.errors.length,
+ errorDetails: result.errors.slice(0, 10),
+ })
+
+ return NextResponse.json(result)
+ } catch (error) {
+ log.error('SIE import failed:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'SIE import failed' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+
+ // ── Execute entity migration (customers, suppliers, invoices) ──
+ {
+ method: 'POST',
+ path: '/migrate',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const {
+ consentId,
+ importCompanyInfo = true,
+ importCustomers = true,
+ importSuppliers = true,
+ importSalesInvoices = true,
+ importSupplierInvoices = true,
+ } = await request.json() as {
+ consentId: string
+ importCompanyInfo?: boolean
+ importCustomers?: boolean
+ importSuppliers?: boolean
+ importSalesInvoices?: boolean
+ importSupplierInvoices?: boolean
+ }
+
+ if (!consentId) {
+ return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
+ }
+
+ try {
+ // Verify consent
+ const consent = await getConsent(consentId)
+ if (consent.status !== 1) {
+ return NextResponse.json(
+ { error: 'Consent is not accepted' },
+ { status: 400 }
+ )
+ }
+
+ log.info(`Starting migration for user ${user.id} from ${consent.provider}`)
+
+ const results = await executeMigration({
+ consentId,
+ userId: user.id,
+ supabase,
+ importCompanyInfo,
+ importCustomers,
+ importSuppliers,
+ importSalesInvoices,
+ importSupplierInvoices,
+ })
+
+ log.info('Migration completed:', results)
+
+ return NextResponse.json({ success: true, results })
+ } catch (error) {
+ log.error('Migration failed:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Migration failed' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+
+ // ── Disconnect / revoke consent ───────────────────────────────
+ {
+ method: 'DELETE',
+ path: '/disconnect',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const log = ctx?.log ?? console
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { consentId } = await request.json() as { consentId: string }
+
+ if (!consentId) {
+ return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
+ }
+
+ try {
+ await deleteConsent(consentId)
+
+ // Clear stored consent from settings
+ if (ctx?.settings) {
+ await ctx.settings.set('consent_id', null)
+ await ctx.settings.set('provider', null)
+ }
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ log.error('Disconnect error:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Disconnect failed' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+ ],
+
+ eventHandlers: [],
+}
diff --git a/extensions/general/arcim-migration/lib/arcim-client.ts b/extensions/general/arcim-migration/lib/arcim-client.ts
new file mode 100644
index 00000000..6a2df9de
--- /dev/null
+++ b/extensions/general/arcim-migration/lib/arcim-client.ts
@@ -0,0 +1,215 @@
+/**
+ * HTTP client for the Arcim Sync gateway API.
+ *
+ * Targets the consent-based resource API (/api/v1/consents/...) which
+ * provides typed, normalized access to any Swedish accounting provider.
+ */
+
+import type {
+ ArcimProvider,
+ ConsentRecord,
+ OtcResponse,
+ PaginatedResponse,
+ CompanyInformationDto,
+ CustomerDto,
+ SupplierDto,
+ SalesInvoiceDto,
+ SupplierInvoiceDto,
+} from '../types'
+
+function getBaseUrl(): string {
+ const url = process.env.ARCIM_SYNC_GATEWAY_URL
+ if (!url) throw new Error('ARCIM_SYNC_GATEWAY_URL is not configured')
+ return url.replace(/\/$/, '')
+}
+
+function getApiKey(): string {
+ const key = process.env.ARCIM_SYNC_API_KEY
+ if (!key) throw new Error('ARCIM_SYNC_API_KEY is not configured')
+ return key
+}
+
+async function request(
+ path: string,
+ options: RequestInit = {}
+): Promise {
+ const url = `${getBaseUrl()}${path}`
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ 'Authorization': `Bearer ${getApiKey()}`,
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ })
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '')
+ throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`)
+ }
+
+ return response.json()
+}
+
+// ── Consent lifecycle ───────────────────────────────────────────────
+
+export async function createConsent(
+ provider: ArcimProvider,
+ name: string,
+ orgNumber?: string,
+ companyName?: string
+): Promise {
+ return request('/api/v1/consents', {
+ method: 'POST',
+ body: JSON.stringify({ name, provider, orgNumber, companyName }),
+ })
+}
+
+export async function getConsent(consentId: string): Promise {
+ return request(`/api/v1/consents/${consentId}`)
+}
+
+export async function generateOtc(
+ consentId: string,
+ expiresInMinutes: number = 60
+): Promise {
+ return request(`/api/v1/consents/${consentId}/otc`, {
+ method: 'POST',
+ body: JSON.stringify({ expiresInMinutes }),
+ })
+}
+
+export async function deleteConsent(consentId: string): Promise {
+ await request(`/api/v1/consents/${consentId}`, { method: 'DELETE' })
+}
+
+// ── OAuth helpers ───────────────────────────────────────────────────
+
+export async function getAuthUrl(
+ provider: ArcimProvider,
+ state?: string
+): Promise<{ url: string }> {
+ const params = new URLSearchParams()
+ if (state) params.set('state', state)
+ const qs = params.toString()
+ return request<{ url: string }>(`/api/v1/auth/${provider}/url${qs ? `?${qs}` : ''}`)
+}
+
+export async function exchangeAuthToken(
+ consentId: string,
+ provider: ArcimProvider,
+ otcCode: string,
+ oauthCode: string
+): Promise<{ success: boolean; consentId: string }> {
+ return request(`/api/v1/auth/${provider}/callback`, {
+ method: 'POST',
+ body: JSON.stringify({
+ code: oauthCode,
+ consentId,
+ otcCode,
+ }),
+ })
+}
+
+// ── Token-based auth (Bokio, Björn Lundén, Briox) ──────────────────
+
+export async function submitProviderToken(
+ consentId: string,
+ provider: ArcimProvider,
+ apiToken: string,
+ companyId?: string
+): Promise<{ success: boolean; consentId: string }> {
+ return request(`/api/v1/auth/${provider}/callback`, {
+ method: 'POST',
+ body: JSON.stringify({
+ code: apiToken,
+ consentId,
+ ...(companyId ? { companyId } : {}),
+ }),
+ })
+}
+
+// ── Resource fetching (paginated) ───────────────────────────────────
+
+async function fetchAllPages(
+ consentId: string,
+ resource: string,
+ params?: Record,
+ pageSize: number = 100
+): Promise {
+ const all: T[] = []
+ let page = 1
+
+ while (true) {
+ const query = new URLSearchParams({
+ page: String(page),
+ pageSize: String(pageSize),
+ ...params,
+ })
+ const result = await request>(
+ `/api/v1/consents/${consentId}/${resource}?${query}`
+ )
+ all.push(...result.data)
+
+ if (!result.hasMore || result.data.length === 0) break
+ page++
+ }
+
+ return all
+}
+
+// ── Typed resource accessors ────────────────────────────────────────
+
+export async function fetchCompanyInfo(
+ consentId: string
+): Promise {
+ // CompanyInformation is a singleton resource — gateway returns { data: object }
+ const result = await request<{ data: CompanyInformationDto }>(
+ `/api/v1/consents/${consentId}/companyinformation`
+ )
+ return result.data ?? null
+}
+
+export async function fetchCustomers(consentId: string): Promise {
+ return fetchAllPages(consentId, 'customers')
+}
+
+export async function fetchSuppliers(consentId: string): Promise {
+ return fetchAllPages(consentId, 'suppliers')
+}
+
+export async function fetchSalesInvoices(
+ consentId: string,
+ params?: Record
+): Promise {
+ return fetchAllPages(consentId, 'salesinvoices', params)
+}
+
+export async function fetchSupplierInvoices(
+ consentId: string,
+ params?: Record
+): Promise {
+ return fetchAllPages(consentId, 'supplierinvoices', params)
+}
+
+// ── SIE export ────────────────────────────────────────────────────
+
+export interface SIEExportFile {
+ fiscalYear: number
+ sieType: number
+ rawContent: string
+ accountCount: number
+ transactionCount: number
+}
+
+export async function fetchSIEExport(
+ consentId: string,
+ sieType?: number
+): Promise<{ files: SIEExportFile[] }> {
+ const params = new URLSearchParams()
+ if (sieType) params.set('sieType', String(sieType))
+ const qs = params.toString()
+ return request<{ files: SIEExportFile[] }>(
+ `/api/v1/consents/${consentId}/sie/export${qs ? `?${qs}` : ''}`
+ )
+}
diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts
new file mode 100644
index 00000000..066abd56
--- /dev/null
+++ b/extensions/general/arcim-migration/lib/entity-mapper.ts
@@ -0,0 +1,309 @@
+/**
+ * Maps Arcim Sync canonical DTOs to gnubok internal types.
+ *
+ * These mappers transform the normalized data from any Swedish accounting
+ * provider into the exact shapes gnubok expects for database insertion.
+ */
+
+import type { CustomerType, SupplierType, VatTreatment } from '@/types'
+import type {
+ CustomerDto,
+ SupplierDto,
+ SalesInvoiceDto,
+ SalesInvoiceLineDto,
+ SupplierInvoiceDto,
+ SupplierInvoiceLineDto,
+ CompanyInformationDto,
+ PostalAddress,
+ PartyDto,
+} from '../types'
+
+// ── Helpers ─────────────────────────────────────────────────────────
+
+function round2(n: number): number {
+ return Math.round(n * 100) / 100
+}
+
+function formatAddress(addr?: PostalAddress): {
+ address_line1: string | null
+ address_line2: string | null
+ postal_code: string | null
+ city: string | null
+ country: string | null
+} {
+ if (!addr) {
+ return { address_line1: null, address_line2: null, postal_code: null, city: null, country: null }
+ }
+ const line1 = [addr.streetName, addr.buildingNumber].filter(Boolean).join(' ') || null
+ return {
+ address_line1: line1,
+ address_line2: addr.additionalStreetName || null,
+ postal_code: addr.postalZone || null,
+ city: addr.cityName || null,
+ country: addr.countryCode || null,
+ }
+}
+
+function getOrgNumber(party: PartyDto): string | null {
+ // Look for SE:ORGNR scheme first, then companyId in legalEntity
+ const seOrg = party.identifications?.find(i => i.schemeId === 'SE:ORGNR')
+ if (seOrg) return seOrg.id
+ return party.legalEntity?.companyId || null
+}
+
+const EU_COUNTRIES = ['AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SI', 'SK']
+
+function inferTypeFromVatOrCountry(
+ vatNumber: string | undefined,
+ countryCode: string | undefined
+): 'swedish_business' | 'eu_business' | 'non_eu_business' {
+ // 1. VAT number prefix is the strongest signal
+ if (vatNumber) {
+ const prefix = vatNumber.substring(0, 2).toUpperCase()
+ if (prefix === 'SE') return 'swedish_business'
+ if (EU_COUNTRIES.includes(prefix)) return 'eu_business'
+ return 'non_eu_business'
+ }
+
+ // 2. Fall back to address country
+ const country = countryCode?.toUpperCase()
+ if (!country || country === 'SE') return 'swedish_business'
+ if (EU_COUNTRIES.includes(country)) return 'eu_business'
+ return 'non_eu_business'
+}
+
+function inferCustomerType(dto: CustomerDto): CustomerType {
+ if (dto.type === 'private') return 'individual'
+ return inferTypeFromVatOrCountry(dto.vatNumber, dto.party.postalAddress?.countryCode)
+}
+
+function inferSupplierType(dto: SupplierDto): SupplierType {
+ return inferTypeFromVatOrCountry(dto.vatNumber, dto.party.postalAddress?.countryCode)
+}
+
+function inferVatTreatment(taxPercent?: number, currencyCode?: string): VatTreatment {
+ if (taxPercent === 25) return 'standard_25'
+ if (taxPercent === 12) return 'reduced_12'
+ if (taxPercent === 6) return 'reduced_6'
+ if (taxPercent === 0 && currencyCode && currencyCode !== 'SEK') return 'export'
+ return 'standard_25'
+}
+
+function inferVatRate(taxPercent?: number): number {
+ if (taxPercent === 25 || taxPercent === 12 || taxPercent === 6) return taxPercent
+ if (taxPercent === 0) return 0
+ return 25 // Default to standard rate
+}
+
+// ── Public mappers ──────────────────────────────────────────────────
+
+export function mapCustomer(dto: CustomerDto, userId: string): Record {
+ const addr = formatAddress(dto.party.postalAddress)
+ return {
+ user_id: userId,
+ name: dto.party.name,
+ customer_type: inferCustomerType(dto),
+ email: dto.party.contact?.email || null,
+ phone: dto.party.contact?.telephone || null,
+ ...addr,
+ org_number: getOrgNumber(dto.party),
+ vat_number: dto.vatNumber || null,
+ vat_number_validated: false,
+ default_payment_terms: dto.defaultPaymentTermsDays || 30,
+ notes: dto.note || null,
+ }
+}
+
+export function mapSupplier(dto: SupplierDto, userId: string): Record {
+ const addr = formatAddress(dto.party.postalAddress)
+ return {
+ user_id: userId,
+ name: dto.party.name,
+ supplier_type: inferSupplierType(dto),
+ email: dto.party.contact?.email || null,
+ phone: dto.party.contact?.telephone || null,
+ ...addr,
+ org_number: getOrgNumber(dto.party),
+ vat_number: dto.vatNumber || null,
+ bankgiro: dto.bankGiro || null,
+ plusgiro: dto.plusGiro || null,
+ bank_account: dto.bankAccount || null,
+ iban: null,
+ bic: null,
+ default_expense_account: null,
+ default_payment_terms: dto.defaultPaymentTermsDays || 30,
+ default_currency: 'SEK',
+ notes: dto.note || null,
+ }
+}
+
+export function mapSalesInvoice(
+ dto: SalesInvoiceDto,
+ userId: string,
+ customerId: string
+): { invoice: Record; items: Record[] } {
+ const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
+ const total = round2(dto.legalMonetaryTotal.payableAmount.value)
+ const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
+
+ // Determine primary VAT treatment from first line with tax
+ const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
+ const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
+
+ // Map Arcim status to gnubok status
+ const statusMap: Record = {
+ draft: 'draft',
+ sent: 'sent',
+ booked: 'sent', // gnubok has no 'booked' status — treat as sent
+ paid: 'paid',
+ overdue: 'overdue',
+ cancelled: 'cancelled',
+ credited: 'credited',
+ }
+
+ const isCreditNote = dto.invoiceTypeCode === '381'
+
+ const invoice: Record = {
+ user_id: userId,
+ customer_id: customerId,
+ invoice_number: dto.invoiceNumber,
+ invoice_date: dto.issueDate,
+ due_date: dto.dueDate || dto.issueDate,
+ status: statusMap[dto.status] || 'sent',
+ currency: dto.currencyCode || 'SEK',
+ exchange_rate: dto.currencyCode === 'SEK' ? null : null,
+ subtotal,
+ subtotal_sek: dto.currencyCode === 'SEK' ? subtotal : null,
+ vat_amount: vatAmount,
+ vat_amount_sek: dto.currencyCode === 'SEK' ? vatAmount : null,
+ total,
+ total_sek: dto.currencyCode === 'SEK' ? total : null,
+ vat_treatment: vatTreatment,
+ vat_rate: inferVatRate(primaryTaxPercent),
+ your_reference: null,
+ our_reference: null,
+ notes: dto.note || null,
+ document_type: isCreditNote ? 'invoice' : 'invoice',
+ paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
+ paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
+ }
+
+ const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx))
+
+ return { invoice, items }
+}
+
+function mapSalesInvoiceLine(line: SalesInvoiceLineDto, index: number): Record {
+ return {
+ sort_order: index + 1,
+ description: line.description || line.itemName || '',
+ quantity: line.quantity || 1,
+ unit: line.unitCode || 'st',
+ unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
+ line_total: round2(line.lineExtensionAmount.value),
+ vat_rate: inferVatRate(line.taxPercent),
+ vat_amount: round2(line.taxAmount?.value ?? 0),
+ }
+}
+
+export function mapSupplierInvoice(
+ dto: SupplierInvoiceDto,
+ userId: string,
+ supplierId: string
+): { invoice: Record; items: Record[] } {
+ const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
+ const total = round2(dto.legalMonetaryTotal.payableAmount.value)
+ const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
+
+ const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
+ const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
+
+ const statusMap: Record = {
+ draft: 'registered',
+ sent: 'registered',
+ booked: 'registered',
+ paid: 'paid',
+ overdue: 'overdue',
+ cancelled: 'credited',
+ credited: 'credited',
+ }
+
+ const isCreditNote = dto.invoiceTypeCode === '381'
+
+ const invoice: Record = {
+ user_id: userId,
+ supplier_id: supplierId,
+ supplier_invoice_number: dto.invoiceNumber,
+ invoice_date: dto.issueDate,
+ due_date: dto.dueDate || dto.issueDate,
+ received_date: dto.issueDate,
+ delivery_date: dto.deliveryDate || null,
+ status: statusMap[dto.status] || 'registered',
+ currency: dto.currencyCode || 'SEK',
+ exchange_rate: dto.currencyCode === 'SEK' ? null : null,
+ subtotal,
+ subtotal_sek: dto.currencyCode === 'SEK' ? subtotal : null,
+ vat_amount: vatAmount,
+ vat_amount_sek: dto.currencyCode === 'SEK' ? vatAmount : null,
+ total,
+ total_sek: dto.currencyCode === 'SEK' ? total : null,
+ vat_treatment: vatTreatment,
+ reverse_charge: vatTreatment === 'reverse_charge',
+ payment_reference: dto.ocrNumber || null,
+ paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
+ paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
+ remaining_amount: round2(dto.paymentStatus.balance.value),
+ is_credit_note: isCreditNote,
+ notes: dto.note || null,
+ }
+
+ const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx))
+
+ return { invoice, items }
+}
+
+function mapSupplierInvoiceLine(line: SupplierInvoiceLineDto, index: number): Record {
+ return {
+ sort_order: index + 1,
+ description: line.description || line.itemName || '',
+ quantity: line.quantity || 1,
+ unit: line.unitCode || 'st',
+ unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
+ line_total: round2(line.lineExtensionAmount.value),
+ account_number: line.accountNumber || '4000', // Default to purchases
+ vat_rate: inferVatRate(line.taxPercent),
+ vat_amount: round2(line.taxAmount?.value ?? 0),
+ }
+}
+
+export function mapCompanyInfo(dto: CompanyInformationDto): {
+ 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
+} {
+ const addr = formatAddress(dto.address)
+ // Parse fiscal year start month from "MM-DD" format
+ let fiscalYearStartMonth = 1
+ if (dto.fiscalYearStart) {
+ const month = parseInt(dto.fiscalYearStart.split('-')[0], 10)
+ if (month >= 1 && month <= 12) fiscalYearStartMonth = month
+ }
+
+ return {
+ company_name: dto.companyName || null,
+ org_number: dto.organizationNumber || null,
+ vat_number: dto.vatNumber || null,
+ fiscal_year_start_month: fiscalYearStartMonth,
+ address_line1: addr.address_line1,
+ postal_code: addr.postal_code,
+ city: addr.city,
+ phone: dto.contact?.telephone || null,
+ email: dto.contact?.email || null,
+ }
+}
diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts
new file mode 100644
index 00000000..de625eab
--- /dev/null
+++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts
@@ -0,0 +1,449 @@
+/**
+ * Migration orchestrator — coordinates the data migration from
+ * an external accounting system via Arcim Sync into gnubok.
+ *
+ * Bookkeeping data (accounts, balances, vouchers) is now imported
+ * via SIE files through the core SIE import engine. This orchestrator
+ * handles only entity-level imports:
+ * 1. Company info → pre-fill company_settings
+ * 2. Customers → needed before sales invoices
+ * 3. Suppliers → needed before supplier invoices
+ * 4. Sales invoices (open only)
+ * 5. Supplier invoices (open only)
+ */
+
+import type { SupabaseClient } from '@supabase/supabase-js'
+import type { MigrationProgress, MigrationResults } from '../types'
+import {
+ fetchCompanyInfo,
+ fetchCustomers,
+ fetchSuppliers,
+ fetchSalesInvoices,
+ fetchSupplierInvoices,
+} from './arcim-client'
+import {
+ mapCustomer,
+ mapSupplier,
+ mapSalesInvoice,
+ mapSupplierInvoice,
+ mapCompanyInfo,
+} from './entity-mapper'
+
+export interface MigrationOptions {
+ consentId: string
+ userId: string
+ supabase: SupabaseClient
+ importCompanyInfo?: boolean
+ importCustomers?: boolean
+ importSuppliers?: boolean
+ importSalesInvoices?: boolean
+ importSupplierInvoices?: boolean
+ onProgress?: (progress: MigrationProgress) => void
+}
+
+function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
+ options.onProgress?.(progress)
+}
+
+// ── Main orchestrator ─────────────────────────────────────────────
+
+export async function executeMigration(options: MigrationOptions): Promise {
+ const { consentId, userId, supabase } = options
+ const results: MigrationResults = {}
+
+ try {
+ // ── Step 1: Company information ───────────────────────────────
+ if (options.importCompanyInfo !== false) {
+ emitProgress(options, { status: 'fetching', currentStep: 'Hämtar företagsinformation...', progress: 5 })
+ try {
+ const companyInfo = await fetchCompanyInfo(consentId)
+ if (companyInfo) {
+ const mapped = mapCompanyInfo(companyInfo)
+ const { data: existing } = await supabase
+ .from('company_settings')
+ .select('company_name, org_number, vat_number')
+ .eq('user_id', userId)
+ .single()
+
+ const updates: Record = {}
+ if (!existing?.company_name && mapped.company_name) updates.company_name = mapped.company_name
+ if (!existing?.org_number && mapped.org_number) updates.org_number = mapped.org_number
+ if (!existing?.vat_number && mapped.vat_number) {
+ updates.vat_number = mapped.vat_number
+ updates.vat_registered = true
+ }
+ if (mapped.fiscal_year_start_month !== 1) {
+ updates.fiscal_year_start_month = mapped.fiscal_year_start_month
+ }
+ if (mapped.address_line1) updates.address_line1 = mapped.address_line1
+ if (mapped.postal_code) updates.postal_code = mapped.postal_code
+ if (mapped.city) updates.city = mapped.city
+ if (mapped.phone) updates.phone = mapped.phone
+ if (mapped.email) updates.email = mapped.email
+
+ if (Object.keys(updates).length > 0) {
+ await supabase.from('company_settings').update(updates).eq('user_id', userId)
+ }
+ results.companyInfo = { imported: true }
+ }
+ } catch (err) {
+ console.error('Failed to import company info:', err)
+ results.companyInfo = { imported: false }
+ }
+ }
+
+ // ── Step 2: Customers ─────────────────────────────────────────
+ const customerIdMap = new Map()
+
+ if (options.importCustomers !== false) {
+ emitProgress(options, { status: 'importing', currentStep: 'Importerar kunder...', progress: 20 })
+ try {
+ const customers = await fetchCustomers(consentId)
+ let imported = 0
+ let skipped = 0
+
+ for (const customer of customers) {
+ if (!customer.active) {
+ console.log(`[migration] Customer skipped (inactive): ${customer.party.name}`)
+ skipped++
+ continue
+ }
+
+ const orgNumber = customer.party.legalEntity?.companyId ||
+ customer.party.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
+ if (orgNumber) {
+ const { data: existing } = await supabase
+ .from('customers')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('org_number', orgNumber)
+ .limit(1)
+
+ if (existing && existing.length > 0) {
+ console.log(`[migration] Customer skipped (duplicate org_number ${orgNumber}): ${customer.party.name}`)
+ customerIdMap.set(customer.id, existing[0].id)
+ skipped++
+ continue
+ }
+ }
+
+ const mapped = mapCustomer(customer, userId)
+ const { data: inserted, error } = await supabase
+ .from('customers')
+ .insert(mapped)
+ .select('id')
+ .single()
+
+ if (error || !inserted) {
+ console.error(`[migration] Customer insert failed: ${customer.party.name}`, error?.message)
+ skipped++
+ } else {
+ customerIdMap.set(customer.id, inserted.id)
+ imported++
+ }
+ }
+
+ results.customers = { total: customers.length, imported, skipped }
+ } catch (err) {
+ console.error('Failed to import customers:', err)
+ }
+ }
+
+ // ── Step 3: Suppliers ─────────────────────────────────────────
+ const supplierIdMap = new Map()
+
+ if (options.importSuppliers !== false) {
+ emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörer...', progress: 40 })
+ try {
+ const suppliers = await fetchSuppliers(consentId)
+ let imported = 0
+ let skipped = 0
+
+ for (const supplier of suppliers) {
+ if (!supplier.active) {
+ console.log(`[migration] Supplier skipped (inactive): ${supplier.party.name}`)
+ skipped++
+ continue
+ }
+
+ const orgNumber = supplier.party.legalEntity?.companyId ||
+ supplier.party.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
+ if (orgNumber) {
+ const { data: existing } = await supabase
+ .from('suppliers')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('org_number', orgNumber)
+ .limit(1)
+
+ if (existing && existing.length > 0) {
+ console.log(`[migration] Supplier skipped (duplicate org_number ${orgNumber}): ${supplier.party.name}`)
+ supplierIdMap.set(supplier.id, existing[0].id)
+ skipped++
+ continue
+ }
+ }
+
+ const mapped = mapSupplier(supplier, userId)
+ const { data: inserted, error } = await supabase
+ .from('suppliers')
+ .insert(mapped)
+ .select('id')
+ .single()
+
+ if (error || !inserted) {
+ console.error(`[migration] Supplier insert failed: ${supplier.party.name}`, error?.message)
+ skipped++
+ } else {
+ supplierIdMap.set(supplier.id, inserted.id)
+ imported++
+ }
+ }
+
+ results.suppliers = { total: suppliers.length, imported, skipped }
+ } catch (err) {
+ console.error('Failed to import suppliers:', err)
+ }
+ }
+
+ // ── Step 4: Sales invoices (open/unpaid only) ─────────────────
+ if (options.importSalesInvoices !== false) {
+ emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
+ try {
+ const invoices = await fetchSalesInvoices(consentId)
+ const openInvoices = invoices.filter(i =>
+ i.status === 'sent' || i.status === 'overdue' || i.status === 'booked'
+ )
+ console.log(`[migration] Sales invoices: ${invoices.length} total, ${openInvoices.length} open (filtered by status: sent/overdue/booked)`)
+
+ let imported = 0
+ let skipped = 0
+
+ for (const inv of openInvoices) {
+ const customerOrgNumber = inv.customer.legalEntity?.companyId ||
+ inv.customer.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
+
+ let customerId: string | null = null
+
+ if (customerOrgNumber) {
+ const { data: match } = await supabase
+ .from('customers')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('org_number', customerOrgNumber)
+ .limit(1)
+ if (match?.[0]) customerId = match[0].id
+ }
+
+ if (!customerId) {
+ const { data: match } = await supabase
+ .from('customers')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('name', inv.customer.name)
+ .limit(1)
+ if (match?.[0]) customerId = match[0].id
+ }
+
+ if (!customerId) {
+ const minimalCustomer = {
+ user_id: userId,
+ name: inv.customer.name,
+ customer_type: 'swedish_business',
+ default_payment_terms: 30,
+ country: 'SE',
+ vat_number_validated: false,
+ }
+ const { data: created, error: custErr } = await supabase
+ .from('customers')
+ .insert(minimalCustomer)
+ .select('id')
+ .single()
+ if (created) {
+ customerId = created.id
+ } else {
+ console.error(`[migration] Sales invoice ${inv.invoiceNumber} skipped — could not create customer "${inv.customer.name}":`, custErr?.message)
+ }
+ }
+
+ if (!customerId) {
+ console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — no customer match for "${inv.customer.name}" (org: ${customerOrgNumber || 'n/a'})`)
+ skipped++
+ continue
+ }
+
+ const { data: existingInv } = await supabase
+ .from('invoices')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('invoice_number', inv.invoiceNumber)
+ .limit(1)
+
+ if (existingInv && existingInv.length > 0) {
+ console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — already exists`)
+ skipped++
+ continue
+ }
+
+ const { invoice: mappedInvoice, items: mappedItems } = mapSalesInvoice(inv, userId, customerId)
+
+ const { data: insertedInv, error: invError } = await supabase
+ .from('invoices')
+ .insert(mappedInvoice)
+ .select('id')
+ .single()
+
+ if (invError || !insertedInv) {
+ console.error(`[migration] Sales invoice ${inv.invoiceNumber} insert failed:`, invError?.message)
+ skipped++
+ continue
+ }
+
+ if (mappedItems.length > 0) {
+ const itemsWithInvoiceId = mappedItems.map(item => ({
+ ...item,
+ invoice_id: insertedInv.id,
+ }))
+ await supabase.from('invoice_items').insert(itemsWithInvoiceId)
+ }
+
+ imported++
+ }
+
+ results.salesInvoices = { total: openInvoices.length, imported, skipped }
+ } catch (err) {
+ console.error('Failed to import sales invoices:', err)
+ }
+ }
+
+ // ── Step 5: Supplier invoices (open/unpaid only) ──────────────
+ if (options.importSupplierInvoices !== false) {
+ emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
+ try {
+ const invoices = await fetchSupplierInvoices(consentId)
+ const openInvoices = invoices.filter(i =>
+ i.status === 'sent' || i.status === 'overdue' || i.status === 'booked' || i.status === 'draft'
+ )
+ console.log(`[migration] Supplier invoices: ${invoices.length} total, ${openInvoices.length} open (filtered by status: sent/overdue/booked/draft)`)
+
+ let imported = 0
+ let skipped = 0
+
+ for (const inv of openInvoices) {
+ const supplierOrgNumber = inv.supplier.legalEntity?.companyId ||
+ inv.supplier.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
+
+ let supplierId: string | null = null
+
+ if (supplierOrgNumber) {
+ const { data: match } = await supabase
+ .from('suppliers')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('org_number', supplierOrgNumber)
+ .limit(1)
+ if (match?.[0]) supplierId = match[0].id
+ }
+
+ if (!supplierId) {
+ const { data: match } = await supabase
+ .from('suppliers')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('name', inv.supplier.name)
+ .limit(1)
+ if (match?.[0]) supplierId = match[0].id
+ }
+
+ if (!supplierId) {
+ const minimalSupplier = {
+ user_id: userId,
+ name: inv.supplier.name,
+ supplier_type: 'swedish_business',
+ default_payment_terms: 30,
+ default_currency: 'SEK',
+ country: 'SE',
+ }
+ const { data: created, error: supErr } = await supabase
+ .from('suppliers')
+ .insert(minimalSupplier)
+ .select('id')
+ .single()
+ if (created) {
+ supplierId = created.id
+ } else {
+ console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not create supplier "${inv.supplier.name}":`, supErr?.message)
+ }
+ }
+
+ if (!supplierId) {
+ console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — no supplier match for "${inv.supplier.name}" (org: ${supplierOrgNumber || 'n/a'})`)
+ skipped++
+ continue
+ }
+
+ const { data: existingInv } = await supabase
+ .from('supplier_invoices')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('supplier_invoice_number', inv.invoiceNumber)
+ .eq('supplier_id', supplierId)
+ .limit(1)
+
+ if (existingInv && existingInv.length > 0) {
+ console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — already exists for supplier "${inv.supplier.name}"`)
+ skipped++
+ continue
+ }
+
+ const { invoice: mappedInvoice, items: mappedItems } = mapSupplierInvoice(inv, userId, supplierId)
+
+ // Get next arrival number (ankomstnummer) — required NOT NULL column
+ const { data: arrivalNum, error: arrivalError } = await supabase
+ .rpc('get_next_arrival_number', { p_user_id: userId })
+
+ if (arrivalError || arrivalNum == null) {
+ console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not get arrival number:`, arrivalError?.message)
+ skipped++
+ continue
+ }
+
+ mappedInvoice.arrival_number = arrivalNum
+
+ const { data: insertedInv, error: invError } = await supabase
+ .from('supplier_invoices')
+ .insert(mappedInvoice)
+ .select('id')
+ .single()
+
+ if (invError || !insertedInv) {
+ console.error(`[migration] Supplier invoice ${inv.invoiceNumber} insert failed for "${inv.supplier.name}":`, invError?.message, JSON.stringify(mappedInvoice, null, 2))
+ skipped++
+ continue
+ }
+
+ if (mappedItems.length > 0) {
+ const itemsWithInvoiceId = mappedItems.map(item => ({
+ ...item,
+ supplier_invoice_id: insertedInv.id,
+ }))
+ await supabase.from('supplier_invoice_items').insert(itemsWithInvoiceId)
+ }
+
+ imported++
+ }
+
+ results.supplierInvoices = { total: openInvoices.length, imported, skipped }
+ } catch (err) {
+ console.error('Failed to import supplier invoices:', err)
+ }
+ }
+
+ emitProgress(options, { status: 'completed', progress: 100, results })
+ return results
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Migration failed'
+ emitProgress(options, { status: 'failed', progress: 0, error: message })
+ throw error
+ }
+}
diff --git a/extensions/general/arcim-migration/manifest.json b/extensions/general/arcim-migration/manifest.json
new file mode 100644
index 00000000..9538ec2e
--- /dev/null
+++ b/extensions/general/arcim-migration/manifest.json
@@ -0,0 +1,19 @@
+{
+ "id": "arcim-migration",
+ "sector": "general",
+ "exportName": "arcimMigrationExtension",
+ "entryPoint": "@/extensions/general/arcim-migration",
+ "workspace": "@/components/extensions/general/ArcimMigrationWorkspace",
+ "requiredEnvVars": ["ARCIM_SYNC_GATEWAY_URL", "ARCIM_SYNC_API_KEY"],
+ "optionalEnvVars": [],
+ "npmDependencies": [],
+ "definition": {
+ "name": "Systemmigration (Arcim Sync)",
+ "category": "import",
+ "icon": "ArrowRightLeft",
+ "dataPattern": "manual",
+ "hasOwnData": false,
+ "description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
+ "longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration."
+ }
+}
diff --git a/extensions/general/arcim-migration/types.ts b/extensions/general/arcim-migration/types.ts
new file mode 100644
index 00000000..8c28aecf
--- /dev/null
+++ b/extensions/general/arcim-migration/types.ts
@@ -0,0 +1,245 @@
+/**
+ * Types for the Arcim Sync migration extension.
+ *
+ * These mirror the canonical DTOs from the Arcim Sync gateway
+ * (packages/core/src/types/dto/) so we don't take a runtime dependency.
+ */
+
+// ── Arcim Sync canonical DTOs (subset we consume) ──────────────────
+
+export interface AmountType {
+ value: number
+ currencyCode: string
+}
+
+export interface PostalAddress {
+ streetName?: string
+ additionalStreetName?: string
+ buildingNumber?: string
+ cityName?: string
+ postalZone?: string
+ countrySubentity?: string
+ countryCode?: string
+}
+
+export interface Contact {
+ name?: string
+ telephone?: string
+ email?: string
+ website?: string
+}
+
+export interface PartyIdentification {
+ id: string
+ schemeId?: string
+}
+
+export interface PartyLegalEntity {
+ registrationName: string
+ companyId?: string
+ companyIdSchemeId?: string
+}
+
+export interface PartyDto {
+ name: string
+ identifications: PartyIdentification[]
+ postalAddress?: PostalAddress
+ legalEntity?: PartyLegalEntity
+ contact?: Contact
+}
+
+export interface PaginatedResponse {
+ data: T[]
+ page: number
+ pageSize: number
+ totalCount: number
+ hasMore: boolean
+}
+
+export interface TaxSubtotalDto {
+ taxableAmount: AmountType
+ taxAmount: AmountType
+ taxCategory?: string
+ percent?: number
+}
+
+export interface TaxTotalDto {
+ taxAmount: AmountType
+ taxSubtotals?: TaxSubtotalDto[]
+}
+
+export interface LegalMonetaryTotalDto {
+ lineExtensionAmount: AmountType
+ taxExclusiveAmount?: AmountType
+ taxInclusiveAmount?: AmountType
+ payableAmount: AmountType
+}
+
+export interface PaymentStatusDto {
+ paid: boolean
+ balance: AmountType
+ lastPaymentDate?: string
+}
+
+// ── Company Information ─────────────────────────────────────────────
+
+export interface CompanyInformationDto {
+ companyName: string
+ organizationNumber?: string
+ legalEntity?: PartyLegalEntity
+ address?: PostalAddress
+ contact?: Contact
+ vatNumber?: string
+ fiscalYearStart?: string // MM-DD
+ baseCurrency?: string
+}
+
+// ── Customer ────────────────────────────────────────────────────────
+
+export type ArcimCustomerType = 'company' | 'private'
+
+export interface CustomerDto {
+ id: string
+ customerNumber: string
+ type?: ArcimCustomerType
+ party: PartyDto
+ active: boolean
+ vatNumber?: string
+ defaultPaymentTermsDays?: number
+ note?: string
+}
+
+// ── Supplier ────────────────────────────────────────────────────────
+
+export interface SupplierDto {
+ id: string
+ supplierNumber: string
+ party: PartyDto
+ active: boolean
+ vatNumber?: string
+ bankAccount?: string
+ bankGiro?: string
+ plusGiro?: string
+ defaultPaymentTermsDays?: number
+ note?: string
+}
+
+// ── Sales Invoice ───────────────────────────────────────────────────
+
+export type InvoiceStatusCode = 'draft' | 'sent' | 'booked' | 'paid' | 'overdue' | 'cancelled' | 'credited'
+
+export interface SalesInvoiceLineDto {
+ id: string
+ description?: string
+ quantity?: number
+ unitCode?: string
+ unitPrice?: AmountType
+ lineExtensionAmount: AmountType
+ taxPercent?: number
+ taxAmount?: AmountType
+ accountNumber?: string
+ itemName?: string
+}
+
+export interface SalesInvoiceDto {
+ id: string
+ invoiceNumber: string
+ issueDate: string
+ dueDate?: string
+ deliveryDate?: string
+ invoiceTypeCode?: string
+ currencyCode: string
+ status: InvoiceStatusCode
+ supplier: PartyDto
+ customer: PartyDto
+ lines: SalesInvoiceLineDto[]
+ taxTotal?: TaxTotalDto
+ legalMonetaryTotal: LegalMonetaryTotalDto
+ paymentStatus: PaymentStatusDto
+ paymentTerms?: string
+ note?: string
+}
+
+// ── Supplier Invoice ────────────────────────────────────────────────
+
+export interface SupplierInvoiceLineDto {
+ id: string
+ description?: string
+ quantity?: number
+ unitCode?: string
+ unitPrice?: AmountType
+ lineExtensionAmount: AmountType
+ taxPercent?: number
+ taxAmount?: AmountType
+ accountNumber?: string
+ itemName?: string
+}
+
+export interface SupplierInvoiceDto {
+ id: string
+ invoiceNumber: string
+ issueDate: string
+ dueDate?: string
+ deliveryDate?: string
+ invoiceTypeCode?: string
+ currencyCode: string
+ status: InvoiceStatusCode
+ supplier: PartyDto
+ buyer: PartyDto
+ lines: SupplierInvoiceLineDto[]
+ taxTotal?: TaxTotalDto
+ legalMonetaryTotal: LegalMonetaryTotalDto
+ paymentStatus: PaymentStatusDto
+ ocrNumber?: string
+ note?: string
+}
+
+// ── Supported providers ─────────────────────────────────────────────
+
+export type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
+
+export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
+ { id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
+ { id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
+ { id: 'bokio', name: 'Bokio', authType: 'token' },
+ { id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
+ { id: 'briox', name: 'Briox', authType: 'token' },
+]
+
+// ── Migration state ─────────────────────────────────────────────────
+
+export interface MigrationProgress {
+ status: 'idle' | 'connecting' | 'fetching' | 'importing' | 'completed' | 'failed'
+ currentStep?: string
+ progress: number // 0-100
+ results?: MigrationResults
+ error?: string
+}
+
+export interface MigrationResults {
+ companyInfo?: { imported: boolean }
+ customers?: { total: number; imported: number; skipped: number }
+ suppliers?: { total: number; imported: number; skipped: number }
+ salesInvoices?: { total: number; imported: number; skipped: number }
+ supplierInvoices?: { total: number; imported: number; skipped: number }
+}
+
+// ── Consent flow ────────────────────────────────────────────────────
+
+export interface ConsentRecord {
+ id: string
+ name: string
+ provider: ArcimProvider
+ status: 0 | 1 | 2 | 3 // Created | Accepted | Revoked | Inactive
+ orgNumber?: string
+ companyName?: string
+ etag?: string
+ createdAt?: string
+ updatedAt?: string
+}
+
+export interface OtcResponse {
+ code: string
+ consentId: string
+ expiresAt: string
+}
diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts
index 90224143..f6584d56 100644
--- a/lib/extensions/__tests__/sectors.test.ts
+++ b/lib/extensions/__tests__/sectors.test.ts
@@ -49,7 +49,7 @@ describe('sectors registry', () => {
})
it('should have 8 total extensions', () => {
- expect(getAllExtensions().length).toBe(8)
+ expect(getAllExtensions().length).toBe(9)
})
it('should have unique slugs within each sector', () => {
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('general')
- expect(extensions.length).toBe(8)
+ expect(extensions.length).toBe(9)
})
it('all extensions have required fields', () => {
diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts
index b3f51efc..d307d1d1 100644
--- a/lib/extensions/_generated/enabled-extensions.ts
+++ b/lib/extensions/_generated/enabled-extensions.ts
@@ -3,4 +3,5 @@
export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([
'enable-banking',
'email',
+ 'arcim-migration',
])
diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts
index e7a01222..15ba41ab 100644
--- a/lib/extensions/_generated/extension-list.ts
+++ b/lib/extensions/_generated/extension-list.ts
@@ -2,8 +2,10 @@
import type { Extension } from '../types'
import { enableBankingExtension } from '@/extensions/general/enable-banking'
import { emailExtension } from '@/extensions/general/email'
+import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
emailExtension,
+ arcimMigrationExtension,
]
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index 395f56e1..8524ab68 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -30,5 +30,15 @@ export const EXTENSION_DEFINITIONS: Record = {
"company_settings"
]
},
+ {
+ "slug": "arcim-migration",
+ "name": "Systemmigration (Arcim Sync)",
+ "sector": "general",
+ "category": "import",
+ "icon": "ArrowRightLeft",
+ "dataPattern": "manual",
+ "description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
+ "longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration."
+ },
],
}
diff --git a/lib/extensions/_generated/workspace-map.tsx b/lib/extensions/_generated/workspace-map.tsx
index b610caaf..50681548 100644
--- a/lib/extensions/_generated/workspace-map.tsx
+++ b/lib/extensions/_generated/workspace-map.tsx
@@ -5,4 +5,5 @@ import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record> = {
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
+ 'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
}
diff --git a/lib/extensions/toggle-check.ts b/lib/extensions/toggle-check.ts
index 00930de4..bcf3e64d 100644
--- a/lib/extensions/toggle-check.ts
+++ b/lib/extensions/toggle-check.ts
@@ -13,6 +13,7 @@ const LEGACY_GENERAL_EXTENSIONS = [
'ai-categorization',
'ai-chat',
'enable-banking',
+ 'arcim-migration',
]
export async function isExtensionEnabled(
diff --git a/lib/import/__tests__/account-mapper.test.ts b/lib/import/__tests__/account-mapper.test.ts
index 7d9483ce..f040a9b9 100644
--- a/lib/import/__tests__/account-mapper.test.ts
+++ b/lib/import/__tests__/account-mapper.test.ts
@@ -8,6 +8,7 @@ import {
getMappingStats,
applyMappingOverride,
mappingsToMap,
+ isSystemAccount,
} from '../account-mapper'
// --- Helpers ---
@@ -54,6 +55,8 @@ const basAccounts: BASAccount[] = [
makeBASAccount('1510', 'Kundfordringar'),
makeBASAccount('1930', 'Företagskonto'),
makeBASAccount('2440', 'Leverantörsskulder'),
+ makeBASAccount('2640', 'Ingående moms'),
+ makeBASAccount('2641', 'Debiterad ingående moms'),
makeBASAccount('3001', 'Försäljning varor 25%'),
makeBASAccount('3002', 'Försäljning varor 12%'),
makeBASAccount('5010', 'Lokalhyra'),
@@ -94,7 +97,7 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('3400')
expect(result[0].targetName).toBe('Försäljning tjänster')
- expect(result[0].confidence).toBe(0.9)
+ expect(result[0].confidence).toBe(0.7)
expect(result[0].matchType).toBe('bas_range')
})
@@ -105,7 +108,7 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('1241')
expect(result[0].targetName).toBe('Personbilar')
- expect(result[0].confidence).toBe(0.9)
+ expect(result[0].confidence).toBe(0.7)
expect(result[0].matchType).toBe('bas_range')
})
@@ -164,9 +167,9 @@ describe('suggestMappings', () => {
// Unmapped (confidence 0) should come first
expect(result[0].sourceAccount).toBe('9999')
expect(result[0].confidence).toBe(0)
- // bas_range (confidence 0.9) next
+ // bas_range (confidence 0.7) next
expect(result[1].sourceAccount).toBe('3400')
- expect(result[1].confidence).toBe(0.9)
+ expect(result[1].confidence).toBe(0.7)
// Exact matches (confidence 1.0) come last
expect(result[2].confidence).toBe(1.0)
expect(result[3].confidence).toBe(1.0)
@@ -193,13 +196,33 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(0)
})
+ it('redirects group header account 2640 to posting account 2641', () => {
+ const source = [makeSIEAccount('2640', 'Ingående moms')]
+ const result = suggestMappings(source, basAccounts)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].sourceAccount).toBe('2640')
+ expect(result[0].targetAccount).toBe('2641')
+ expect(result[0].targetName).toBe('Debiterad ingående moms')
+ expect(result[0].confidence).toBe(1.0)
+ expect(result[0].matchType).toBe('exact')
+ })
+
+ it('does not redirect 2641 (it is the posting account, not a group header)', () => {
+ const source = [makeSIEAccount('2641', 'Debiterad ingående moms')]
+ const result = suggestMappings(source, basAccounts)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].targetAccount).toBe('2641')
+ })
+
it('handles empty BAS accounts — bas_range fallback for valid accounts', () => {
const source = [makeSIEAccount('1510', 'Kundfordringar')]
const result = suggestMappings(source, [])
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('1510')
- expect(result[0].confidence).toBe(0.9)
+ expect(result[0].confidence).toBe(0.7)
expect(result[0].matchType).toBe('bas_range')
})
@@ -360,8 +383,8 @@ describe('getMappingStats', () => {
)
const stats = getMappingStats(mappings)
- // Average of (1.0 + 0.9) / 2 = 0.95
- expect(stats.averageConfidence).toBe(0.95)
+ // Average of (1.0 + 0.7) / 2 = 0.85
+ expect(stats.averageConfidence).toBe(0.85)
})
it('returns 0 average confidence when nothing is mapped', () => {
@@ -446,3 +469,53 @@ describe('mappingsToMap', () => {
expect(map.size).toBe(1)
})
})
+
+describe('isSystemAccount', () => {
+ it('returns true for Fortnox system account 0099', () => {
+ expect(isSystemAccount('0099')).toBe(true)
+ })
+
+ it('returns true for other 0xxx accounts', () => {
+ expect(isSystemAccount('0001')).toBe(true)
+ expect(isSystemAccount('0500')).toBe(true)
+ expect(isSystemAccount('0999')).toBe(true)
+ })
+
+ it('returns false for valid BAS accounts (1000-8999)', () => {
+ expect(isSystemAccount('1000')).toBe(false)
+ expect(isSystemAccount('1510')).toBe(false)
+ expect(isSystemAccount('3001')).toBe(false)
+ expect(isSystemAccount('8999')).toBe(false)
+ })
+
+ it('returns false for 9000+ accounts (handled separately as out-of-range)', () => {
+ expect(isSystemAccount('9000')).toBe(false)
+ expect(isSystemAccount('9999')).toBe(false)
+ })
+
+ it('returns false for non-4-digit numbers', () => {
+ expect(isSystemAccount('099')).toBe(false)
+ expect(isSystemAccount('00099')).toBe(false)
+ expect(isSystemAccount('abc')).toBe(false)
+ expect(isSystemAccount('')).toBe(false)
+ })
+
+ it('allows pre-filtering system accounts before suggestMappings', () => {
+ const allAccounts = [
+ makeSIEAccount('0099', 'Systemkonto'),
+ makeSIEAccount('1510', 'Kundfordringar'),
+ makeSIEAccount('1930', 'Företagskonto'),
+ ]
+
+ const bookkeepingAccounts = allAccounts.filter((a) => !isSystemAccount(a.number))
+ const excluded = allAccounts.filter((a) => isSystemAccount(a.number))
+
+ expect(bookkeepingAccounts).toHaveLength(2)
+ expect(excluded).toHaveLength(1)
+ expect(excluded[0].number).toBe('0099')
+
+ const mappings = suggestMappings(bookkeepingAccounts, basAccounts)
+ expect(mappings).toHaveLength(2)
+ expect(mappings.every((m) => m.targetAccount)).toBe(true)
+ })
+})
diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts
index 53d92ec8..27ec14c9 100644
--- a/lib/import/__tests__/sie-import.test.ts
+++ b/lib/import/__tests__/sie-import.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { generateImportPreview } from '../sie-import'
+import { generateImportPreview, validateIBBalance, isBalanceSheetAccount } from '../sie-import'
import type { ParsedSIEFile, AccountMapping } from '../types'
// --- Helpers ---
@@ -221,3 +221,116 @@ describe('generateImportPreview', () => {
})
})
})
+
+describe('validateIBBalance', () => {
+ it('returns 0 roundingAdjustment when IB is balanced', () => {
+ const parsed = makeParsedFile({
+ openingBalances: [
+ { yearIndex: 0, account: '1510', amount: 50000 },
+ { yearIndex: 0, account: '2440', amount: -50000 },
+ ],
+ })
+ const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
+ const result = validateIBBalance(parsed, accountMap)
+
+ expect(result.roundingAdjustment).toBe(0)
+ expect(result.fileImbalance).toBe(0)
+ expect(result.excludedAccountsTotal).toBe(0)
+ expect(result.lines).toHaveLength(2)
+ })
+
+ it('returns rounding adjustment for imbalance <= 1 SEK', () => {
+ const parsed = makeParsedFile({
+ openingBalances: [
+ { yearIndex: 0, account: '1510', amount: 50000.50 },
+ { yearIndex: 0, account: '2440', amount: -50000 },
+ ],
+ })
+ const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
+ const result = validateIBBalance(parsed, accountMap)
+
+ expect(result.roundingAdjustment).toBe(0.5)
+ expect(result.fileImbalance).toBe(0.5)
+ })
+
+ it('returns large adjustment for file-level imbalance (unallocated årets resultat)', () => {
+ // Simulates a Fortnox export where previous year result hasn't been allocated
+ // to equity — BS accounts don't balance because årets resultat is implicit
+ const parsed = makeParsedFile({
+ openingBalances: [
+ { yearIndex: 0, account: '1510', amount: 50100 },
+ { yearIndex: 0, account: '2440', amount: -50000 },
+ ],
+ })
+ const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
+ const result = validateIBBalance(parsed, accountMap)
+
+ // The adjustment is 100 SEK — caller should book to 2099, never reject
+ expect(result.roundingAdjustment).toBe(100)
+ expect(result.fileImbalance).toBe(100)
+ expect(result.excludedAccountsTotal).toBe(0)
+ })
+
+ it('tracks excluded accounts separately from file imbalance (Fortnox system accounts)', () => {
+ // Simulates Fortnox 0099 carrying IB balance — file is balanced,
+ // but mapped accounts are not because 0099 is excluded from mapping
+ const parsed = makeParsedFile({
+ openingBalances: [
+ { yearIndex: 0, account: '1510', amount: 50000 },
+ { yearIndex: 0, account: '2440', amount: -150000 },
+ { yearIndex: 0, account: '0099', amount: 100000 }, // System account, not mapped
+ ],
+ })
+ const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
+ const result = validateIBBalance(parsed, accountMap)
+
+ // File-level: 50000 + (-150000) + 100000 = 0, balanced
+ expect(result.fileImbalance).toBe(0)
+ // Mapped-level: 50000 debit, 150000 credit = -100000 diff
+ expect(result.roundingAdjustment).toBe(-100000)
+ // The excluded 0099 accounts for the entire difference
+ expect(result.excludedAccountsTotal).toBe(100000)
+ // Only 2 lines (0099 excluded)
+ expect(result.lines).toHaveLength(2)
+ })
+
+ it('ignores non-current-year balances', () => {
+ const parsed = makeParsedFile({
+ openingBalances: [
+ { yearIndex: 0, account: '1510', amount: 50000 },
+ { yearIndex: 0, account: '2440', amount: -50000 },
+ { yearIndex: -1, account: '1510', amount: 99999 }, // Previous year — ignored
+ ],
+ })
+ const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
+ const result = validateIBBalance(parsed, accountMap)
+
+ expect(result.roundingAdjustment).toBe(0)
+ expect(result.lines).toHaveLength(2)
+ })
+})
+
+describe('isBalanceSheetAccount', () => {
+ it('returns true for class 1 (assets)', () => {
+ expect(isBalanceSheetAccount('1510')).toBe(true)
+ expect(isBalanceSheetAccount('1930')).toBe(true)
+ })
+
+ it('returns true for class 2 (liabilities/equity)', () => {
+ expect(isBalanceSheetAccount('2099')).toBe(true)
+ expect(isBalanceSheetAccount('2440')).toBe(true)
+ })
+
+ it('returns false for class 3 (revenue)', () => {
+ expect(isBalanceSheetAccount('3001')).toBe(false)
+ expect(isBalanceSheetAccount('3740')).toBe(false)
+ })
+
+ it('returns false for class 4-8 (expenses)', () => {
+ expect(isBalanceSheetAccount('4010')).toBe(false)
+ expect(isBalanceSheetAccount('5010')).toBe(false)
+ expect(isBalanceSheetAccount('6211')).toBe(false)
+ expect(isBalanceSheetAccount('7210')).toBe(false)
+ expect(isBalanceSheetAccount('8999')).toBe(false)
+ })
+})
diff --git a/lib/import/__tests__/sie-parser.test.ts b/lib/import/__tests__/sie-parser.test.ts
index 0aa97d30..81743d38 100644
--- a/lib/import/__tests__/sie-parser.test.ts
+++ b/lib/import/__tests__/sie-parser.test.ts
@@ -383,7 +383,7 @@ describe('validateSIEFile', () => {
expect(validation.errors.some((e) => e.includes('not balanced'))).toBe(true)
})
- it('adds warning for undefined account references', () => {
+ it('no longer warns about accounts referenced in #IB since parser auto-adds them', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
@@ -394,9 +394,12 @@ describe('validateSIEFile', () => {
].join('\n')
const parsed = parseSIEFile(content)
- const validation = validateSIEFile(parsed)
+ // Parser now auto-adds 9999 to accounts list from #IB data
+ expect(parsed.accounts.map((a) => a.number)).toContain('9999')
- expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(true)
+ const validation = validateSIEFile(parsed)
+ // No warning since account was auto-added by the parser
+ expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(false)
})
it('adds error for missing #RAR', () => {
@@ -436,6 +439,60 @@ describe('validateSIEFile', () => {
// --- Fix 2: Windows-1252 encoding detection and decoding ---
+describe('detectEncoding — #FORMAT PC8 detection', () => {
+ it('returns cp437 when #FORMAT PC8 is present in the first 500 bytes', () => {
+ const text = '#FLAGGA 0\n#FORMAT PC8\n#SIETYP 4\n'
+ const encoder = new TextEncoder()
+ const buf = encoder.encode(text)
+ const encoding = detectEncoding(buf.buffer)
+ expect(encoding).toBe('cp437')
+ })
+
+ it('returns cp437 even when Win-1252 bytes follow #FORMAT PC8', () => {
+ // #FORMAT PC8 header should take priority over any byte analysis
+ const prefix = new TextEncoder().encode('#FORMAT PC8\n#FNAMN F')
+ const buf = new Uint8Array(prefix.length + 3)
+ buf.set(prefix)
+ buf[prefix.length] = 0xf6 // ö in Win-1252
+ buf[prefix.length + 1] = 0xe4 // ä in Win-1252
+ buf[prefix.length + 2] = 0xe5 // å in Win-1252
+ const encoding = detectEncoding(buf.buffer)
+ expect(encoding).toBe('cp437')
+ })
+})
+
+describe('detectEncoding — range-based discrimination', () => {
+ it('detects CP437 when bytes are in 0x80-0x9F range only', () => {
+ // 0x84=ä, 0x86=å, 0x94=ö in CP437 — all in 0x80-0x9F
+ const buf = new Uint8Array([0x23, 0x84, 0x86, 0x94, 0x84, 0x86])
+ const encoding = detectEncoding(buf.buffer)
+ expect(encoding).toBe('cp437')
+ })
+
+ it('detects Win-1252 when bytes are in 0xC0-0xFF range only', () => {
+ // 0xE4=ä, 0xE5=å, 0xF6=ö in Win-1252 — all in 0xC0-0xFF
+ const buf = new Uint8Array([0x23, 0xe4, 0xe5, 0xf6, 0xe4, 0xe5])
+ const encoding = detectEncoding(buf.buffer)
+ expect(encoding).toBe('windows1252')
+ })
+
+ it('does not double-count UTF-8 continuation bytes as CP437', () => {
+ // UTF-8: ä = C3 A4, å = C3 A5, ö = C3 B6
+ // Without skipping, 0xA4/0xA5/0xB6 are NOT in CP437 map so no false count,
+ // but 0x84/0x85 ARE in CP437 map — test that C3 84 (Ä in UTF-8) is not
+ // counted as CP437 0x84 (ä)
+ const buf = new Uint8Array([
+ 0x23, // #
+ 0xc3, 0x84, // Ä in UTF-8
+ 0xc3, 0x85, // Å in UTF-8
+ 0xc3, 0x96, // Ö in UTF-8
+ 0xc3, 0xa4, // ä in UTF-8
+ 0xc3, 0xa5, // å in UTF-8
+ ])
+ const encoding = detectEncoding(buf.buffer)
+ expect(encoding).toBe('utf8')
+ })
+})
describe('detectEncoding — Windows-1252', () => {
it('detects Windows-1252 when Swedish chars use Win-1252 byte values', () => {
// Build a buffer with Windows-1252 encoded Swedish text: "#FNAMN Företag"
@@ -662,3 +719,82 @@ describe('parseSIEFile — missing amount handling', () => {
expect(result.openingBalances[0].amount).toBe(100000)
})
})
+
+// --- Fix B4: Account collection from transaction data ---
+
+describe('parseSIEFile — account collection from transaction data', () => {
+ it('adds accounts from #TRANS that are missing from #KONTO', () => {
+ const content = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#FNAMN "Test"',
+ '#RAR 0 20240101 20241231',
+ '#KONTO 1510 "Kundfordringar"',
+ // 3001 is NOT defined in #KONTO but used in #TRANS
+ '#VER A 1 20240115 "Test"',
+ '{',
+ '#TRANS 1510 {} 10000.00',
+ '#TRANS 3001 {} -10000.00',
+ '}',
+ ].join('\n')
+
+ const result = parseSIEFile(content)
+ // Should have both 1510 (from #KONTO) and 3001 (from #TRANS)
+ expect(result.accounts.map((a) => a.number)).toContain('1510')
+ expect(result.accounts.map((a) => a.number)).toContain('3001')
+ // The auto-added account should have empty name
+ const added = result.accounts.find((a) => a.number === '3001')
+ expect(added?.name).toBe('')
+ })
+
+ it('adds accounts from #IB that are missing from #KONTO', () => {
+ const content = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#FNAMN "Test"',
+ '#RAR 0 20240101 20241231',
+ '#KONTO 1510 "Kundfordringar"',
+ '#IB 0 1510 50000.00',
+ '#IB 0 2440 -50000.00', // 2440 not in #KONTO
+ ].join('\n')
+
+ const result = parseSIEFile(content)
+ expect(result.accounts.map((a) => a.number)).toContain('2440')
+ })
+
+ it('does not duplicate accounts already in #KONTO', () => {
+ const content = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#FNAMN "Test"',
+ '#RAR 0 20240101 20241231',
+ '#KONTO 1510 "Kundfordringar"',
+ '#KONTO 3001 "Försäljning"',
+ '#VER A 1 20240115 "Test"',
+ '{',
+ '#TRANS 1510 {} 10000.00',
+ '#TRANS 3001 {} -10000.00',
+ '}',
+ ].join('\n')
+
+ const result = parseSIEFile(content)
+ const count1510 = result.accounts.filter((a) => a.number === '1510').length
+ expect(count1510).toBe(1)
+ })
+
+ it('adds accounts from #UB and #RES that are missing from #KONTO', () => {
+ const content = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#FNAMN "Test"',
+ '#RAR 0 20240101 20241231',
+ '#KONTO 1510 "Kundfordringar"',
+ '#UB 0 1930 100000.00', // 1930 not in #KONTO
+ '#RES 0 3001 -50000.00', // 3001 not in #KONTO
+ ].join('\n')
+
+ const result = parseSIEFile(content)
+ expect(result.accounts.map((a) => a.number)).toContain('1930')
+ expect(result.accounts.map((a) => a.number)).toContain('3001')
+ })
+})
diff --git a/lib/import/account-mapper.ts b/lib/import/account-mapper.ts
index 23ad23a8..4e8721f5 100644
--- a/lib/import/account-mapper.ts
+++ b/lib/import/account-mapper.ts
@@ -25,6 +25,25 @@ export type MappableAccount = {
account_name: string
}
+// Group header accounts that should redirect to their posting sub-account.
+// These are BAS group headers not meant for direct posting.
+const GROUP_HEADER_REDIRECTS: Record = {
+ '2640': '2641', // Ingående moms → Debiterad ingående moms
+}
+
+/**
+ * Check if an account is a source-system internal account that should be
+ * excluded from import. BAS accounts use classes 1-8 (1000-8999). Account
+ * numbers starting with 0 (e.g. Fortnox 0099) are internal system accounts
+ * with no BAS equivalent — they should be silently filtered out rather than
+ * forcing the user to map them.
+ */
+export function isSystemAccount(accountNumber: string): boolean {
+ if (!/^\d{4}$/.test(accountNumber)) return false
+ const num = parseInt(accountNumber, 10)
+ return num < 1000
+}
+
/**
* Check if an account number is in the valid BAS range (1000-8999).
* Standard Swedish BAS accounts are 4-digit numbers in classes 1-8.
@@ -60,6 +79,23 @@ function findBestMatch(
)
if (exactMatch) {
+ // Redirect group header accounts to their posting sub-account
+ const redirect = GROUP_HEADER_REDIRECTS[exactMatch.account_number]
+ if (redirect) {
+ const redirectTarget = basAccounts.find((a) => a.account_number === redirect)
+ if (redirectTarget) {
+ return {
+ sourceAccount: source.number,
+ sourceName: source.name,
+ targetAccount: redirectTarget.account_number,
+ targetName: redirectTarget.account_name,
+ confidence: 1.0,
+ matchType: 'exact',
+ isOverride: false,
+ }
+ }
+ }
+
return {
sourceAccount: source.number,
sourceName: source.name,
@@ -80,7 +116,7 @@ function findBestMatch(
sourceName: source.name,
targetAccount: source.number,
targetName: source.name,
- confidence: 0.9,
+ confidence: 0.7,
matchType: 'bas_range',
isOverride: false,
}
diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts
index f5d773f6..cf0b5723 100644
--- a/lib/import/sie-import.ts
+++ b/lib/import/sie-import.ts
@@ -14,16 +14,22 @@ import type {
ImportResult,
ImportPreview,
SIEImport,
+ MigrationDocumentation,
} from './types'
import type { CreateJournalEntryLineInput } from '@/types'
import { mappingsToMap, getMappingStats } from './account-mapper'
import { calculateFileHash } from './sie-parser'
+import { getBASReference } from '@/lib/bookkeeping/bas-reference'
+import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
/**
* Format a date to ISO date string (YYYY-MM-DD)
*/
function formatDate(date: Date): string {
- return date.toISOString().split('T')[0]
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
}
/**
@@ -68,6 +74,7 @@ export function generateImportPreview(
unmapped: mappingStats.unmapped,
lowConfidence: mappingStats.lowConfidence,
},
+ excludedSystemAccounts: [],
issues: parsed.issues,
}
}
@@ -157,14 +164,82 @@ async function ensureFiscalPeriod(
}
/**
- * Create opening balance journal entry from IB amounts
+ * Compute IB imbalance and validate it before creating the opening balance entry.
+ *
+ * Distinguishes between:
+ * - File-level imbalance: the raw SIE #IB data doesn't balance (source file error)
+ * - Mapping-level imbalance: caused by excluded accounts (system accounts like Fortnox 0099)
+ * that carry IB balances but are correctly filtered from mapping. This is expected and
+ * should be booked to 2099 with clear documentation.
+ */
+export function validateIBBalance(
+ parsed: ParsedSIEFile,
+ accountMap: Map
+): {
+ lines: CreateJournalEntryLineInput[]
+ roundingAdjustment: number
+ fileImbalance: number
+ excludedAccountsTotal: number
+} {
+ const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
+
+ // First: check the raw file-level IB balance (all accounts, before mapping)
+ const rawTotal = currentYearBalances.reduce((sum, b) => sum + b.amount, 0)
+ const fileImbalance = Math.round(Math.abs(rawTotal) * 100) / 100
+
+ // Build mapped lines and track excluded account totals
+ const lines: CreateJournalEntryLineInput[] = []
+ let excludedTotal = 0
+
+ for (const balance of currentYearBalances) {
+ const targetAccount = accountMap.get(balance.account)
+ if (!targetAccount) {
+ // Account not in mapping (system account or unmapped) — track its IB contribution
+ excludedTotal += balance.amount
+ continue
+ }
+
+ if (balance.amount > 0) {
+ lines.push({
+ account_number: targetAccount,
+ debit_amount: balance.amount,
+ credit_amount: 0,
+ line_description: `IB ${balance.account}`,
+ })
+ } else if (balance.amount < 0) {
+ lines.push({
+ account_number: targetAccount,
+ debit_amount: 0,
+ credit_amount: Math.abs(balance.amount),
+ line_description: `IB ${balance.account}`,
+ })
+ }
+ }
+
+ const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ const mappedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
+
+ return {
+ lines,
+ roundingAdjustment: Math.abs(mappedDiff) > 0.01 ? mappedDiff : 0,
+ fileImbalance,
+ excludedAccountsTotal: Math.round(excludedTotal * 100) / 100,
+ }
+}
+
+/**
+ * Create opening balance journal entry from IB amounts.
+ * The caller must validate the IB balance first via validateIBBalance().
+ * If roundingAdjustment is non-zero, it is booked explicitly to 2099 with clear text.
*/
async function createOpeningBalanceEntry(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
- accountMap: Map
+ accountMap: Map,
+ roundingAdjustment: number
): Promise {
const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
@@ -177,11 +252,8 @@ async function createOpeningBalanceEntry(
for (const balance of currentYearBalances) {
const targetAccount = accountMap.get(balance.account)
- if (!targetAccount) {
- continue // Skip unmapped accounts
- }
+ if (!targetAccount) continue
- // Opening balances: positive = debit, negative = credit
if (balance.amount > 0) {
lines.push({
account_number: targetAccount,
@@ -203,27 +275,21 @@ async function createOpeningBalanceEntry(
return null
}
- // Check if balanced
- const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
- const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
- const diff = Math.abs(totalDebit - totalCredit)
-
- // If not balanced, add an adjustment line to equity
- if (diff > 0.01) {
- const adjustment = totalDebit - totalCredit
- if (adjustment > 0) {
+ // Add explicit rounding adjustment if needed (pre-validated by caller, <= 1 SEK)
+ if (Math.abs(roundingAdjustment) > 0.01) {
+ if (roundingAdjustment > 0) {
lines.push({
- account_number: '2099', // Årets resultat (or similar equity account)
+ account_number: '2099',
debit_amount: 0,
- credit_amount: adjustment,
- line_description: 'Balanseringsdifferens',
+ credit_amount: roundingAdjustment,
+ line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
})
} else {
lines.push({
account_number: '2099',
- debit_amount: Math.abs(adjustment),
+ debit_amount: Math.abs(roundingAdjustment),
credit_amount: 0,
- line_description: 'Balanseringsdifferens',
+ line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
})
}
}
@@ -253,15 +319,58 @@ async function importVouchers(
parsed: ParsedSIEFile,
accountMap: Map,
voucherSeries: string
-): Promise<{ created: number; ids: string[]; errors: string[] }> {
+): Promise<{
+ created: number
+ ids: string[]
+ errors: string[]
+ skippedEmpty: number
+ skippedSingleLine: number
+ skippedUnbalanced: number
+ skippedUnmapped: number
+ movementsByAccount: Map
+ skippedDetails: {
+ voucherId: string
+ date: string
+ description: string
+ reason: 'unmapped' | 'empty' | 'unbalanced' | 'zero_lines' | 'single_line'
+ unmappedAccounts?: string[]
+ balanceDiff?: number
+ totalDebit?: number
+ totalCredit?: number
+ sourceLines?: { account: string; amount: number }[]
+ mappedLineCount?: number
+ originalLineCount?: number
+ }[]
+ voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }>
+}> {
const results = {
created: 0,
ids: [] as string[],
errors: [] as string[],
+ skippedEmpty: 0,
+ skippedSingleLine: 0,
+ skippedUnbalanced: 0,
+ skippedUnmapped: 0,
+ movementsByAccount: new Map(),
+ skippedDetails: [] as {
+ voucherId: string
+ date: string
+ description: string
+ reason: 'unmapped' | 'empty' | 'unbalanced' | 'zero_lines' | 'single_line'
+ unmappedAccounts?: string[]
+ balanceDiff?: number
+ totalDebit?: number
+ totalCredit?: number
+ sourceLines?: { account: string; amount: number }[]
+ mappedLineCount?: number
+ originalLineCount?: number
+ }[],
+ voucherNumberMapping: [] as Array<{ sourceId: string; targetNumber: number }>,
}
// Pre-filter and prepare all valid vouchers
interface PreparedVoucher {
+ sourceId: string
date: string
description: string
lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[]
@@ -272,15 +381,14 @@ async function importVouchers(
for (const voucher of parsed.vouchers) {
const lines: PreparedVoucher['lines'] = []
let hasUnmappedAccount = false
+ const unmappedAccountSet = new Set()
for (const line of voucher.lines) {
const targetAccount = accountMap.get(line.account)
if (!targetAccount) {
hasUnmappedAccount = true
- results.errors.push(
- `Voucher ${voucher.series}${voucher.number}: Unmapped account ${line.account}`
- )
+ unmappedAccountSet.add(line.account)
continue
}
@@ -300,30 +408,117 @@ async function importVouchers(
line_description: line.description || null,
})
}
+ // Note: lines with amount === 0 are silently dropped
}
- // Skip vouchers with unmapped accounts or too few lines
- if (hasUnmappedAccount || lines.length < 2) {
+ const voucherId = `${voucher.series}${voucher.number}`
+ const voucherDate = formatDate(voucher.date)
+
+ // Skip vouchers with unmapped accounts
+ if (hasUnmappedAccount) {
+ results.skippedDetails.push({
+ voucherId,
+ date: voucherDate,
+ description: voucher.description,
+ reason: 'unmapped',
+ unmappedAccounts: [...unmappedAccountSet],
+ mappedLineCount: lines.length,
+ originalLineCount: voucher.lines.length,
+ sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
+ })
+ results.skippedUnmapped++
continue
}
- // Validate balance
+ // Fix 3: Separate empty (0 lines) from single-line vouchers
+ if (lines.length === 0) {
+ results.skippedDetails.push({
+ voucherId,
+ date: voucherDate,
+ description: voucher.description,
+ reason: 'zero_lines',
+ mappedLineCount: 0,
+ originalLineCount: voucher.lines.length,
+ sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
+ })
+ results.skippedEmpty++
+ continue
+ }
+
+ if (lines.length === 1) {
+ results.skippedDetails.push({
+ voucherId,
+ date: voucherDate,
+ description: voucher.description,
+ reason: 'single_line',
+ mappedLineCount: 1,
+ originalLineCount: voucher.lines.length,
+ sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
+ })
+ results.skippedSingleLine++
+ continue
+ }
+
+ // Validate balance — Fix 2: Tiered rounding with öresutjämning (3741)
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
- if (Math.abs(totalDebit - totalCredit) > 0.01) {
- results.errors.push(
- `Voucher ${voucher.series}${voucher.number}: Not balanced (debit: ${totalDebit}, credit: ${totalCredit})`
- )
+ const balanceDiff = Math.round(Math.abs(totalDebit - totalCredit) * 100) / 100
+ if (balanceDiff > 1.00) {
+ // More than 1 SEK off — incomplete voucher in source system, skip
+ results.skippedDetails.push({
+ voucherId,
+ date: voucherDate,
+ description: voucher.description,
+ reason: 'unbalanced',
+ balanceDiff,
+ totalDebit: Math.round(totalDebit * 100) / 100,
+ totalCredit: Math.round(totalCredit * 100) / 100,
+ mappedLineCount: lines.length,
+ originalLineCount: voucher.lines.length,
+ sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
+ })
+ results.skippedUnbalanced++
continue
+ } else if (balanceDiff > 0.005) {
+ // Rounding difference <= 1 SEK — add explicit öresutjämning line (never modify existing lines)
+ const roundedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
+ if (roundedDiff > 0) {
+ lines.push({
+ account_number: '3741',
+ debit_amount: 0,
+ credit_amount: Math.abs(roundedDiff),
+ line_description: 'Öresutjämning',
+ })
+ } else {
+ lines.push({
+ account_number: '3741',
+ debit_amount: Math.abs(roundedDiff),
+ credit_amount: 0,
+ line_description: 'Öresutjämning',
+ })
+ }
}
preparedVouchers.push({
+ sourceId: voucherId,
date: formatDate(voucher.date),
description: voucher.description || `Import: ${voucher.series}${voucher.number}`,
lines,
})
}
+ // Compute per-account net movements from vouchers that will be imported.
+ // Used for UB/RES reconciliation to generate migration adjustment entries.
+ for (const v of preparedVouchers) {
+ for (const l of v.lines) {
+ const net = l.debit_amount - l.credit_amount
+ results.movementsByAccount.set(
+ l.account_number,
+ (results.movementsByAccount.get(l.account_number) || 0) + net
+ )
+ }
+ }
+
if (preparedVouchers.length === 0) {
return results
}
@@ -404,6 +599,7 @@ async function importVouchers(
if (!entryId) continue
const voucher = batch[i]
+ const assignedNumber = currentVoucherNumber + batchStart + i
voucher.lines.forEach((line, lineIndex) => {
allLines.push({
journal_entry_id: entryId,
@@ -417,6 +613,12 @@ async function importVouchers(
})
})
+ // Fix 7: Capture voucher number mapping (source → target)
+ results.voucherNumberMapping.push({
+ sourceId: voucher.sourceId,
+ targetNumber: assignedNumber,
+ })
+
results.ids.push(entryId)
results.created++
}
@@ -433,11 +635,254 @@ async function importVouchers(
}
}
+ // Update voucher sequence to reflect all assigned numbers.
+ // next_voucher_number() was called once but we assigned N numbers manually,
+ // so the sequence only got incremented by 1. Fix with GREATEST to avoid races.
+ if (results.created > 0) {
+ const highestUsed = currentVoucherNumber + preparedVouchers.length - 1
+ await supabase.rpc('reserve_voucher_range', {
+ p_user_id: userId,
+ p_fiscal_period_id: fiscalPeriodId,
+ p_series: voucherSeries,
+ p_highest_used: highestUsed,
+ })
+ }
+
return results
}
/**
- * Record the import in the database
+ * Determine if an account is balance sheet (class 1-2) or P&L (class 3-8)
+ */
+export function isBalanceSheetAccount(accountNumber: string): boolean {
+ const firstDigit = parseInt(accountNumber.charAt(0), 10)
+ return firstDigit >= 1 && firstDigit <= 2
+}
+
+/**
+ * Create a migration adjustment entry (omföringsverifikation) to reconcile
+ * imported voucher movements against the SIE file's closing balances.
+ *
+ * When unbalanced vouchers are skipped during import, the sum of imported
+ * movements will differ from the true account balances computed by the source
+ * system. This function:
+ * 1. Computes expected net movements from #UB (balance sheet) and #RES (result),
+ * separated by account class per Fix 8
+ * 2. Compares against actual imported movements
+ * 3. Books the per-account delta as a proper omföringsverifikation
+ *
+ * Per BFL 1999:1078 and BFNAR 2013:2, corrections must be documented through
+ * verifikationer with clear descriptions. This satisfies that requirement.
+ */
+async function createMigrationAdjustmentEntry(
+ supabase: SupabaseClient,
+ userId: string,
+ fiscalPeriodId: string,
+ parsed: ParsedSIEFile,
+ accountMap: Map,
+ importedMovements: Map,
+ skippedDetails: {
+ voucherId: string
+ date: string
+ reason: string
+ }[]
+): Promise<{ entryId: string | null; deltaAccounts: number; warnings: string[] }> {
+ const warnings: string[] = []
+ const hasUB = parsed.closingBalances.some((b) => b.yearIndex === 0)
+ const hasRES = parsed.resultBalances.some((b) => b.yearIndex === 0)
+
+ if (!hasUB && !hasRES) {
+ return { entryId: null, deltaAccounts: 0, warnings }
+ }
+
+ // Fix 8: Separate BS/P&L reconciliation
+ // For BS accounts (class 1-2): expectedMovement = UB - IB (ignore RES)
+ // For P&L accounts (class 3-8): expectedMovement = RES (ignore IB/UB)
+ const expectedMovements = new Map()
+
+ // Process IB — only for balance sheet accounts
+ for (const ib of parsed.openingBalances.filter((b) => b.yearIndex === 0)) {
+ const target = accountMap.get(ib.account)
+ if (!target) continue
+ if (!isBalanceSheetAccount(target)) {
+ // P&L account appearing in IB — likely malformed SIE
+ warnings.push(`P&L-konto ${ib.account} (→${target}) förekommer i #IB — ignoreras för resultaträkning`)
+ continue
+ }
+ expectedMovements.set(target, (expectedMovements.get(target) || 0) - ib.amount)
+ }
+
+ // Process UB — only for balance sheet accounts
+ for (const ub of parsed.closingBalances.filter((b) => b.yearIndex === 0)) {
+ const target = accountMap.get(ub.account)
+ if (!target) continue
+ if (!isBalanceSheetAccount(target)) {
+ warnings.push(`P&L-konto ${ub.account} (→${target}) förekommer i #UB — ignoreras för resultaträkning`)
+ continue
+ }
+ expectedMovements.set(target, (expectedMovements.get(target) || 0) + ub.amount)
+ }
+
+ // Process RES — only for P&L accounts
+ for (const res of parsed.resultBalances.filter((b) => b.yearIndex === 0)) {
+ const target = accountMap.get(res.account)
+ if (!target) continue
+ if (isBalanceSheetAccount(target)) {
+ warnings.push(`Balanskonto ${res.account} (→${target}) förekommer i #RES — ignoreras för balansräkning`)
+ continue
+ }
+ expectedMovements.set(target, (expectedMovements.get(target) || 0) + res.amount)
+ }
+
+ // Compute per-account delta: expected - imported
+ const lines: CreateJournalEntryLineInput[] = []
+ const allAccounts = new Set([...expectedMovements.keys(), ...importedMovements.keys()])
+ let deltaAccountCount = 0
+
+ for (const account of allAccounts) {
+ const expected = expectedMovements.get(account) || 0
+ const imported = importedMovements.get(account) || 0
+ const delta = Math.round((expected - imported) * 100) / 100
+
+ if (Math.abs(delta) < 0.01) continue
+ deltaAccountCount++
+
+ // Fix 4: Per-line text referencing what the adjustment concerns
+ const lineDesc = `Justering konto ${account}: delta ${delta} SEK från ${skippedDetails.length} exkl. verifikationer`
+
+ if (delta > 0) {
+ lines.push({
+ account_number: account,
+ debit_amount: delta,
+ credit_amount: 0,
+ line_description: lineDesc,
+ })
+ } else {
+ lines.push({
+ account_number: account,
+ debit_amount: 0,
+ credit_amount: Math.abs(delta),
+ line_description: lineDesc,
+ })
+ }
+ }
+
+ if (lines.length === 0) {
+ return { entryId: null, deltaAccounts: 0, warnings }
+ }
+
+ // The entry must balance. It should by construction, but verify and handle rounding.
+ const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ const balanceDiff = Math.round(Math.abs(totalDebit - totalCredit) * 100) / 100
+
+ if (balanceDiff > 0.005) {
+ const roundedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
+ if (roundedDiff > 0) {
+ lines.push({
+ account_number: '3741',
+ debit_amount: 0,
+ credit_amount: Math.abs(roundedDiff),
+ line_description: 'Öresutjämning omföringsverifikation',
+ })
+ } else {
+ lines.push({
+ account_number: '3741',
+ debit_amount: Math.abs(roundedDiff),
+ credit_amount: 0,
+ line_description: 'Öresutjämning omföringsverifikation',
+ })
+ }
+ }
+
+ // Date the adjustment at fiscal year end
+ const fiscalYearEnd = parsed.stats.fiscalYearEnd
+ const entryDate = fiscalYearEnd ? formatDate(fiscalYearEnd) : formatDate(new Date())
+
+ // Fix 4: Build structured description with skipped voucher details
+ const skippedIds = skippedDetails.map(d => d.voucherId)
+ const skippedDates = skippedDetails.map(d => d.date).sort()
+ const firstId = skippedIds[0] || '?'
+ const lastId = skippedIds[skippedIds.length - 1] || '?'
+ const firstDate = skippedDates[0] || '?'
+ const lastDate = skippedDates[skippedDates.length - 1] || '?'
+
+ const entry = await createJournalEntry(supabase, userId, {
+ fiscal_period_id: fiscalPeriodId,
+ entry_date: entryDate,
+ description: `Omföringsverifikation: justering för ${skippedDetails.length} exkluderade verifikationer (${firstId}–${lastId}, ${firstDate}–${lastDate}) vid SIE-import`,
+ source_type: 'import',
+ voucher_series: 'M',
+ lines,
+ })
+
+ return { entryId: entry.id, deltaAccounts: deltaAccountCount, warnings }
+}
+
+/**
+ * Ensure a specific account exists in the user's chart of accounts.
+ * Uses BAS reference for metadata when available, falls back to derivation.
+ */
+async function ensureAccountExists(
+ supabase: SupabaseClient,
+ userId: string,
+ accountNumber: string,
+ accountName: string
+): Promise {
+ const { data } = await supabase
+ .from('chart_of_accounts')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('account_number', accountNumber)
+ .single()
+
+ if (data) return // Already exists
+
+ const basRef = getBASReference(accountNumber)
+
+ if (basRef) {
+ await supabase.from('chart_of_accounts').insert({
+ user_id: userId,
+ account_number: accountNumber,
+ account_name: basRef.account_name,
+ account_class: basRef.account_class,
+ account_group: basRef.account_group,
+ account_type: basRef.account_type,
+ normal_balance: basRef.normal_balance,
+ sru_code: basRef.sru_code ?? computeSRUCode(accountNumber),
+ k2_excluded: basRef.k2_excluded,
+ plan_type: 'full_bas',
+ is_active: true,
+ is_system_account: false,
+ })
+ return
+ }
+
+ // Fallback: derive metadata from account number
+ const classNum = parseInt(accountNumber.charAt(0), 10)
+ const group = accountNumber.substring(0, 2)
+ const accountType = classNum === 1 ? 'asset'
+ : classNum === 2 ? (group === '21' ? 'untaxed_reserves' : (group === '20' ? 'equity' : 'liability'))
+ : classNum === 3 ? 'revenue'
+ : 'expense'
+
+ await supabase.from('chart_of_accounts').insert({
+ user_id: userId,
+ account_number: accountNumber,
+ account_name: accountName,
+ account_class: classNum,
+ account_group: group,
+ account_type: accountType,
+ normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
+ sru_code: computeSRUCode(accountNumber),
+ plan_type: 'full_bas',
+ is_active: true,
+ is_system_account: false,
+ })
+}
+
+/**
+ * Record the import in the database and archive the SIE file to Supabase Storage.
*/
async function recordImport(
supabase: SupabaseClient,
@@ -445,7 +890,8 @@ async function recordImport(
parsed: ParsedSIEFile,
fileContent: string,
filename: string,
- result: ImportResult
+ result: ImportResult,
+ documentation?: MigrationDocumentation
): Promise {
const fileHash = await calculateFileHash(fileContent)
@@ -471,6 +917,7 @@ async function recordImport(
fiscal_period_id: result.fiscalPeriodId,
opening_balance_entry_id: result.openingBalanceEntryId,
imported_at: new Date().toISOString(),
+ migration_documentation: documentation ?? null,
})
.select('id')
.single()
@@ -479,6 +926,22 @@ async function recordImport(
throw new Error(`Failed to record import: ${error?.message}`)
}
+ // Archive the SIE file to Supabase Storage (BFL 7 kap 1-2§ retention)
+ const storagePath = `${userId}/${data.id}.se`
+ const fileBlob = new Blob([fileContent], { type: 'text/plain; charset=cp437' })
+ const { error: uploadError } = await supabase.storage
+ .from('sie-files')
+ .upload(storagePath, fileBlob, { upsert: false })
+
+ if (uploadError) {
+ console.error(`[sie-import] Failed to archive SIE file: ${uploadError.message}`)
+ } else {
+ await supabase
+ .from('sie_imports')
+ .update({ file_storage_path: storagePath })
+ .eq('id', data.id)
+ }
+
return data.id
}
@@ -592,6 +1055,23 @@ export async function executeSIEImport(
// Build account mapping lookup
const accountMap = mappingsToMap(mappings)
+ // Ensure all mapped target accounts exist in chart_of_accounts.
+ // The mapping contains every account referenced in the SIE file; accounts
+ // that were not seeded during onboarding need to be created here so that
+ // journal entry lines can link to them via account_id.
+ const seenTargets = new Set()
+ for (const mapping of mappings) {
+ if (mapping.targetAccount && !seenTargets.has(mapping.targetAccount)) {
+ seenTargets.add(mapping.targetAccount)
+ await ensureAccountExists(
+ supabase,
+ userId,
+ mapping.targetAccount,
+ mapping.targetName
+ )
+ }
+ }
+
// Create or find fiscal period
const fiscalYearStart = parsed.stats.fiscalYearStart
const fiscalYearEnd = parsed.stats.fiscalYearEnd
@@ -626,53 +1106,237 @@ export async function executeSIEImport(
result.fiscalPeriodId = existing.id
}
- // Import opening balances
- if (options.importOpeningBalances && parsed.openingBalances.length > 0 && result.fiscalPeriodId) {
- result.openingBalanceEntryId = await createOpeningBalanceEntry(
- supabase,
- userId,
- result.fiscalPeriodId,
- parsed,
- accountMap
- )
+ // Track documentation data across import phases
+ let ibRoundingAdjustment = 0
+ let migrationAdjustmentInfo = { created: false, deltaAccounts: 0, entryId: null as string | null }
+ let voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }> = []
+ let voucherStats = {
+ total: parsed.vouchers.length,
+ imported: 0,
+ skippedUnbalanced: 0,
+ skippedUnmapped: 0,
+ skippedSingleLine: 0,
+ skippedEmpty: 0,
+ }
+ const voucherSeries = options.voucherSeries || 'B'
- if (result.openingBalanceEntryId) {
- result.journalEntriesCreated++
- result.journalEntryIds.push(result.openingBalanceEntryId)
+ // Validate and import opening balances.
+ //
+ // IB imbalance is NORMAL in Swedish SIE files for two common reasons:
+ // 1. Excluded system accounts (Fortnox 0099 etc.) carry IB balances
+ // 2. Previous year's result (årets resultat) hasn't been allocated to equity
+ // yet — the profit/loss is implicit, not an explicit IB on 2099
+ //
+ // In both cases, the correct treatment is to book the diff to 2099 with
+ // explicit documentation. We never reject based on IB imbalance — the
+ // original goal was to stop SILENT equity alteration, not prevent it.
+ if (options.importOpeningBalances && parsed.openingBalances.length > 0 && result.fiscalPeriodId) {
+ const ibValidation = validateIBBalance(parsed, accountMap)
+
+ if (ibValidation.lines.length > 0) {
+ const absAdj = Math.abs(ibValidation.roundingAdjustment)
+
+ if (absAdj > 0.01) {
+ ibRoundingAdjustment = ibValidation.roundingAdjustment
+
+ // Produce a descriptive warning explaining the source of the imbalance
+ if (Math.abs(ibValidation.excludedAccountsTotal) > 0.01 && ibValidation.fileImbalance <= 1.00) {
+ // File-level IB is balanced — imbalance is entirely from excluded system accounts
+ result.warnings.push(
+ `Exkluderade systemkonton har IB-saldon på totalt ${ibValidation.excludedAccountsTotal} SEK. ` +
+ `Differensen (${ibValidation.roundingAdjustment} SEK) bokförs på konto 2099.`
+ )
+ } else if (ibValidation.fileImbalance > 1.00) {
+ // File-level IB doesn't balance — likely unallocated årets resultat from previous year
+ result.warnings.push(
+ `Ingående balanser obalanserade med ${ibValidation.roundingAdjustment} SEK ` +
+ `(troligen ej allokerat årets resultat från föregående räkenskapsår). ` +
+ `Differensen bokförs på konto 2099 (Årets resultat).`
+ )
+ } else {
+ // Small rounding
+ result.warnings.push(
+ `Avrundningsdifferens vid SIE-import: ${ibValidation.roundingAdjustment} SEK bokförd på konto 2099`
+ )
+ }
+ }
+
+ result.openingBalanceEntryId = await createOpeningBalanceEntry(
+ supabase,
+ userId,
+ result.fiscalPeriodId,
+ parsed,
+ accountMap,
+ ibRoundingAdjustment
+ )
+
+ if (result.openingBalanceEntryId) {
+ result.journalEntriesCreated++
+ result.journalEntryIds.push(result.openingBalanceEntryId)
+ }
}
}
// Import transactions (SIE4 only)
if (options.importTransactions && parsed.vouchers.length > 0 && result.fiscalPeriodId) {
+ // Detect partial-year export: if voucher dates don't span the full fiscal year,
+ // the migration adjustment will produce incorrect large deltas for the missing period.
+ if (parsed.vouchers.length > 0 && fiscalYearStart && fiscalYearEnd) {
+ const voucherDates = parsed.vouchers.map(v => v.date.getTime())
+ const earliestVoucher = new Date(Math.min(...voucherDates))
+ const latestVoucher = new Date(Math.max(...voucherDates))
+
+ // Allow 30 days margin from fiscal year start/end for partial detection
+ const msPerDay = 86400000
+ const startGap = earliestVoucher.getTime() - fiscalYearStart.getTime()
+ const endGap = fiscalYearEnd.getTime() - latestVoucher.getTime()
+
+ if (startGap > 60 * msPerDay || endGap > 60 * msPerDay) {
+ result.warnings.push(
+ `SIE-filen verkar innehålla ett ofullständigt räkenskapsår: verifikationer ${formatDate(earliestVoucher)}–${formatDate(latestVoucher)}, ` +
+ `räkenskapsår ${formatDate(fiscalYearStart)}–${formatDate(fiscalYearEnd)}. ` +
+ `Omföringsverifikationen kan bli felaktig om #UB/#RES avser hela året men verifikationerna bara täcker en del.`
+ )
+ }
+ }
+
+ // Ensure öresutjämning account 3741 exists in the user's chart
+ await ensureAccountExists(supabase, userId, '3741', 'Öresutjämning vid import')
+
const voucherResults = await importVouchers(
supabase,
userId,
result.fiscalPeriodId,
parsed,
accountMap,
- options.voucherSeries || 'B'
+ voucherSeries
)
result.journalEntriesCreated += voucherResults.created
result.journalEntryIds.push(...voucherResults.ids)
result.errors.push(...voucherResults.errors)
+ voucherNumberMapping = voucherResults.voucherNumberMapping
+
+ // Update stats for documentation
+ voucherStats = {
+ total: parsed.vouchers.length,
+ imported: voucherResults.created,
+ skippedUnbalanced: voucherResults.skippedUnbalanced,
+ skippedUnmapped: voucherResults.skippedUnmapped,
+ skippedSingleLine: voucherResults.skippedSingleLine,
+ skippedEmpty: voucherResults.skippedEmpty,
+ }
+
+ // Report skipped vouchers as warnings
+ const totalSkipped = voucherResults.skippedEmpty + voucherResults.skippedSingleLine + voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped
+ if (totalSkipped > 0) {
+ const parts: string[] = []
+ if (voucherResults.skippedEmpty > 0) parts.push(`${voucherResults.skippedEmpty} tomma`)
+ if (voucherResults.skippedUnbalanced > 0) parts.push(`${voucherResults.skippedUnbalanced} obalanserade`)
+ if (voucherResults.skippedUnmapped > 0) parts.push(`${voucherResults.skippedUnmapped} med ej mappade konton`)
+ result.warnings.push(
+ `${totalSkipped} verifikationer hoppades över (ofullständiga i källsystemet): ${parts.join(', ')}`
+ )
+ }
+
+ // Fix 3: Specific warning for single-line vouchers
+ if (voucherResults.skippedSingleLine > 0) {
+ const singleLineDetails = voucherResults.skippedDetails
+ .filter(d => d.reason === 'single_line')
+ .slice(0, 10)
+ .map(d => d.voucherId)
+ result.warnings.push(
+ `${voucherResults.skippedSingleLine} enradsverifikationer hoppades över (kan vara periodiseringar/manuella justeringar): ${singleLineDetails.join(', ')}${voucherResults.skippedSingleLine > 10 ? '...' : ''}`
+ )
+ }
+
+ // Create migration adjustment entry to reconcile against UB/RES
+ const totalSkippedForAdjustment = voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped + voucherResults.skippedSingleLine
+ if (totalSkippedForAdjustment > 0 && result.fiscalPeriodId) {
+ try {
+ const adjustment = await createMigrationAdjustmentEntry(
+ supabase,
+ userId,
+ result.fiscalPeriodId,
+ parsed,
+ accountMap,
+ voucherResults.movementsByAccount,
+ voucherResults.skippedDetails
+ )
+
+ result.warnings.push(...adjustment.warnings)
+
+ if (adjustment.entryId) {
+ result.journalEntriesCreated++
+ result.journalEntryIds.push(adjustment.entryId)
+ result.warnings.push(
+ `Migreringsjustering skapad: ${adjustment.deltaAccounts} konton justerade för att matcha UB/RES från källsystemet`
+ )
+ migrationAdjustmentInfo = {
+ created: true,
+ deltaAccounts: adjustment.deltaAccounts,
+ entryId: adjustment.entryId,
+ }
+ }
+ } catch (adjustmentError) {
+ console.error('[sie-import] Failed to create migration adjustment entry:', adjustmentError)
+ result.warnings.push(
+ 'Kunde inte skapa migreringsjustering — kontrollera saldon manuellt mot källsystemet'
+ )
+ }
+ }
}
// Save account mappings for future use
await saveMappings(supabase, userId, mappings)
- // Record the import
+ // Fix 6: Generate systemdokumentation (MigrationDocumentation)
+ const mappingStats = getMappingStats(mappings)
+ const documentation: MigrationDocumentation = {
+ sourceSystem: parsed.header.program,
+ sourceVersion: parsed.header.programVersion,
+ sieType: parsed.header.sieType,
+ generatedDate: parsed.header.generatedDate ? formatDate(parsed.header.generatedDate) : null,
+ fiscalYear: {
+ start: formatDate(fiscalYearStart),
+ end: formatDate(fiscalYearEnd),
+ },
+ importedAt: new Date().toISOString(),
+ importedBy: userId,
+ accountMappings: {
+ total: mappingStats.total,
+ exact: mappingStats.exact,
+ basRange: mappingStats.basRange,
+ manual: mappingStats.manual,
+ unmapped: mappingStats.unmapped,
+ },
+ vouchers: voucherStats,
+ openingBalanceRounding: ibRoundingAdjustment !== 0 ? ibRoundingAdjustment : null,
+ migrationAdjustment: migrationAdjustmentInfo,
+ voucherSeriesUsed: voucherSeries,
+ voucherNumberRange: voucherNumberMapping.length > 0
+ ? {
+ from: voucherNumberMapping[0].targetNumber,
+ to: voucherNumberMapping[voucherNumberMapping.length - 1].targetNumber,
+ }
+ : null,
+ voucherNumberMapping,
+ }
+
+ // Set success before recording so recordImport() sees correct status
+ result.success = result.errors.length === 0
+
+ // Record the import with documentation
result.importId = await recordImport(
supabase,
userId,
parsed,
options.fileContent,
options.filename,
- result
+ result,
+ documentation
)
- result.success = result.errors.length === 0
-
// Add warnings for any issues
for (const issue of parsed.issues) {
if (issue.severity === 'warning') {
diff --git a/lib/import/sie-parser.ts b/lib/import/sie-parser.ts
index d6a029e0..3eb520ac 100644
--- a/lib/import/sie-parser.ts
+++ b/lib/import/sie-parser.ts
@@ -24,21 +24,43 @@ import type {
ValidationResult,
} from './types'
-// CP437 to UTF-8 mapping for Swedish characters
-// CP437 was the standard encoding for DOS/early Windows
+// CP437 to UTF-8 mapping — full 0x80-0x9F range
+// CP437 was the standard encoding for DOS/early Windows (used by SIE #FORMAT PC8)
const CP437_MAP: Record = {
+ // 0x80-0x8F
+ 0x80: 'Ç', // Ç
+ 0x81: 'ü', // ü
+ 0x82: 'é', // é
+ 0x83: 'â', // â
+ 0x84: 'ä', // ä
+ 0x85: 'à', // à
+ 0x86: 'å', // å
+ 0x87: 'ç', // ç
+ 0x88: 'ê', // ê
+ 0x89: 'ë', // ë
+ 0x8a: 'è', // è
+ 0x8b: 'ï', // ï
+ 0x8c: 'î', // î
+ 0x8d: 'ì', // ì
0x8e: 'Ä', // Ä
0x8f: 'Å', // Å
- 0x99: 'Ö', // Ö
- 0x84: 'ä', // ä
- 0x86: 'å', // å
- 0x94: 'ö', // ö
- 0x81: 'ü', // ü
- 0x9a: 'Ü', // Ü
- 0x92: 'Æ', // Æ (not common but in CP437)
+ // 0x90-0x9F
+ 0x90: 'É', // É
0x91: 'æ', // æ
+ 0x92: 'Æ', // Æ
+ 0x93: 'ô', // ô
+ 0x94: 'ö', // ö
+ 0x95: 'ò', // ò
+ 0x96: 'û', // û
+ 0x97: 'ù', // ù
+ 0x98: 'ÿ', // ÿ
+ 0x99: 'Ö', // Ö
+ 0x9a: 'Ü', // Ü
+ 0x9b: 'ø', // ø (Norwegian)
+ 0x9c: '£', // £
0x9d: 'Ø', // Ø (Norwegian)
- 0x9b: 'ø', // ø
+ 0x9e: '×', // ×
+ 0x9f: 'ƒ', // ƒ
}
// Windows-1252 bytes for Swedish characters (superset of ISO-8859-1)
@@ -53,7 +75,16 @@ const WIN1252_SWEDISH_BYTES = new Set([
])
/**
- * Detect the encoding of a SIE file by looking for Swedish characters
+ * Detect the encoding of a SIE file by looking for Swedish characters.
+ *
+ * Strategy:
+ * 1. UTF-8 BOM → utf8
+ * 2. `#FORMAT PC8` in raw bytes → cp437 (SIE standard header for CP437)
+ * 3. Range-based discrimination: CP437 Swedish chars live in 0x80-0x9F,
+ * Windows-1252 Swedish chars live in 0xC0-0xFF. These ranges don't overlap,
+ * so presence in one range rules out the other.
+ * 4. UTF-8 multi-byte sequences (0xC3 + continuation) are detected with proper
+ * skipping of continuation bytes to avoid false CP437 counts.
*/
export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
const bytes = new Uint8Array(buffer)
@@ -63,11 +94,27 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
return 'utf8'
}
- // Look for encoding-specific Swedish characters in first 1000 bytes
- const sampleSize = Math.min(bytes.length, 1000)
- let cp437Count = 0
- let utf8Count = 0
- let win1252Count = 0
+ // Check for #FORMAT PC8 in the first 500 bytes (ASCII-safe, works regardless of encoding)
+ const headerSize = Math.min(bytes.length, 500)
+ const FORMAT_PC8 = [0x23, 0x46, 0x4f, 0x52, 0x4d, 0x41, 0x54, 0x20, 0x50, 0x43, 0x38]
+ for (let i = 0; i <= headerSize - FORMAT_PC8.length; i++) {
+ let match = true
+ for (let j = 0; j < FORMAT_PC8.length; j++) {
+ if (bytes[i + j] !== FORMAT_PC8[j]) {
+ match = false
+ break
+ }
+ }
+ if (match) {
+ return 'cp437'
+ }
+ }
+
+ // Scan sample for encoding-specific byte ranges
+ const sampleSize = Math.min(bytes.length, 2000)
+ let cp437Count = 0 // Swedish chars in 0x80-0x9F (CP437 range)
+ let utf8Count = 0 // Valid UTF-8 multi-byte Swedish sequences
+ let win1252Count = 0 // Swedish chars in 0xC0-0xFF (Win-1252 range)
for (let i = 0; i < sampleSize; i++) {
const byte = bytes[i]
@@ -88,8 +135,20 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
const nextByte = bytes[i + 1]
if ([0x84, 0x85, 0x96, 0xa4, 0xa5, 0xb6].includes(nextByte)) {
utf8Count++
+ i++ // Skip continuation byte to avoid false CP437 count (e.g. 0x84 = ä in CP437)
+ continue
}
}
+
+ // CP437 Swedish chars live in 0x80-0x9F
+ if (byte >= 0x80 && byte <= 0x9f && CP437_MAP[byte]) {
+ cp437Count++
+ }
+
+ // Windows-1252 Swedish chars live in 0xC0-0xFF
+ if (WIN1252_SWEDISH_BYTES.has(byte)) {
+ win1252Count++
+ }
}
if (utf8Count > cp437Count && utf8Count > win1252Count) return 'utf8'
@@ -277,6 +336,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
address: null,
fiscalYears: [],
currency: 'SEK',
+ kontoPlanType: null,
}
const accounts: SIEAccount[] = []
@@ -377,6 +437,10 @@ export function parseSIEFile(content: string): ParsedSIEFile {
header.currency = parseStringField(fields[1]) || 'SEK'
break
+ case 'KPTYP':
+ header.kontoPlanType = parseStringField(fields[1])
+ break
+
case 'RAR': {
// #RAR yearIndex start end
const yearIndex = parseInt(fields[1], 10)
@@ -516,10 +580,16 @@ export function parseSIEFile(content: string): ParsedSIEFile {
break
}
- case 'TRANS': {
- // #TRANS accountNumber {objectList} amount [date] [description] [quantity] [signature]
+ case 'TRANS':
+ case 'RTRANS':
+ case 'BTRANS': {
+ // #TRANS/#RTRANS/#BTRANS accountNumber {objectList} amount [date] [description] [quantity] [signature]
+ // BTRANS = Added/corrected transaction lines (part of the voucher)
+ // RTRANS = Removed/reversed transaction lines (amounts already have correct sign)
+ // All three must be included for vouchers to balance correctly.
+ // Fortnox/Bokio/Visma only emit #TRANS — this is a no-op for those providers.
if (!currentVoucher) {
- addIssue(issues, 'error', lineNum, 'TRANS outside of VER block', tag)
+ addIssue(issues, 'error', lineNum, `${tag} outside of VER block`, tag)
break
}
@@ -534,7 +604,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
const transAmountStr = fields[fieldIndex]
if (!transAmountStr || transAmountStr.trim() === '') {
- addIssue(issues, 'warning', lineNum, 'Missing amount in #TRANS, skipping line', tag)
+ addIssue(issues, 'warning', lineNum, `Missing amount in #${tag}, skipping line`, tag)
break
}
@@ -563,17 +633,9 @@ export function parseSIEFile(content: string): ParsedSIEFile {
break
}
- case 'RTRANS':
- case 'BTRANS':
- // BTRANS = Balance transactions (preliminary/carried-forward balances)
- // RTRANS = Reversed/corrected transactions
- // These are supplementary lines and should NOT be included in balance validation
- // or imported as regular transaction lines. Skip them.
- break
-
default:
// Unknown tag - add info issue for notable ones
- if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'KPTYP', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
+ if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
addIssue(issues, 'info', lineNum, `Unknown tag: #${tag}`, tag)
}
}
@@ -588,6 +650,28 @@ export function parseSIEFile(content: string): ParsedSIEFile {
}
}
+ // Collect accounts referenced in balances and vouchers but missing from #KONTO
+ const definedAccountNumbers = new Set(accounts.map((a) => a.number))
+ const referencedAccounts = new Set()
+
+ for (const balance of [...openingBalances, ...closingBalances, ...resultBalances]) {
+ if (balance.account && !definedAccountNumbers.has(balance.account)) {
+ referencedAccounts.add(balance.account)
+ }
+ }
+ for (const voucher of vouchers) {
+ for (const line of voucher.lines) {
+ if (line.account && !definedAccountNumbers.has(line.account)) {
+ referencedAccounts.add(line.account)
+ }
+ }
+ }
+
+ for (const accountNumber of referencedAccounts) {
+ accounts.push({ number: accountNumber, name: '' })
+ addIssue(issues, 'info', 0, `Account ${accountNumber} added from transaction data (not in #KONTO)`)
+ }
+
// Calculate statistics
const currentFiscalYear = header.fiscalYears.find((fy) => fy.yearIndex === 0)
const totalTransactionLines = vouchers.reduce((sum, v) => sum + v.lines.length, 0)
@@ -637,6 +721,17 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
warnings.push('No accounts found (#KONTO)')
}
+ // Warn if non-BAS kontoplan declared — mapping logic assumes BAS number ranges
+ if (parsed.header.kontoPlanType) {
+ const planType = parsed.header.kontoPlanType.toUpperCase()
+ const isBAS = planType.startsWith('BAS') || planType === 'EUBAS' || planType === 'EU-BAS'
+ if (!isBAS) {
+ warnings.push(
+ `Kontoplanstyp "${parsed.header.kontoPlanType}" är inte BAS-baserad. Alla kontomappningar bör granskas manuellt.`
+ )
+ }
+ }
+
// Check for unbalanced vouchers
for (const voucher of parsed.vouchers) {
const total = voucher.lines.reduce((sum, l) => sum + l.amount, 0)
diff --git a/lib/import/types.ts b/lib/import/types.ts
index 1a49cbb1..017fe96e 100644
--- a/lib/import/types.ts
+++ b/lib/import/types.ts
@@ -39,6 +39,7 @@ export interface SIEHeader {
// Fiscal year info
fiscalYears: FiscalYearInfo[] // #RAR
currency: string // #VALUTA (default SEK)
+ kontoPlanType: string | null // #KPTYP (e.g. 'BAS95', 'BAS96', 'EUBAS')
}
/**
@@ -189,6 +190,7 @@ export interface SIEImport {
fiscal_period_id: string | null
opening_balance_entry_id: string | null
imported_at: string | null
+ migration_documentation: MigrationDocumentation | null
created_at: string
updated_at: string
}
@@ -284,10 +286,65 @@ export interface ImportPreview {
lowConfidence: number
}
+ // Source-system accounts excluded from import (e.g. Fortnox 0099)
+ excludedSystemAccounts: { number: string; name: string }[]
+
// Issues to review
issues: ParseIssue[]
}
+/**
+ * Structured systemdokumentation per BFNAR 2013:2 Chapter 9.
+ * Generated at the end of a SIE import and stored in sie_imports.migration_documentation.
+ */
+export interface MigrationDocumentation {
+ // Source system info
+ sourceSystem: string | null // from #PROGRAM
+ sourceVersion: string | null
+ sieType: number
+ generatedDate: string | null // from #GEN
+
+ // Import scope
+ fiscalYear: { start: string; end: string }
+ importedAt: string
+ importedBy: string // user_id
+
+ // Account mapping
+ accountMappings: {
+ total: number
+ exact: number
+ basRange: number
+ manual: number
+ unmapped: number
+ }
+
+ // Voucher statistics
+ vouchers: {
+ total: number
+ imported: number
+ skippedUnbalanced: number
+ skippedUnmapped: number
+ skippedSingleLine: number
+ skippedEmpty: number
+ }
+
+ // Adjustments
+ openingBalanceRounding: number | null // SEK amount if any
+ migrationAdjustment: {
+ created: boolean
+ deltaAccounts: number
+ entryId: string | null
+ }
+
+ // Voucher number mapping
+ voucherSeriesUsed: string
+ voucherNumberRange: { from: number; to: number } | null
+ voucherNumberMapping: Array<{
+ sourceId: string // e.g. "A1"
+ targetNumber: number
+ }>
+}
+
/**
* Wizard step state
*/
diff --git a/public/logos/Briox_logo.png b/public/logos/Briox_logo.png
new file mode 100644
index 00000000..2526b1fc
Binary files /dev/null and b/public/logos/Briox_logo.png differ
diff --git a/public/logos/bjornlunden.png b/public/logos/bjornlunden.png
new file mode 100644
index 00000000..5a1f2df1
Binary files /dev/null and b/public/logos/bjornlunden.png differ
diff --git a/public/logos/bokio.png b/public/logos/bokio.png
new file mode 100644
index 00000000..74fa663a
Binary files /dev/null and b/public/logos/bokio.png differ
diff --git a/public/logos/fortnox.svg b/public/logos/fortnox.svg
new file mode 100644
index 00000000..f87d7ee0
--- /dev/null
+++ b/public/logos/fortnox.svg
@@ -0,0 +1,9 @@
+
+ fortnox-brand-symbol-svg
+
+
+
+
+
+
+
diff --git a/public/logos/visma.jpeg b/public/logos/visma.jpeg
new file mode 100644
index 00000000..ca885cdc
Binary files /dev/null and b/public/logos/visma.jpeg differ
diff --git a/scripts/backfill-import-accounts.ts b/scripts/backfill-import-accounts.ts
new file mode 100644
index 00000000..9c1a6844
--- /dev/null
+++ b/scripts/backfill-import-accounts.ts
@@ -0,0 +1,284 @@
+#!/usr/bin/env npx tsx
+/**
+ * Backfill missing accounts into chart_of_accounts for ALL tenants.
+ *
+ * Finds accounts referenced by journal_entry_lines but not in chart_of_accounts,
+ * resolves metadata from BAS reference (primary) or SIE account mappings (fallback),
+ * and inserts them.
+ *
+ * Usage: npx tsx scripts/backfill-import-accounts.ts [--dry-run]
+ */
+
+import { config } from 'dotenv'
+config({ path: '.env.local' })
+import { createClient } from '@supabase/supabase-js'
+import { getBASReference } from '../lib/bookkeeping/bas-reference'
+import { computeSRUCode } from '../lib/bookkeeping/bas-data/sru-mapping'
+
+const DRY_RUN = process.argv.includes('--dry-run')
+
+const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
+const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
+
+if (!supabaseUrl || !serviceRoleKey) {
+ console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env')
+ process.exit(1)
+}
+
+const supabase = createClient(supabaseUrl, serviceRoleKey)
+
+// ---------------------------------------------------------------------------
+// Non-BAS account overrides (company-specific sub-accounts not in BAS 2026)
+// Only used when BAS reference and SIE source_name both miss.
+// ---------------------------------------------------------------------------
+
+interface AccountOverride {
+ account_name: string
+ account_type?: 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
+ normal_balance?: 'debit' | 'credit'
+}
+
+const NON_BAS_OVERRIDES: Record = {
+ '1402': { account_name: 'Förråd av varor' },
+ '1799': { account_name: 'Observationskonto' },
+ '2662': {
+ account_name: 'Kortfristig skuld Le comptoir',
+ account_type: 'liability',
+ normal_balance: 'credit',
+ },
+ '3041': { account_name: 'Försäljning tjänster 25% Sverige' },
+ '3051': { account_name: 'Försäljning varor 25% Sverige' },
+ '3052': { account_name: 'Försäljning varor 12% Sverige' },
+ '4020': { account_name: 'Alkoholskatt' },
+ '4056': { account_name: 'Inköp varor 25% EU' },
+ '4057': { account_name: 'Inköp varor 12% EU' },
+ '4071': { account_name: 'Lagerkostnader' },
+ '4072': { account_name: 'Inköp frakt 25% EU' },
+ '4990': { account_name: 'Lagerförändring' },
+ '4992': { account_name: 'Varor på väg' },
+ '6561': { account_name: 'GS1' },
+ '8300': { account_name: 'Ränteintäkter (gruppkonto)' },
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function deriveAccountType(accountNumber: string): 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves' {
+ const classNum = parseInt(accountNumber.charAt(0), 10)
+ const group = accountNumber.substring(0, 2)
+
+ if (classNum === 1) return 'asset'
+ if (classNum === 2) {
+ if (group === '20') return 'equity'
+ if (group === '21') return 'untaxed_reserves'
+ return 'liability'
+ }
+ if (classNum === 3) return 'revenue'
+ return 'expense'
+}
+
+function deriveNormalBalance(accountNumber: string): 'debit' | 'credit' {
+ const classNum = parseInt(accountNumber.charAt(0), 10)
+ return classNum <= 1 || classNum >= 4 ? 'debit' : 'credit'
+}
+
+async function getUsedAccountNumbers(userId: string): Promise> {
+ const usedSet = new Set()
+ const PAGE_SIZE = 1000
+ let offset = 0
+ let hasMore = true
+
+ while (hasMore) {
+ const { data: batch, error } = await supabase
+ .from('journal_entry_lines')
+ .select('account_number, journal_entries!inner(user_id)')
+ .eq('journal_entries.user_id', userId)
+ .range(offset, offset + PAGE_SIZE - 1)
+
+ if (error) throw new Error(`Failed to fetch lines for ${userId}: ${error.message}`)
+
+ for (const row of batch ?? []) {
+ usedSet.add(row.account_number)
+ }
+
+ hasMore = (batch?.length ?? 0) === PAGE_SIZE
+ offset += PAGE_SIZE
+ }
+
+ return usedSet
+}
+
+async function getSIESourceNames(userId: string): Promise> {
+ const { data, error } = await supabase
+ .from('sie_account_mappings')
+ .select('source_account, source_name')
+ .eq('user_id', userId)
+
+ if (error) throw new Error(`Failed to fetch SIE mappings for ${userId}: ${error.message}`)
+
+ const map = new Map()
+ for (const row of data ?? []) {
+ map.set(row.source_account, row.source_name)
+ }
+ return map
+}
+
+// ---------------------------------------------------------------------------
+// Per-tenant backfill
+// ---------------------------------------------------------------------------
+
+async function backfillForUser(userId: string): Promise {
+ console.log(`\n--- User ${userId} ---`)
+
+ // Get existing accounts
+ const { data: existingAccounts, error: existingError } = await supabase
+ .from('chart_of_accounts')
+ .select('account_number')
+ .eq('user_id', userId)
+
+ if (existingError) throw new Error(`Failed to fetch accounts: ${existingError.message}`)
+ const existingSet = new Set(existingAccounts?.map(a => a.account_number) ?? [])
+
+ // Get used account numbers from journal entries
+ const usedSet = await getUsedAccountNumbers(userId)
+ const missingAccounts = [...usedSet].filter(num => !existingSet.has(num)).sort()
+
+ if (missingAccounts.length === 0) {
+ console.log(' No missing accounts.')
+ return 0
+ }
+
+ console.log(` Found ${missingAccounts.length} missing accounts`)
+
+ // Get SIE source names as fallback for account naming
+ const sieNames = await getSIESourceNames(userId)
+
+ // Build insert rows
+ const rows = missingAccounts.map(accountNumber => {
+ const basRef = getBASReference(accountNumber)
+
+ if (basRef) {
+ return {
+ user_id: userId,
+ account_number: accountNumber,
+ account_name: basRef.account_name,
+ account_class: basRef.account_class,
+ account_group: basRef.account_group,
+ account_type: basRef.account_type,
+ normal_balance: basRef.normal_balance,
+ sru_code: basRef.sru_code ?? computeSRUCode(accountNumber),
+ k2_excluded: basRef.k2_excluded,
+ plan_type: 'full_bas' as const,
+ is_active: true,
+ is_system_account: false,
+ }
+ }
+
+ // Check hardcoded overrides (for company-specific accounts with known metadata)
+ const override = NON_BAS_OVERRIDES[accountNumber]
+ if (override) {
+ const accountType = override.account_type ?? deriveAccountType(accountNumber)
+ const normalBalance = override.normal_balance ?? deriveNormalBalance(accountNumber)
+ const classNum = parseInt(accountNumber.charAt(0), 10)
+ return {
+ user_id: userId,
+ account_number: accountNumber,
+ account_name: override.account_name,
+ account_class: classNum,
+ account_group: accountNumber.substring(0, 2),
+ account_type: accountType,
+ normal_balance: normalBalance,
+ sru_code: computeSRUCode(accountNumber),
+ k2_excluded: false,
+ plan_type: 'full_bas' as const,
+ is_active: true,
+ is_system_account: false,
+ }
+ }
+
+ // Fallback: use SIE source_name if available, otherwise derive
+ const sieName = sieNames.get(accountNumber)
+ const classNum = parseInt(accountNumber.charAt(0), 10)
+ if (sieName) {
+ console.warn(` INFO: Account ${accountNumber} not in BAS — using SIE name: "${sieName}"`)
+ } else {
+ console.warn(` WARNING: Account ${accountNumber} not in BAS or SIE — deriving all metadata`)
+ }
+
+ return {
+ user_id: userId,
+ account_number: accountNumber,
+ account_name: sieName ?? `Konto ${accountNumber}`,
+ account_class: classNum,
+ account_group: accountNumber.substring(0, 2),
+ account_type: deriveAccountType(accountNumber),
+ normal_balance: deriveNormalBalance(accountNumber),
+ sru_code: computeSRUCode(accountNumber),
+ k2_excluded: false,
+ plan_type: 'full_bas' as const,
+ is_active: true,
+ is_system_account: false,
+ }
+ })
+
+ // Log summary
+ const fromBAS = rows.filter(r => getBASReference(r.account_number)).length
+ const fromOverride = rows.filter(r => !getBASReference(r.account_number) && NON_BAS_OVERRIDES[r.account_number]).length
+ const fromFallback = rows.length - fromBAS - fromOverride
+ console.log(` ${fromBAS} from BAS, ${fromOverride} from overrides, ${fromFallback} from SIE/derived`)
+
+ for (const row of rows) {
+ console.log(` ${row.account_number} — ${row.account_name} (${row.account_type}, SRU: ${row.sru_code ?? 'none'})`)
+ }
+
+ if (DRY_RUN) {
+ console.log(` [DRY RUN] Would insert ${rows.length} accounts`)
+ return rows.length
+ }
+
+ const { error: insertError } = await supabase
+ .from('chart_of_accounts')
+ .insert(rows)
+
+ if (insertError) {
+ console.error(` Insert failed: ${insertError.message}`)
+ return 0
+ }
+
+ console.log(` Inserted ${rows.length} accounts`)
+ return rows.length
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+async function main() {
+ if (DRY_RUN) console.log('=== DRY RUN MODE ===\n')
+
+ // Find all users with missing accounts
+ const { data: allImportUsers, error } = await supabase
+ .from('sie_imports')
+ .select('user_id')
+
+ if (error) {
+ console.error('Failed to fetch SIE import users:', error.message)
+ process.exit(1)
+ }
+
+ const userIds = [...new Set(allImportUsers?.map(r => r.user_id) ?? [])]
+ console.log(`Found ${userIds.length} users with SIE imports`)
+
+ let totalInserted = 0
+ for (const userId of userIds) {
+ totalInserted += await backfillForUser(userId)
+ }
+
+ console.log(`\n=== Done: ${totalInserted} accounts ${DRY_RUN ? 'would be' : ''} inserted across ${userIds.length} users ===`)
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/scripts/backfill-sie-files.ts b/scripts/backfill-sie-files.ts
new file mode 100644
index 00000000..effc4262
--- /dev/null
+++ b/scripts/backfill-sie-files.ts
@@ -0,0 +1,130 @@
+#!/usr/bin/env npx tsx
+/**
+ * Backfill SIE file archival for existing imports.
+ *
+ * Accepts a directory of SIE files, computes SHA-256 hashes, matches against
+ * sie_imports.file_hash, uploads to Supabase Storage, and populates
+ * file_storage_path.
+ *
+ * Usage: npx tsx scripts/backfill-sie-files.ts
+ */
+
+import { config } from 'dotenv'
+config({ path: '.env.local' })
+import { createClient } from '@supabase/supabase-js'
+import { readdir, readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { createHash } from 'node:crypto'
+
+const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
+const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
+
+if (!supabaseUrl || !serviceRoleKey) {
+ console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env')
+ process.exit(1)
+}
+
+const supabase = createClient(supabaseUrl, serviceRoleKey)
+
+async function calculateFileHash(content: string): Promise {
+ const hash = createHash('sha256')
+ hash.update(content)
+ return hash.digest('hex')
+}
+
+async function main() {
+ const dir = process.argv[2]
+ if (!dir) {
+ console.error('Usage: npx tsx scripts/backfill-sie-files.ts ')
+ process.exit(1)
+ }
+
+ // 1. Read all SIE files from directory
+ const files = await readdir(dir)
+ const sieFiles = files.filter(f => f.toLowerCase().endsWith('.se') || f.toLowerCase().endsWith('.si'))
+
+ if (sieFiles.length === 0) {
+ console.error(`No .se/.si files found in ${dir}`)
+ process.exit(1)
+ }
+
+ console.log(`Found ${sieFiles.length} SIE files in ${dir}`)
+
+ // 2. Get all existing imports without file_storage_path
+ const { data: imports, error: importError } = await supabase
+ .from('sie_imports')
+ .select('id, user_id, file_hash, filename, file_storage_path')
+ .is('file_storage_path', null)
+
+ if (importError) {
+ console.error('Failed to fetch imports:', importError.message)
+ process.exit(1)
+ }
+
+ if (!imports || imports.length === 0) {
+ console.log('No imports need backfilling.')
+ return
+ }
+
+ console.log(`Found ${imports.length} imports without archived files`)
+
+ // Build hash→import mapping
+ const hashToImport = new Map()
+ for (const imp of imports) {
+ if (imp.file_hash) {
+ hashToImport.set(imp.file_hash, imp)
+ }
+ }
+
+ // 3. Match files by hash and upload
+ let matched = 0
+ let uploaded = 0
+
+ for (const filename of sieFiles) {
+ const filePath = join(dir, filename)
+ const content = await readFile(filePath, 'utf-8')
+ const hash = await calculateFileHash(content)
+
+ const imp = hashToImport.get(hash)
+ if (!imp) {
+ console.log(` ${filename} — no matching import (hash: ${hash.substring(0, 12)}...)`)
+ continue
+ }
+
+ matched++
+ console.log(` ${filename} → import ${imp.id} (${imp.filename})`)
+
+ // Upload to storage
+ const storagePath = `${imp.user_id}/${imp.id}.se`
+ const fileBlob = new Blob([content], { type: 'text/plain; charset=cp437' })
+ const { error: uploadError } = await supabase.storage
+ .from('sie-files')
+ .upload(storagePath, fileBlob, { upsert: false })
+
+ if (uploadError) {
+ console.error(` Upload failed: ${uploadError.message}`)
+ continue
+ }
+
+ // Update import record
+ const { error: updateError } = await supabase
+ .from('sie_imports')
+ .update({ file_storage_path: storagePath })
+ .eq('id', imp.id)
+
+ if (updateError) {
+ console.error(` DB update failed: ${updateError.message}`)
+ continue
+ }
+
+ uploaded++
+ console.log(` Archived to ${storagePath}`)
+ }
+
+ console.log(`\nDone: ${matched} matched, ${uploaded} uploaded, ${sieFiles.length - matched} unmatched`)
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/sentry.client.config.ts b/sentry.client.config.ts
index ef064cb6..9eefd0a3 100644
--- a/sentry.client.config.ts
+++ b/sentry.client.config.ts
@@ -1,7 +1,13 @@
import * as Sentry from "@sentry/nextjs";
+const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
+
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
- tracesSampleRate: 0.1,
- enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
+ enabled: isHosted && !!process.env.NEXT_PUBLIC_SENTRY_DSN,
+ environment: process.env.NODE_ENV,
+ tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
+ replaysSessionSampleRate: 0,
+ replaysOnErrorSampleRate: 1.0,
+ integrations: [Sentry.replayIntegration()],
});
diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts
index 618aa413..c80cac49 100644
--- a/sentry.edge.config.ts
+++ b/sentry.edge.config.ts
@@ -1,7 +1,10 @@
import * as Sentry from "@sentry/nextjs";
+const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
+
Sentry.init({
- dsn: process.env.SENTRY_DSN,
- tracesSampleRate: 0.1,
- enabled: !!process.env.SENTRY_DSN,
+ dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,
+ enabled: isHosted && !!(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN),
+ environment: process.env.NODE_ENV,
+ tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
});
diff --git a/sentry.server.config.ts b/sentry.server.config.ts
index 618aa413..c80cac49 100644
--- a/sentry.server.config.ts
+++ b/sentry.server.config.ts
@@ -1,7 +1,10 @@
import * as Sentry from "@sentry/nextjs";
+const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
+
Sentry.init({
- dsn: process.env.SENTRY_DSN,
- tracesSampleRate: 0.1,
- enabled: !!process.env.SENTRY_DSN,
+ dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,
+ enabled: isHosted && !!(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN),
+ environment: process.env.NODE_ENV,
+ tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
});
diff --git a/types/index.ts b/types/index.ts
index ff8c9e21..9d18a931 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -1016,7 +1016,6 @@ export interface CreateFiscalPeriodInput {
export interface OnboardingProgress {
hasCustomers: boolean
hasInvoices: boolean
- hasReceipts: boolean
hasBankConnected: boolean
}