import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' import { createConsent, getConsent, listConsents, generateOtc, getAuthUrl, exchangeAuthToken, submitProviderToken, acceptConsent, deleteConsent, resolveConsent, fetchCompanyInfoDirect, } from './lib/provider-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, calculateFileHash } 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, getBASReference } from '@/lib/bookkeeping/bas-reference' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { FortnoxClient } from '@/lib/providers/fortnox/client' import type { ProviderName } from '@/lib/providers/types' /** Fiscal years we support importing — older data is not needed */ const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026]) const fortnoxClient = new FortnoxClient() /** * Map known OAuth error codes from providers (Fortnox, Visma) to actionable * Swedish guidance. Falls back to the raw provider message so we never hide * unknown errors from the user. */ function translateOAuthError(error: string, description: string | null): string { const haystack = `${error} ${description ?? ''}`.toLowerCase() if (haystack.includes('missing license') || haystack.includes('not have enough licenses')) { return 'Du behöver aktivera tilläggstjänsten "Fortnox Integration" (~149 kr/mån) på ditt Fortnox-konto innan du kan ansluta. Aktivera den under Inställningar → Tilläggstjänster i Fortnox och försök igen.' } if (error === 'access_denied') { return 'Du avbröt anslutningen i leverantörens inloggning. Försök igen om du vill koppla kontot.' } if (error === 'invalid_scope') { return 'Tredjepartsappen har inte rätt behörigheter för ditt konto. Kontakta supporten.' } return description ? `${error}: ${description}` : error } /** * Provider Migration extension * * Migrates bookkeeping data from external Swedish accounting systems * (Fortnox, Visma, Bokio, Björn Lundén, Briox) into gnubok by talking * directly to each provider's API. * * Bookkeeping data (accounts, balances, vouchers) is imported via SIE * files fetched from providers. Entity data (customers, suppliers, * invoices) is imported via the provider REST APIs. */ export const arcimMigrationExtension: Extension = { id: 'arcim-migration', name: 'Systemmigration', version: '2.0.0', apiRoutes: [ // ── List available providers ─────────────────────────────────── { method: 'GET', path: '/providers', handler: async () => { return NextResponse.json({ providers: ARCIM_PROVIDERS }) }, }, // ── Check existing connections and import history ────────────── { method: 'GET', path: '/status', handler: async (request: Request, ctx?: ExtensionContext) => { 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 companyId = ctx?.companyId ?? user.id try { // Get accepted consents only (status 1) — not abandoned/created ones const allConsents = await listConsents(companyId) const consents = allConsents.filter(c => c.status === 1) // Get SIE import history const { data: sieImports } = await supabase .from('sie_imports') .select('id, filename, status, accounts_count, transactions_count, company_name, fiscal_year_start, fiscal_year_end, imported_at, created_at') .eq('company_id', companyId) .order('created_at', { ascending: false }) .limit(10) // Get entity counts (to show what's already been imported) const [ { count: customerCount }, { count: supplierCount }, { count: invoiceCount }, ] = await Promise.all([ supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), supabase.from('suppliers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId), ]) return NextResponse.json({ consents: consents.map(c => ({ id: c.id, provider: c.provider, status: c.status, companyName: c.companyName, createdAt: c.createdAt, })), sieImports: sieImports ?? [], entityCounts: { customers: customerCount ?? 0, suppliers: supplierCount ?? 0, invoices: invoiceCount ?? 0, }, }) } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to fetch status' }, { status: 500 } ) } }, }, // ── 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 companyId = ctx?.companyId ?? user.id 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 { const { createServiceClient: createSvc } = await import('@/lib/supabase/server') // Reuse existing accepted consent if one exists for this provider const existingConsents = await listConsents(companyId) const accepted = existingConsents.find(c => c.provider === provider && c.status === 1) if (accepted) { // Already connected — skip OAuth, go straight to preview if (ctx?.settings) { await ctx.settings.set('consent_id', accepted.id) await ctx.settings.set('provider', provider) } return NextResponse.json({ consentId: accepted.id, authType: providerInfo.authType, alreadyConnected: true, }) } // Check for status 0 consents that already have tokens stored (credentials submitted but migration not completed) const pending = existingConsents.filter(c => c.provider === provider && c.status === 0) if (pending.length > 0) { const svc = createSvc() for (const p of pending) { const { data: tokens } = await svc .from('provider_consent_tokens') .select('id') .eq('consent_id', p.id) .limit(1) if (tokens && tokens.length > 0) { // Tokens exist — reuse this consent, skip credential entry if (ctx?.settings) { await ctx.settings.set('consent_id', p.id) await ctx.settings.set('provider', provider) } return NextResponse.json({ consentId: p.id, authType: providerInfo.authType, alreadyConnected: true, }) } } // No tokens found — clean up abandoned consents for (const p of pending) { await deleteConsent(p.id) } } // Create new consent const consent = await createConsent( companyId, provider, `gnubok-migration-${user.id}`, orgNumber, companyName ) 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) // Build the OAuth callback URL const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' const callbackUrl = `${appUrl}/api/extensions/ext/arcim-migration/callback` // Encode consentId + provider in state const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider }) const stateEncoded = Buffer.from(statePayload).toString('base64url') const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl) 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 if (provider !== 'bjornlunden' && !apiToken) { return NextResponse.json( { error: 'apiToken is required for this provider' }, { status: 400 } ) } 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', skipAuth: true, handler: async (request: Request, ctx?: ExtensionContext) => { const log = ctx?.log ?? console const url = new URL(request.url) const code = url.searchParams.get('code') const stateRaw = url.searchParams.get('state') const oauthError = url.searchParams.get('error') const oauthErrorDescription = url.searchParams.get('error_description') const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' // JSON-encode for safe embedding inside
Anslutningen misslyckades: ${escapedReason}