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:
Jakob Wennberg
2026-03-13 16:24:54 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 82ad630448
commit 2ad8731dc9
44 changed files with 5108 additions and 122 deletions
+572
View File
@@ -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."
}
}
+245
View File
@@ -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
}