feat: arcim migration wizard UX, import fixes, Sentry setup (#22)
* 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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
82ad630448
commit
2ad8731dc9
@@ -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<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: [],
|
||||
}
|
||||
@@ -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<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
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<ConsentRecord> {
|
||||
return request<ConsentRecord>('/api/v1/consents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, provider, orgNumber, companyName }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getConsent(consentId: string): Promise<ConsentRecord> {
|
||||
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
|
||||
}
|
||||
|
||||
export async function generateOtc(
|
||||
consentId: string,
|
||||
expiresInMinutes: number = 60
|
||||
): Promise<OtcResponse> {
|
||||
return request<OtcResponse>(`/api/v1/consents/${consentId}/otc`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expiresInMinutes }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteConsent(consentId: string): Promise<void> {
|
||||
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<T>(
|
||||
consentId: string,
|
||||
resource: string,
|
||||
params?: Record<string, string>,
|
||||
pageSize: number = 100
|
||||
): Promise<T[]> {
|
||||
const all: T[] = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
...params,
|
||||
})
|
||||
const result = await request<PaginatedResponse<T>>(
|
||||
`/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<CompanyInformationDto | null> {
|
||||
// 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<CustomerDto[]> {
|
||||
return fetchAllPages<CustomerDto>(consentId, 'customers')
|
||||
}
|
||||
|
||||
export async function fetchSuppliers(consentId: string): Promise<SupplierDto[]> {
|
||||
return fetchAllPages<SupplierDto>(consentId, 'suppliers')
|
||||
}
|
||||
|
||||
export async function fetchSalesInvoices(
|
||||
consentId: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<SalesInvoiceDto[]> {
|
||||
return fetchAllPages<SalesInvoiceDto>(consentId, 'salesinvoices', params)
|
||||
}
|
||||
|
||||
export async function fetchSupplierInvoices(
|
||||
consentId: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<SupplierInvoiceDto[]> {
|
||||
return fetchAllPages<SupplierInvoiceDto>(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}` : ''}`
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>; items: Record<string, unknown>[] } {
|
||||
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<string, string> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>; items: Record<string, unknown>[] } {
|
||||
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<string, string> = {
|
||||
draft: 'registered',
|
||||
sent: 'registered',
|
||||
booked: 'registered',
|
||||
paid: 'paid',
|
||||
overdue: 'overdue',
|
||||
cancelled: 'credited',
|
||||
credited: 'credited',
|
||||
}
|
||||
|
||||
const isCreditNote = dto.invoiceTypeCode === '381'
|
||||
|
||||
const invoice: Record<string, unknown> = {
|
||||
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<string, unknown> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<MigrationResults> {
|
||||
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<string, unknown> = {}
|
||||
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<string, string>()
|
||||
|
||||
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<string, string>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user