* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
573 lines
20 KiB
TypeScript
573 lines
20 KiB
TypeScript
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<string>('consent_id')
|
|
: null
|
|
const provider = ctx?.settings
|
|
? await ctx.settings.get<ArcimProvider>('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<string, { number: string; name: string }>()
|
|
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: [],
|
|
}
|