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. +

+ +
+
+ + + ); +} 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. +

+ +
+
+ ); +} 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 ( + + ) + })} +
+
+
+
+ ) +} + +// ── 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. +

+ +
+ )} + + {/* Token-based flow */} + {authType === 'token' && consentId && !isLoading && ( +
+

+ {tokenHelpText} +

+
+ {needsApiToken && ( +
+ + setApiToken(e.target.value)} + /> +
+ )} + {needsCompanyId && ( +
+ + setCompanyId(e.target.value)} + /> +
+ )} + +
+
+ )} +
+
+ +
+ +
+
+ ) +} + +// ── 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 && ( +
+ +

{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. +

+
+
+ )} +
+
+ +
+ + +
+
+ ) +} + +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}

+
+
+
+
+ +
+ ) + } + + 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')} + /> +
+
+ +
+ + +
+ + { + 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. + + + +
+
+ {progress}% +
+ +
+
+ +

{currentStep}

+
+
+
+ ) +} + +// ── 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}

+
+
+
+
+
+ + +
+
+ ) + } + + 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

+
+
+
+
+ +
+ +
+ + +
+
+
+ ) +} + +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 }