import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' import { createConsent, getConsent, listConsents, generateOtc, consumeOAuthState, getAuthUrl, exchangeAuthToken, submitProviderToken, acceptConsent, deleteConsent, resolveConsent, fetchCompanyInfoDirect, ProviderTokenInvalidError, ProviderCompanyMismatchError, ConsentNotFoundError, } from './lib/provider-client' import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher' import { mapCompanyInfo } from './lib/entity-mapper' import { executeMigration } from './lib/migration-orchestrator' import { importProviderDocuments } from './lib/import-documents' import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers' 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 } from '@/lib/import/sie-import' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' import type { ProviderName } from '@/lib/providers/types' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { classifyProviderError } from '@/lib/providers/with-provider-call' import { createLogger } from '@/lib/logger' const moduleLog = createLogger('extensions/arcim-migration') /** * The one answer the unauthenticated OAuth callback gives for every state * failure: forged, unknown, expired, replayed, or pointing at a consent that no * longer exists. Distinguishing them would turn the callback into a probe for * other tenants' consent ids. */ const STATE_REJECTED_MESSAGE = 'Ingen giltig migrationssession hittades. Starta om anslutningen.' /** * 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 } /** * Resolve the OAuth callback URL for a provider. Single source of truth for * BOTH legs of the flow: the redirect_uri sent in the authorization request * and the redirect_uri sent in the token exchange must be byte-identical, or * the provider rejects the code exchange (RFC 6749 §4.1.3). The two legs used * to resolve this independently: authorize honored the FORTNOX_REDIRECT_URI / * VISMA_REDIRECT_URI override while the exchange hardcoded the * NEXT_PUBLIC_APP_URL fallback, so any override differing from the fallback * (dev ngrok URI, or an env var left on the old app domain after a domain * cutover) silently broke every OAuth connect at the exchange step. * * The override exists so dev environments can route through a single * registered URI instead of registering every ngrok URL on the OAuth client. */ function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string { const providerRedirectEnv = provider === 'visma' ? process.env.VISMA_REDIRECT_URI : provider === 'fortnox' ? process.env.FORTNOX_REDIRECT_URI : undefined if (providerRedirectEnv && providerRedirectEnv.trim().length > 0) { return providerRedirectEnv } const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' return `${appUrl}/api/extensions/ext/arcim-migration/callback` } /** * Build a provider OAuth authorization URL bound to an EXISTING consent id. * Used by both first-time connect and reconnect (token revival): the callback * runs exchangeAuthToken(consentId, …) which upserts the fresh tokens keyed by * consent_id, so re-running OAuth against the same consent overwrites a dead * refresh-token pair in place: no disconnect/recreate needed. */ async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): Promise { // Server-side state row: consent id, provider (via the consent), expiry and a // consumed marker all live in provider_otc. The `state` handed to the provider // is that row's opaque random primary key, nothing more. const otc = await generateOtc(consentId) const callbackUrl = resolveArcimCallbackUrl(provider) // The state is the one-time code itself: an unguessable pointer to the row // above. It deliberately encodes NOTHING. The previous base64url JSON payload // was attacker-authored input the callback trusted, so anyone who learned a // consent id could redirect their own provider tokens onto that consent. const { url } = await getAuthUrl(provider, otc.code, callbackUrl) return url } /** * Provider Migration extension * * Migrates bookkeeping data from external Swedish accounting systems * (Fortnox, Visma, Bokio, Björn Lundén, Briox) into Accounted 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) { moduleLog.error('arcim status failed', error as Error, { companyId }) return errorResponseFromCode('PROVIDER_STATUS_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) } }, }, // ── 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, reconnect } = await request.json() as { provider: ArcimProvider companyName?: string orgNumber?: string reconnect?: boolean } if (!provider) { return errorResponseFromCode('VALIDATION_ERROR', moduleLog, { details: { field: 'provider', reason: 'required' }, }) } const providerInfo = ARCIM_PROVIDERS.find(p => p.id === provider) if (!providerInfo) { return errorResponseFromCode('PROVIDER_INVALID', moduleLog, { details: { provider }, }) } try { const { createServiceClient: createSvc } = await import('@/lib/supabase/server') const existingConsents = await listConsents(companyId) // Reconnect: an existing connection's stored tokens are dead (refresh // failed → PROVIDER_AUTH_EXPIRED). Re-run auth against the SAME consent // so fresh tokens overwrite the dead pair in place: no disconnect, no // duplicate consent, import history preserved. Bypasses the // alreadyConnected short-circuit below (which would otherwise skip the // auth that's the whole point here). if (reconnect) { const stale = existingConsents.find( c => c.provider === provider && (c.status === 0 || c.status === 1), ) if (stale) { if (ctx?.settings) { await ctx.settings.set('consent_id', stale.id) await ctx.settings.set('provider', provider) } if (providerInfo.authType === 'oauth') { const authUrl = await buildArcimOAuthUrl(stale.id, provider) return NextResponse.json({ consentId: stale.id, authType: 'oauth', authUrl, reconnect: true, }) } // Token-based providers re-authorize by re-entering credentials return NextResponse.json({ consentId: stale.id, authType: 'token', reconnect: true, }) } // No existing consent to revive: fall through to a normal connect. } // Reuse existing accepted consent if one exists for this provider 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') // consent_id is the PK: there is no `id` column. Selecting `id` // errors silently (only `data` is read), so `tokens` was always // null and the reuse branch below never fired, deleting valid // status-0 consents as "abandoned". .select('consent_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') { const authUrl = await buildArcimOAuthUrl(consent.id, provider) return NextResponse.json({ consentId: consent.id, authType: 'oauth', authUrl, }) } else { // Token-based providers: consent is ready for direct use return NextResponse.json({ consentId: consent.id, authType: 'token', }) } } catch (error) { log.error('arcim connect failed', error as Error, { provider }) return errorResponseFromCode('PROVIDER_CONNECT_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) } }, }, // ── 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 }) } // The caller's tenant: NOT the provider-side company id below. const ownerCompanyId = ctx?.companyId ?? user.id // `companyId` in the body is the PROVIDER-side company identifier // (BL User-Key / Briox account ID / Bokio company GUID). const { consentId, provider, apiToken, companyId: providerCompanyId } = await request.json() as { consentId: string provider: ArcimProvider apiToken: string companyId?: string } if (!consentId || !provider) { return errorResponseFromCode('VALIDATION_ERROR', moduleLog, { details: { fields: ['consentId', 'provider'], reason: 'required' }, }) } // BL uses server-side client credentials: only needs companyId if (provider !== 'bjornlunden' && !apiToken) { return errorResponseFromCode('PROVIDER_TOKEN_REQUIRED', moduleLog, { details: { provider }, }) } // Briox needs the account ID (the /token clientid param) alongside // the application token; Bokio/BL need their company GUID. if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox') && !providerCompanyId) { return errorResponseFromCode('PROVIDER_COMPANY_ID_REQUIRED', moduleLog, { details: { provider }, }) } try { await submitProviderToken( consentId, provider, apiToken || 'client_credentials', providerCompanyId, ownerCompanyId, ) return NextResponse.json({ success: true, consentId }) } catch (error) { log.error('arcim submit-token failed', error as Error, { provider }) // Consent missing or owned by another company: same 404 either way. if (error instanceof ConsentNotFoundError) { return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, { details: { consentId }, }) } // Wrong credentials (provider actively rejected them): tell the // user to re-check the pasted values instead of a generic 500. if (error instanceof ProviderTokenInvalidError) { return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, { details: { provider, reason: error.message }, }) } // Valid credentials, wrong company: name both org numbers so the user // can see at a glance which company the token actually opened. if (error instanceof ProviderCompanyMismatchError) { return errorResponseFromCode('PROVIDER_COMPANY_MISMATCH', moduleLog, { details: { provider, expectedOrgNumber: error.expectedOrgNumber, actualOrgNumber: error.actualOrgNumber, actualCompanyName: error.actualCompanyName, }, }) } return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) } }, }, // ── 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}

Du kan stänga detta fönster.

` return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' }, }) } // Provider returned an OAuth error (user cancelled, missing API // subscription on the Fortnox side, invalid scope, etc.) if (oauthError) { log.error('OAuth callback returned provider error', { error: oauthError, errorDescription: oauthErrorDescription, hasCode: !!code, hasState: !!stateRaw, }) return respondWithError(translateOAuthError(oauthError, oauthErrorDescription)) } if (!code || !stateRaw) { log.error('OAuth callback missing code or state', { hasCode: !!code, hasState: !!stateRaw, queryKeys: Array.from(url.searchParams.keys()), }) return respondWithError('Återanropet saknade code eller state. Försök igen.') } try { // Single source of truth for who this callback belongs to: the // server-written provider_otc row, consumed atomically here. The row // carries the consent; the consent carries the provider. Nothing is // read from the query string except the opaque state token itself, so // an attacker who knows a victim's consent id still cannot steer their // own provider tokens onto it. const resolvedState = await consumeOAuthState(stateRaw) if (!resolvedState) { // Unknown/forged state, expired state, replayed state and deleted // consent all end here with the SAME message: this route is // unauthenticated, so telling the caller which one it was would // disclose whether a given consent exists. log.error('OAuth callback state rejected', { hasCode: !!code, stateLength: stateRaw.length, }) return respondWithError(STATE_REJECTED_MESSAGE) } const { consentId, provider } = resolvedState // Must match the redirect_uri the authorization request was built // with, so both come from resolveArcimCallbackUrl. const redirectUri = resolveArcimCallbackUrl(provider) // Exchange OAuth code directly with the provider await exchangeAuthToken(consentId, provider, code, redirectUri) // Return an HTML page that notifies the opener tab and closes itself const successUrl = `${appUrl}/import?migration=connected&consentId=${encodeURIComponent(consentId)}` const html = `

Anslutningen lyckades. Du kan stänga denna flik.

` return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' }, }) } catch (error) { log.error('OAuth callback exchange failed', error) const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.' return respondWithError(reason) } }, }, // ── 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 companyId = ctx?.companyId ?? user.id const url = new URL(request.url) const consentId = url.searchParams.get('consentId') if (!consentId) { return errorResponseFromCode('VALIDATION_ERROR', moduleLog, { details: { field: 'consentId', reason: 'required' }, }) } try { // Company-scoped read: the status below is returned to the caller, so // an unscoped lookup would let any authenticated user probe another // tenant's consent id for existence and connection state. A foreign // consent throws ConsentNotFoundError, same as a nonexistent one. const consent = await getConsent(consentId, companyId) if (consent.status !== 0 && consent.status !== 1) { return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, { details: { consentId, status: consent.status }, }) } // Resolve consent to get access token const resolved = await resolveConsent(companyId, consentId) const provider = resolved.consent.provider as ProviderName // Fetch company info directly from provider let mapped = null try { const companyInfo = await fetchCompanyInfoDirect(provider, resolved.accessToken, resolved.providerCompanyId) mapped = companyInfo ? mapCompanyInfo(companyInfo) : null } catch (err) { log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err)) } // Try to fetch SIE data (Fortnox and Briox serve SIE over the API) let sieAvailable = false let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null if (providerSupportsSie(provider)) { try { log.info(`Fetching SIE export from ${provider} for consent ${consentId}...`) // Fetch SIE type 4 for the most recent allowed year to get stats const { files, availableYears } = await fetchProviderSieFiles( provider, resolved.accessToken, resolved.providerCompanyId, { latestOnly: true }, ) if (files.length > 0) { const parsed = parseSIEFile(files[files.length - 1].rawContent) sieAvailable = true sieStats = { accountCount: parsed.accounts.length, transactionCount: parsed.vouchers.length, fiscalYears: availableYears, } } } catch (err) { log.info('SIE export failed:', err instanceof Error ? err.message : String(err)) } } // Check if the company already has completed SIE imports (from manual upload) const { count: sieImportCount } = await supabase .from('sie_imports') .select('*', { count: 'exact', head: true }) .eq('company_id', companyId) .eq('status', 'completed') return NextResponse.json({ consent: { id: consent.id, provider: consent.provider, status: consent.status, companyName: consent.companyName, }, companyInfo: mapped, sieAvailable, sieStats, hasSieData: (sieImportCount ?? 0) > 0, }) } catch (error) { log.error('arcim preview failed', error as Error) // Consent missing or owned by another company: same 404 either way. if (error instanceof ConsentNotFoundError) { return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, { details: { consentId }, }) } // Classify HTTP failures into typed codes so the toast can suggest // reconnect / retry instead of a generic "preview failed". const classified = classifyProviderError(error) return errorResponseFromCode(classified ?? 'PROVIDER_PREVIEW_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown', classified: classified ?? 'unclassified', }, }) } }, }, // ── 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 companyId = ctx?.companyId ?? user.id const url = new URL(request.url) const consentId = url.searchParams.get('consentId') if (!consentId) { return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) } try { // Resolve consent const resolved = await resolveConsent(companyId, consentId) const provider = resolved.consent.provider as ProviderName if (!providerSupportsSie(provider)) { return errorResponseFromCode('PROVIDER_SIE_NOT_SUPPORTED', moduleLog, { details: { provider }, }) } // Fetch SIE type 4 for each allowed fiscal year const { files: sieFiles, failedYears } = await fetchProviderSieFiles( provider, resolved.accessToken, resolved.providerCompanyId, ) if (sieFiles.length === 0) { // The allowed window is rolling (current year and the two before // it): interpolate the actual range instead of the static // registry message so the text never goes stale. const allowedYears = [...getAllowedFiscalYears()].sort((a, b) => a - b) const range = `${allowedYears[0]}-${allowedYears[allowedYears.length - 1]}` return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog, { messageSv: `Inga räkenskapsår ${range} hittades hos leverantören.`, messageEn: `No fiscal years available for ${range}.`, ...(failedYears.length > 0 ? { details: { failedYears } } : {}), }) } // Parse most recent file for preview/validation const sieFile = sieFiles[sieFiles.length - 1] const parsed = parseSIEFile(sieFile.rawContent) const validation = validateSIEFile(parsed) if (!validation.valid) { log.warn( `arcim sie-data validation failed for ${provider} fiscal year ${sieFile.fiscalYear}: ` + `${validation.errors.length} error(s): ${validation.errors.slice(0, 3).join(' | ')}`, ) return NextResponse.json({ error: 'validation', message: 'SIE file validation failed', validation, }, { status: 400 }) } // Collect ALL unique accounts across ALL fiscal year files const allAccountsMap = new Map() for (const file of sieFiles) { 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 }) } } } 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, companyId) 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 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 ${sieFiles.length} files, ${mappingStats.unmapped} unmapped`) const preview = generateImportPreview(parsed, mappings) // Detect prior imports by *fiscal period overlap*, not file hash. // Providers embed the export-time #GEN date in every SIE export so // the hash always changes between syncs; only the period stays // stable. A re-sync replaces the prior import for the same period. const fileStatuses: { fiscalYear: number rawContent: string previousImport: { importedAt: string | null fiscalYearStart: string | null fiscalYearEnd: string | null } | null }[] = [] for (const file of sieFiles) { const fileParsed = parseSIEFile(file.rawContent) const fyStart = fileParsed.stats.fiscalYearStart const fyEnd = fileParsed.stats.fiscalYearEnd let priorImport: { imported_at: string | null fiscal_year_start: string | null fiscal_year_end: string | null } | null = null if (fyStart && fyEnd) { const { data } = await supabase .from('sie_imports') .select('imported_at, fiscal_year_start, fiscal_year_end') .eq('company_id', companyId) .eq('status', 'completed') .lte('fiscal_year_start', fyEnd) .gte('fiscal_year_end', fyStart) .limit(1) .maybeSingle() priorImport = data } fileStatuses.push({ fiscalYear: file.fiscalYear, rawContent: file.rawContent, previousImport: priorImport ? { importedAt: priorImport.imported_at, fiscalYearStart: priorImport.fiscal_year_start, fiscalYearEnd: priorImport.fiscal_year_end, } : null, }) } const replacedFileCount = fileStatuses.filter(f => f.previousImport).length return NextResponse.json({ parsed, mappings, mappingStats, preview, validation, rawContent: fileStatuses.map(f => f.rawContent), fileStatuses: fileStatuses.map(f => ({ fiscalYear: f.fiscalYear, previousImport: f.previousImport, // Back-compat for older wizard builds: an `alreadyImported` // boolean. The new wizard reads `previousImport` directly. alreadyImported: !!f.previousImport, importedAt: f.previousImport?.importedAt ?? null, })), allImported: false, newFileCount: fileStatuses.length - replacedFileCount, replacedFileCount, // Allowed years whose provider export failed: the wizard warns // the user before proceeding so an IB/UB gap cannot slip through. failedYears, basAccounts: BAS_REFERENCE, }) } catch (error) { log.error('arcim sie-data fetch failed', error as Error) const classified = classifyProviderError(error) return errorResponseFromCode(classified ?? 'PROVIDER_SIE_FETCH_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown', classified: classified ?? 'unclassified', }, }) } }, }, // ── 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 companyId = ctx?.companyId ?? user.id 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 updateAccountNames?: boolean } } if (!rawContent || !mappings) { return NextResponse.json({ error: 'rawContent and mappings are required' }, { status: 400 }) } try { const parsed = parseSIEFile(rawContent) // Validate all accounts are mapped (same as manual upload) const unmapped = mappings.filter((m: import('@/lib/import/types').AccountMapping) => !m.targetAccount) if (unmapped.length > 0) { return NextResponse.json({ error: 'validation', message: `${unmapped.length} account(s) are not mapped`, unmappedAccounts: unmapped.map((m: import('@/lib/import/types').AccountMapping) => ({ account: m.sourceAccount, name: m.sourceName, })), }, { status: 400 }) } // Account creation (and #KONTO renames) happen inside // executeSIEImport via syncMappedAccounts: the auto-activate block // that used to live here was a duplicate of that logic. Mapping // persistence ALSO happens inside executeSIEImport, with the // correct companyId + userId and non-fatal warning handling: the // direct saveMappings call that used to sit here passed user.id in // the companyId slot, which throws on RLS/FK now that the helper // surfaces upsert failures, 500ing every provider-migration import // before a single voucher was written. Do not re-add it. const result = await executeSIEImport(supabase, companyId, user.id, parsed, mappings, { filename: `migration-sie-${Date.now()}.se`, fileContent: rawContent, createFiscalPeriod: options.createFiscalPeriod, importOpeningBalances: options.importOpeningBalances, importTransactions: options.importTransactions, voucherSeries: options.voucherSeries, // Default ON: re-syncs keep account names current with the source // system (idempotent: equal names are a no-op in the rename pass). updateAccountNames: options.updateAccountNames ?? true, // Provider re-sync semantics: a prior completed import for the // same fiscal year is automatically replaced (its imported // entries are cancelled) so the user can pull updated data // without manual cleanup. Manual SIE upload keeps default // 'block' behavior. onExistingPeriod: 'replace', }) 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('arcim sie import failed', error as Error) const classified = classifyProviderError(error) return errorResponseFromCode(classified ?? 'SIE_IMPORT_UNEXPECTED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown', classified: classified ?? 'unclassified', }, }) } }, }, // ── 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 companyId = ctx?.companyId ?? user.id const { consentId, importCompanyInfo = true, importCustomers = true, importSuppliers = true, importSalesInvoices = true, importSupplierInvoices = true, reconcileVouchers = true, } = await request.json() as { consentId: string importCompanyInfo?: boolean importCustomers?: boolean importSuppliers?: boolean importSalesInvoices?: boolean importSupplierInvoices?: boolean reconcileVouchers?: boolean } if (!consentId) { return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) } try { // Company-scoped read: the status below is returned to the caller, so // an unscoped lookup would let any authenticated user probe another // tenant's consent id for existence and connection state. A foreign // consent throws ConsentNotFoundError, same as a nonexistent one. const consent = await getConsent(consentId, companyId) if (consent.status !== 0 && consent.status !== 1) { return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, { details: { consentId, status: consent.status }, }) } // ── Guard: a completed SIE import is required before entity import ── // Most providers expose ONLY entity data (customers, suppliers, // invoices) via API: never the general ledger. Fortnox pulls the GL // itself via SIE-over-API and is exempt. Briox and Björn Lundén also // serve SIE over the API, but the wizard runs /import-sie before // /migrate, so this guard stays satisfied, and keeps protecting // against a skipped SIE step. Importing entities without the // SIE-derived ledger (kontoplan, // ingående balanser, verifikationer) would leave an incomplete // bokföring under BFL: a subledger with no chart of accounts and no // opening balances, so every subsequent posting and balance is wrong. // The wizard surfaces this as an advisory banner, but it must be // enforced here so the rule cannot be bypassed by a direct API call, // a skipped wizard step, or a stale client. if (consent.provider !== 'fortnox') { const { count: completedSieImports } = await supabase .from('sie_imports') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .eq('status', 'completed') if (!completedSieImports || completedSieImports < 1) { return errorResponseFromCode('PROVIDER_SIE_IMPORT_REQUIRED', moduleLog, { details: { provider: consent.provider }, }) } } log.info(`Starting migration for user ${user.id} from ${consent.provider}`) const results = await executeMigration({ consentId, companyId, userId: user.id, supabase, importCompanyInfo, importCustomers, importSuppliers, importSalesInvoices, importSupplierInvoices, reconcileVouchers, }) log.info('Migration completed:', results) // Mark consent as fully accepted now that data has been imported await acceptConsent(consentId) return NextResponse.json({ success: true, results }) } catch (error) { log.error('arcim migration failed', error as Error) // Consent missing or owned by another company: same 404 either way. if (error instanceof ConsentNotFoundError) { return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, { details: { consentId }, }) } const classified = classifyProviderError(error) return errorResponseFromCode(classified ?? 'PROVIDER_MIGRATE_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown', classified: classified ?? 'unclassified', }, }) } }, }, // ── Reconcile supplier invoices to GL payment vouchers ──────── // Re-runnable maintenance endpoint. The migration runs this automatically as // its final step, but SIE (the GL) and entity import are two separate HTTP // requests whose order is UI-driven: so if the GL lands after the entity // import, or a company was migrated before this feature existed, call this to // auto-link settled supplier invoices to their existing vouchers. Pass // { dryRun: true } to preview the plan (incl. items needing manual review) // without writing. { method: 'POST', path: '/reconcile', 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 let dryRun = false try { const body = (await request.json()) as { dryRun?: boolean } dryRun = body?.dryRun === true } catch { // empty body is fine: default to a real run } try { const result = await reconcileSupplierInvoiceVouchers({ supabase, companyId, userId: user.id, dryRun, }) log.info('arcim reconcile completed', { companyId, dryRun, autoLinked: result.autoLinked, ambiguous: result.ambiguous, unmatched: result.unmatched, }) return NextResponse.json({ success: true, dryRun, result }) } catch (error) { log.error('arcim reconcile failed', error as Error) return errorResponseFromCode('PROVIDER_MIGRATE_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) } }, }, // ── Import provider underlag (receipts) and link to verifikat ── // Best-effort, re-runnable. Kept off the migration's critical path: the // Bokio document API is rate-limited (200 req/60s) and a full receipt // sweep issues hundreds of download calls, which would blow the 300s // migration window. Pages /uploads, resolves each receipt's verifikat via // the SIE-preserved Bokio voucher number, and archives it idempotently // (skips content already stored for the company). Pass { dryRun: true } to // preview the match plan without downloading or writing. { method: 'POST', path: '/import-documents', 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 let consentId: string | undefined let dryRun = false try { const body = (await request.json()) as { consentId?: string; dryRun?: boolean } consentId = body?.consentId dryRun = body?.dryRun === true } catch { // empty/invalid body: consentId check below rejects it } if (!consentId) { return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) } try { const result = await importProviderDocuments({ supabase, companyId, userId: user.id, consentId, dryRun, }) log.info('arcim import-documents completed', { companyId, dryRun, scanned: result.scanned, linked: result.linked, skipped: result.skipped, unmatched: result.unmatched, failed: result.failed, }) return NextResponse.json({ success: true, dryRun, result }) } catch (error) { log.error('arcim import-documents failed', error as Error) return errorResponseFromCode('PROVIDER_IMPORT_DOCUMENTS_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) } }, }, // ── Accept consent (mark as fully connected after import) ───── { method: 'POST', path: '/accept', 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 const { consentId } = await request.json() as { consentId: string } if (!consentId) { return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) } // Verify consent belongs to this company before mutating const { data: consent } = await supabase .from('provider_consents') .select('id') .eq('id', consentId) .eq('company_id', companyId) .single() if (!consent) { return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog) } try { await acceptConsent(consentId) return NextResponse.json({ success: true }) } catch (error) { moduleLog.error('arcim accept failed', error as Error, { consentId }) return errorResponseFromCode('PROVIDER_ACCEPT_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) } }, }, // ── 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 companyId = ctx?.companyId ?? user.id const { consentId } = await request.json() as { consentId: string } if (!consentId) { return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) } // Verify consent belongs to this company before mutating const { data: consent } = await supabase .from('provider_consents') .select('id') .eq('id', consentId) .eq('company_id', companyId) .single() if (!consent) { return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog) } try { await deleteConsent(consentId) if (ctx?.settings) { await ctx.settings.clear('consent_id') await ctx.settings.clear('provider') } return NextResponse.json({ success: true }) } catch (error) { log.error('arcim disconnect failed', error as Error, { consentId }) return errorResponseFromCode('PROVIDER_DISCONNECT_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) } }, }, ], eventHandlers: [], }