From 2ad8731dc90df9f7d44a0379e3913bffb88e57bd Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:24:54 +0100 Subject: [PATCH] feat: arcim migration wizard UX, import fixes, Sentry setup (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: import system improvements, INK2 fix, and Swedish text corrections - SIE parser: Windows-1252 and CP437 encoding detection and decoding - Bank file parser: add Nordea Business (Företag) CSV format - Bank file parser: improve format detection for SEB, Länsförsäkringar, generic CSV - INK2 engine: calculate årets resultat (7222) from income statement for open fiscal years - Dashboard: parallel Supabase queries, simplified dashboard page - Fix Swedish characters (å, ä, ö) in BAS data descriptions, validation messages, AI consent disclosures - Import wizard UI improvements across all steps - Migration: add 'bas_range' match type to sie_account_mappings constraint - Extensive new tests for SIE parser encoding and bank file parser Co-Authored-By: Claude Opus 4.6 * feat: arcim migration wizard UX fixes, Sentry setup, and extension scaffolding Arcim migration wizard improvements: - Progress bar now excludes non-interactive steps (migrating/result) - Fix OAuth text to match target="_blank" behavior (new tab, not redirect) - Display month names instead of "Månad X" in preview - Fix Swedish typo "förifylla" in no-company-info message - Replace native checkboxes with shadcn Switch in options step - Add ConfirmationDialog before starting migration - Show progress percentage during migration - Add "Nästa steg" guidance and navigation links in result step - Add "Försök igen" button in error state (returns to options) - Add Bokio company ID help text (GUID from URL) - Add Fortnox integration add-on hint on connection failure Also includes: SIE import system improvements, INK2 fixes, Swedish text corrections, Sentry error tracking setup, and arcim-migration extension scaffolding. Co-Authored-By: Claude Opus 4.6 * fix: address PR review feedback - Fix OAuth error recovery blank page (restore provider from URL params) - Pass real userId to MigrationWizard instead of empty string - Remove ~50 debug console.log statements from sie-import.ts - Fix comment referencing account 3740 → 3741 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/(dashboard)/import/page.tsx | 58 +- app/(dashboard)/layout.tsx | 2 + app/(dashboard)/page.tsx | 1 - app/api/import/sie/[id]/route.ts | 25 +- app/api/import/sie/parse/route.ts | 15 +- app/global-error.tsx | 37 + app/sentry-example-page/page.tsx | 27 + components/SentryIdentify.tsx | 21 + components/dashboard/DashboardContent.tsx | 1 - .../general/ArcimMigrationWorkspace.tsx | 1380 +++++++++++++++++ components/import/SIEPreviewStep.tsx | 11 + components/onboarding/NewUserChecklist.tsx | 9 - extensions.config.json | 2 +- extensions.schema.json | 3 +- extensions/general/arcim-migration/index.ts | 572 +++++++ .../arcim-migration/lib/arcim-client.ts | 215 +++ .../arcim-migration/lib/entity-mapper.ts | 309 ++++ .../lib/migration-orchestrator.ts | 449 ++++++ .../general/arcim-migration/manifest.json | 19 + extensions/general/arcim-migration/types.ts | 245 +++ lib/extensions/__tests__/sectors.test.ts | 4 +- .../_generated/enabled-extensions.ts | 1 + lib/extensions/_generated/extension-list.ts | 2 + .../_generated/sector-definitions.ts | 10 + lib/extensions/_generated/workspace-map.tsx | 1 + lib/extensions/toggle-check.ts | 1 + lib/import/__tests__/account-mapper.test.ts | 87 +- lib/import/__tests__/sie-import.test.ts | 115 +- lib/import/__tests__/sie-parser.test.ts | 142 +- lib/import/account-mapper.ts | 38 +- lib/import/sie-import.ts | 766 ++++++++- lib/import/sie-parser.ts | 153 +- lib/import/types.ts | 57 + public/logos/Briox_logo.png | Bin 0 -> 45636 bytes public/logos/bjornlunden.png | Bin 0 -> 4041 bytes public/logos/bokio.png | Bin 0 -> 1640 bytes public/logos/fortnox.svg | 9 + public/logos/visma.jpeg | Bin 0 -> 4406 bytes scripts/backfill-import-accounts.ts | 284 ++++ scripts/backfill-sie-files.ts | 130 ++ sentry.client.config.ts | 10 +- sentry.edge.config.ts | 9 +- sentry.server.config.ts | 9 +- types/index.ts | 1 - 44 files changed, 5108 insertions(+), 122 deletions(-) create mode 100644 app/global-error.tsx create mode 100644 app/sentry-example-page/page.tsx create mode 100644 components/SentryIdentify.tsx create mode 100644 components/extensions/general/ArcimMigrationWorkspace.tsx create mode 100644 extensions/general/arcim-migration/index.ts create mode 100644 extensions/general/arcim-migration/lib/arcim-client.ts create mode 100644 extensions/general/arcim-migration/lib/entity-mapper.ts create mode 100644 extensions/general/arcim-migration/lib/migration-orchestrator.ts create mode 100644 extensions/general/arcim-migration/manifest.json create mode 100644 extensions/general/arcim-migration/types.ts create mode 100644 public/logos/Briox_logo.png create mode 100644 public/logos/bjornlunden.png create mode 100644 public/logos/bokio.png create mode 100644 public/logos/fortnox.svg create mode 100644 public/logos/visma.jpeg create mode 100644 scripts/backfill-import-accounts.ts create mode 100644 scripts/backfill-sie-files.ts 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 0000000000000000000000000000000000000000..2526b1fc94956e1ca58e08a910e112aa404ca646 GIT binary patch literal 45636 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 z zs)InT@7XDiBG$z!6(`yW=6z?%4aZXLphLDnfrhR#mwv>)5S>_Yz{ahM$1&Ivn5G3$ zSyNYlfyTa5Pn!WyEWtNfq`!*2`s2KRh8r}Psw0y*^s;W-eTv3|k8db^?0Dk1I5$&= zGfZqEPq*I6{WMMw&f>Zc@K{4ZO0~ zAjj9i1r!}kRM@V1X(371cWWRMig`mrsL78OiR8K)pVy8hq~@LvUHxWsMGJahQK7Bv zDW3spz`bF7u;1*Fxz&$%?ZwH@6-T^qOY)DO8U7>^y=WumAHzdD`Vr@w8c?h{H~@%7 ztm{W`87sXN_;-bzi3Gcb#=5*rKp!(F2;??D0pu0Yf{gFOk`Y;!&oTNmKZ>BB5KXJX z<@AS|qkc9hYWP@i)t5EhTbb|Nj{_PB)TuV1J5$lkMy$RgI=BfpH37?UtOZUAE+@VA zE4@xK_(LeHXp84ig%7yEQ~-AHsC$>5*)n(pFOMmVh`+LnRb5_+@BgBq+?@OOgn~mk zi@gQcd}jyhg|-bUG)5f7&eL**FpHSK*syt@7$Ch}(p7~L7|bQPcUkfCzIe0X&KA{7 zb5r9pNoIMzxO`lMg%%HYieOT46EQiY^==dW_O&TmcQxjPa*Wk8j|V4KQM)|6*(?5- zPmY+2YV4xVaO_~jWJ1*v21fE-qUfn(FMg}y&hdml%Ok!*92Z%#2V$4*59R{1;YMXkb9rGSrZbPi@MDg%j8kn+WOdjDo&`d5ed? z+{sz?P}z@0cS#qV(>fNmKn!Vdesi0L`n~%|A&V-=YLEQ3kpXipU(OUlZL7!%)Oh`` zOA6fFuJChF(imNQmfUctz~>v>`cuXRGDI zUIWc0&`&zoRpz$v5w_nCdXH-dYao_)q;MPbPj1p~d!%3t7BS@ff`HMC+u!^V-vy|v zi2GGz$-m*H zdeu$ug97eTbs0Cp;=iaxKx3+(D%OJPWAp+=AL(5-J*Jd|<_Gojb0o&{yv5dcPRacyWF;XZ%aC!FNr zK%&m7AQ^wdNuY14vY+e;4+0j{ZLd_{{IJ?Sx-`?=37UY%GAQlj)6jc*|Gz*`=B-hRUXLA&^6*t(xOL6}{*r;1Xm@+q$Mm4|2g8fW#*oajbu= zz*m*E8vf5vN`#)PSKH7(-Pqhyy5mb)$hRrH^u*%EIXVfN#36O6W6`0$f+h4H%9BG( zi4ccK^h4Rj#resh2-QWegaTT5V8l&(dsiH(3FJB~(V6fLX$M2)%_gr$rD8Ir;4q;^ zAXQ>My*YCo&M7DC+g3WFE6$PclC@k(RXI-7Agz2P(uk7#S&7Ip^IdHbzvs$Db-nf8 ze?VIb%>%S^KtwycHc!iDdgzE+gwYnB5K+PDIOzmL>G*;Hp(&6;K$BD;v;78roi}MM zFLlT@I+?V>8|2jvO!*2&#g2CBH$+WpVBw2x$mcCX4>~72SBpa5O^p zpVr)MJrJHg;(gl7uugxAWVeN*mA>MV+3B6bY7*GpXwE?~^B`gHc4V#uP}TKmXzl{t zMYC&w6tS&hI>oATXJY9X}dcTh8Y~dmzASge$T34x)>

g@s;__HXnjAE+fSLTnKK<8G)09II;rV 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 YP!x^9cEuY$g#vu4ikk3Rd8@Gh0U+vGivR!s literal 0 HcmV?d00001 diff --git a/public/logos/bjornlunden.png b/public/logos/bjornlunden.png new file mode 100644 index 0000000000000000000000000000000000000000..5a1f2df1c4b6fe01f10820502394777069fb02d1 GIT binary patch literal 4041 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 XgT9tpOMlJ3%MF6-8tZ(!?-=nvD**lb literal 0 HcmV?d00001 diff --git a/public/logos/bokio.png b/public/logos/bokio.png new file mode 100644 index 0000000000000000000000000000000000000000..74fa663a430afcae58203129e87cb1dda33a41f7 GIT binary patch literal 1640 zcmV-u2ABDXP)?$_b`07mZGouQtudF@Bmcr08j1!QSJav?&9kG(%Sp>`v2?j z{j9$DZjkfx_WoIc@|3Lh$kh5~iu2gw{H3|~iKF$q%K3Pg^naZ7Eobnu!}y=H_W(BS zPI~e>Zt=p-`6XoVYK`+kbMZH9@VLkM7hLa~u=Zbt^Lv@}kEr&@QVA&l00odqL_t(| zob8=!bD~NRh8aZ$1QgJyiC3~lqfvMN|F1jol2kS)Jq$1`uT(!@t)X~on(kpPXju%y zFbu;m48t%C!!QiPFbu;m48t%C!!Qg}3q4nhmg5ATU-!)xp*Qbn@f2;Qvyo-@yt?l= zFRhNqLo#1j?tMwN+PMtRw3nVE>%MP&3K+hVuH3tEEM_RgX64j<>zWibeD6m;to!D* zC~hd?grlc_h>3p(6|$6}h}XTk1u`jTD8$08+aQawhT@3+FqSqH18~LihGL4X zXuwd6TXkEgFkvW$LERb(j2MbNa9uQGm;et_%@~Rlw=5bm6eHL;HD#EmL#3wPgq2)Z2hD*c;F~6Y| zkcI=qVNC%;C#9M|rkVnV{)lSQL9F=&3@wjpk~GE8rE20sL!#7j`$UsC1S}V9_!G^K z(q2q7X-uX$P)+&`G4vm)CQ(D8G#F4#=7?+F1q^NS{pr|@ajCbUT42satl%7KiVy4nOhQ8-G3pzxF7(;G!(=wD^;MmrB`9=+9u0TG9*@iN7 z-0fl-WlVSr*d;`@q3<}m@kHHopFAU0L#3e%+|!%tx%&oYkg8T@x>L>0yV8cN6)M9t zkKQ|vpnDaJtsnL=^Nc~mNFokOEwgLDm?snr8j2KB(WIg19{{tBQ9}{$@P?*l4TTuN z4Qxys;wc(86eF-eHE$?(ScS0y!}tJIG%7F@eWNWGfWmv@9&q}&^={1K7gcC9m9BOd;bqP-=3+PflM!I7|uN+-h!2K zF=K);cFQ*WO-Gv_KB-z_3~VwDcdB=G>8cbl_U>mG##{AW%0LmQ5yT1T48wg^H~1?> z>pSQ}jX9TKNjEFYrP{cwYSN}WGr>=o;n9Yb`xABHA)HUV}F{BS* zq|muk-!L3g*Fe~iX~K}+`;9PqwD_YR|@a&`O479!8645KUUr}l+T|3;#BqAdlOz_?J^t}l#j3RZQ72=n<@Dl!E6 zj!Q*`nv1Ldf@%y02+P|yDlzP%?7Tcvi6KfUyikSV31qx>r2@klO0B z2=})$rVXcHJ!stU4_XhJHXH&~`VUMRCND6Hh-hMVY+uAGt37i4JQky2i$*WN*Hzqk6!DCBVSey literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ca885cdcd4de175e0d8fc8387e43fc1c70cab743 GIT binary patch literal 4406 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 = { + '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 }