diff --git a/components/ui/empty-state.tsx b/components/ui/empty-state.tsx
index a64cd6ec..c9c7402a 100644
--- a/components/ui/empty-state.tsx
+++ b/components/ui/empty-state.tsx
@@ -105,8 +105,6 @@ export function EmptyInvoices() {
description="Skapa din första faktura på under 60 sekunder. Vi fyller i dina uppgifter automatiskt."
actionLabel="Skapa faktura"
actionHref="/invoices/new"
- secondaryActionLabel="Lägg till kund först"
- secondaryActionHref="/customers/new"
/>
)
}
diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts
index fa7b0411..0cb43c0a 100644
--- a/extensions/general/arcim-migration/index.ts
+++ b/extensions/general/arcim-migration/index.ts
@@ -3,45 +3,47 @@ import { NextResponse } from 'next/server'
import {
createConsent,
getConsent,
+ listConsents,
generateOtc,
getAuthUrl,
exchangeAuthToken,
submitProviderToken,
deleteConsent,
- fetchCompanyInfo,
- fetchSIEExport,
-} from './lib/arcim-client'
+ resolveConsent,
+ fetchCompanyInfoDirect,
+} from './lib/provider-client'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
-import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
+import { parseSIEFile, validateSIEFile, calculateFileHash } from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
-import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
+import { BAS_REFERENCE, getBASReference } from '@/lib/bookkeeping/bas-reference'
+import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import { FortnoxClient } from '@/lib/providers/fortnox/client'
+import type { ProviderName } from '@/lib/providers/types'
/** Fiscal years we support importing — older data is not needed */
const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026])
+const fortnoxClient = new FortnoxClient()
+
/**
- * Arcim Migration extension
+ * Provider 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.
+ * (Fortnox, Visma, Bokio, Björn Lundén, Briox) into gnubok by talking
+ * directly to each provider's API.
*
* Bookkeeping data (accounts, balances, vouchers) is imported via SIE
- * files fetched from the gateway. Entity data (customers, suppliers,
- * invoices) is imported via the REST API.
- *
- * Required environment variables:
- * - ARCIM_SYNC_GATEWAY_URL
- * - ARCIM_SYNC_API_KEY
+ * files fetched from providers. Entity data (customers, suppliers,
+ * invoices) is imported via the provider REST APIs.
*/
export const arcimMigrationExtension: Extension = {
id: 'arcim-migration',
- name: 'Systemmigration (Arcim Sync)',
- version: '1.0.0',
+ name: 'Systemmigration',
+ version: '2.0.0',
apiRoutes: [
// ── List available providers ───────────────────────────────────
@@ -53,6 +55,68 @@ export const arcimMigrationExtension: Extension = {
},
},
+ // ── Check existing connections and import history ──────────────
+ {
+ method: 'GET',
+ path: '/status',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = ctx?.companyId ?? user.id
+
+ try {
+ // Get accepted consents only (status 1) — not abandoned/created ones
+ const allConsents = await listConsents(companyId)
+ const consents = allConsents.filter(c => c.status === 1)
+
+ // Get SIE import history
+ const { data: sieImports } = await supabase
+ .from('sie_imports')
+ .select('id, filename, status, accounts_count, transactions_count, company_name, fiscal_year_start, fiscal_year_end, imported_at, created_at')
+ .eq('company_id', companyId)
+ .order('created_at', { ascending: false })
+ .limit(10)
+
+ // Get entity counts (to show what's already been imported)
+ const [
+ { count: customerCount },
+ { count: supplierCount },
+ { count: invoiceCount },
+ ] = await Promise.all([
+ supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
+ supabase.from('suppliers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
+ supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
+ ])
+
+ return NextResponse.json({
+ consents: consents.map(c => ({
+ id: c.id,
+ provider: c.provider,
+ status: c.status,
+ companyName: c.companyName,
+ createdAt: c.createdAt,
+ })),
+ sieImports: sieImports ?? [],
+ entityCounts: {
+ customers: customerCount ?? 0,
+ suppliers: supplierCount ?? 0,
+ invoices: invoiceCount ?? 0,
+ },
+ })
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Failed to fetch status' },
+ { status: 500 }
+ )
+ }
+ },
+ },
+
// ── Start consent flow (create consent + OTC) ─────────────────
{
method: 'POST',
@@ -66,6 +130,8 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ const companyId = ctx?.companyId ?? user.id
+
const { provider, companyName, orgNumber } = await request.json() as {
provider: ArcimProvider
companyName?: string
@@ -82,15 +148,39 @@ export const arcimMigrationExtension: Extension = {
}
try {
- // Create consent in Arcim Sync
+ // Reuse existing accepted consent if one exists for this provider
+ const existingConsents = await listConsents(companyId)
+ const existing = existingConsents.find(c => c.provider === provider && c.status === 1)
+
+ if (existing) {
+ // Already connected — skip OAuth, go straight to preview
+ if (ctx?.settings) {
+ await ctx.settings.set('consent_id', existing.id)
+ await ctx.settings.set('provider', provider)
+ }
+
+ return NextResponse.json({
+ consentId: existing.id,
+ authType: providerInfo.authType,
+ alreadyConnected: true,
+ })
+ }
+
+ // Clean up abandoned consents (status 0 = Created but never completed OAuth)
+ const abandoned = existingConsents.filter(c => c.provider === provider && c.status === 0)
+ for (const a of abandoned) {
+ await deleteConsent(a.id)
+ }
+
+ // Create new consent
const consent = await createConsent(
+ companyId,
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)
@@ -100,15 +190,14 @@ export const arcimMigrationExtension: Extension = {
// Generate OTC for OAuth flow
const otc = await generateOtc(consent.id)
- // Build the OAuth callback URL using the current app URL (localhost in dev, production in prod)
+ // Build the OAuth callback URL
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const callbackUrl = `${appUrl}/api/extensions/ext/arcim-migration/callback`
- // Encode consentId + provider in state so the callback doesn't depend on session storage
+ // Encode consentId + provider in state
const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider })
const stateEncoded = Buffer.from(statePayload).toString('base64url')
- // Pass callbackUrl as redirectUri so Fortnox redirects here (works for localhost and production)
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
return NextResponse.json({
@@ -162,7 +251,6 @@ export const arcimMigrationExtension: Extension = {
}
// 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' },
@@ -170,7 +258,6 @@ export const arcimMigrationExtension: Extension = {
)
}
- // Bokio and BL require companyId
if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) {
return NextResponse.json(
{ error: 'companyId is required for this provider' },
@@ -192,10 +279,6 @@ export const arcimMigrationExtension: Extension = {
},
// ── OAuth callback ────────────────────────────────────────────
- // This handler is called by the OAuth provider redirect. It does NOT
- // require user auth — the request comes from the provider, not the user's
- // browser session. Authentication is validated via the OTC code + consent.
- // The 'skipAuth' flag is checked by the extension dispatch route.
{
method: 'GET',
path: '/callback',
@@ -211,24 +294,19 @@ export const arcimMigrationExtension: Extension = {
}
try {
- // Decode state — supports both new format (base64url JSON with consentId/provider)
- // and legacy format (plain OTC code string)
let consentId: string | null = null
let provider: ArcimProvider | null = null
- let otcCode: string = stateRaw
try {
const decoded = JSON.parse(Buffer.from(stateRaw, 'base64url').toString())
- if (decoded.consentId && decoded.provider && decoded.otc) {
+ if (decoded.consentId && decoded.provider) {
consentId = decoded.consentId
provider = decoded.provider as ArcimProvider
- otcCode = decoded.otc
}
} catch {
- // Legacy: state is just the OTC code — fall back to ctx.settings
+ // Legacy fallback
}
- // Fall back to session-based settings if state didn't contain the data
if (!consentId || !provider) {
consentId = ctx?.settings
? await ctx.settings.get
('consent_id')
@@ -242,18 +320,43 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'No active migration session' }, { status: 400 })
}
- // The redirectUri used for the token exchange must match the one used in the auth URL
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback`
- await exchangeAuthToken(consentId, provider, otcCode, code, redirectUri)
+ // Exchange OAuth code directly with the provider
+ await exchangeAuthToken(consentId, provider, code, redirectUri)
- // Redirect to import page with success
- return NextResponse.redirect(`${appUrl}/import?migration=connected&consentId=${consentId}`)
+ // Return an HTML page that notifies the opener tab and closes itself
+ const html = `Anslutningen lyckades. Du kan stänga denna flik.
`
+
+ return new Response(html, {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
} catch (error) {
log.error('OAuth callback error:', error)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
- return NextResponse.redirect(`${appUrl}/import?migration=error`)
+
+ const html = `Något gick fel. Du kan stänga denna flik.
`
+
+ return new Response(html, {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
}
},
},
@@ -271,6 +374,8 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ const companyId = ctx?.companyId ?? user.id
+
const url = new URL(request.url)
const consentId = url.searchParams.get('consentId')
@@ -279,7 +384,6 @@ export const arcimMigrationExtension: Extension = {
}
try {
- // Verify consent is accepted
const consent = await getConsent(consentId)
if (consent.status !== 1) {
return NextResponse.json(
@@ -288,45 +392,64 @@ export const arcimMigrationExtension: Extension = {
)
}
- // Fetch company info for preview (non-blocking — continue if it fails)
+ // Resolve consent to get access token
+ const resolved = await resolveConsent(companyId, consentId)
+ const provider = resolved.consent.provider as ProviderName
+
+ // Fetch company info directly from provider
let mapped = null
try {
- const companyInfo = await fetchCompanyInfo(consentId)
+ const companyInfo = await fetchCompanyInfoDirect(provider, resolved.accessToken, resolved.providerCompanyId)
mapped = companyInfo ? mapCompanyInfo(companyInfo) : null
} catch (err) {
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
}
- // Try to fetch SIE stats (non-blocking — continue if it fails)
+ // Try to fetch SIE data (Fortnox has native SIE export)
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 (provider === 'fortnox') {
+ try {
+ log.info(`Fetching SIE export from Fortnox for consent ${consentId}...`)
+ // Fortnox SIE export endpoint: /3/sie/{type}?financialyear={id}
+ // First get financial years
+ const fyResponse = await fortnoxClient.get>(
+ resolved.accessToken,
+ '/financialyears'
+ )
+ const years = (fyResponse['FinancialYears'] as Record[] | undefined) ?? []
+ const allowedYears = years
+ .map(fy => ({
+ id: fy['Id'] as number,
+ fromDate: fy['FromDate'] as string,
+ toDate: fy['ToDate'] as string,
+ }))
+ .filter(fy => {
+ const year = new Date(fy.fromDate).getFullYear()
+ return ALLOWED_FISCAL_YEARS.has(year)
+ })
- // Only keep fiscal years we need (2024–2026)
- const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
- if (filteredFiles.length < sieResult.files.length) {
- const skippedYears = sieResult.files
- .filter(f => !ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
- .map(f => f.fiscalYear)
- log.info(`Filtered out fiscal years: ${skippedYears.join(', ')} (only importing ${[...ALLOWED_FISCAL_YEARS].join(', ')})`)
+ if (allowedYears.length > 0) {
+ // Fetch SIE type 4 for the most recent allowed year to get stats
+ const latestYear = allowedYears[allowedYears.length - 1]
+ const sieContent = await fortnoxClient.getText(
+ resolved.accessToken,
+ `/sie/4?financialyear=${latestYear.id}`
+ )
+ if (sieContent) {
+ const parsed = parseSIEFile(sieContent)
+ sieAvailable = true
+ sieStats = {
+ accountCount: parsed.accounts.length,
+ transactionCount: parsed.vouchers.length,
+ fiscalYears: allowedYears.map(fy => new Date(fy.fromDate).getFullYear()),
+ }
+ }
+ }
+ } catch (err) {
+ log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
}
-
- if (filteredFiles.length > 0) {
- sieAvailable = true
- const totalAccounts = Math.max(...filteredFiles.map(f => f.accountCount))
- const totalTransactions = filteredFiles.reduce((sum, f) => sum + f.transactionCount, 0)
- const fiscalYears = filteredFiles.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('No SIE files within allowed fiscal years')
- }
- } catch (err) {
- log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
}
return NextResponse.json({
@@ -363,6 +486,8 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ const companyId = ctx?.companyId ?? user.id
+
const url = new URL(request.url)
const consentId = url.searchParams.get('consentId')
@@ -371,22 +496,77 @@ export const arcimMigrationExtension: Extension = {
}
try {
- // Fetch SIE from gateway and filter to allowed fiscal years (2024–2026)
- const sieResult = await fetchSIEExport(consentId, 4)
- const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
- if (filteredFiles.length === 0) {
+ // Resolve consent
+ const resolved = await resolveConsent(companyId, consentId)
+ const provider = resolved.consent.provider as ProviderName
+
+ if (provider !== 'fortnox') {
+ return NextResponse.json(
+ { error: `SIE export is currently only supported for Fortnox. Provider: ${provider}` },
+ { status: 400 }
+ )
+ }
+
+ // Fetch financial years from Fortnox
+ const fyResponse = await fortnoxClient.get>(
+ resolved.accessToken,
+ '/financialyears'
+ )
+ const years = (fyResponse['FinancialYears'] as Record[] | undefined) ?? []
+ const allowedYears = years
+ .map(fy => ({
+ id: fy['Id'] as number,
+ fromDate: fy['FromDate'] as string,
+ toDate: fy['ToDate'] as string,
+ }))
+ .filter(fy => {
+ const year = new Date(fy.fromDate).getFullYear()
+ return ALLOWED_FISCAL_YEARS.has(year)
+ })
+
+ if (allowedYears.length === 0) {
+ return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 })
+ }
+
+ // Fetch SIE type 4 for each allowed year
+ const sieFiles: { fiscalYear: number; rawContent: string }[] = []
+ for (const fy of allowedYears) {
+ try {
+ const sieContent = await fortnoxClient.getText(
+ resolved.accessToken,
+ `/sie/4?financialyear=${fy.id}`
+ )
+ if (sieContent) {
+ sieFiles.push({
+ fiscalYear: new Date(fy.fromDate).getFullYear(),
+ rawContent: sieContent,
+ })
+ }
+ } catch (err) {
+ log.info(`Failed to fetch SIE for year ${fy.id}:`, err instanceof Error ? err.message : String(err))
+ }
+ }
+
+ if (sieFiles.length === 0) {
return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 })
}
// Parse most recent file for preview/validation
- const sieFile = filteredFiles[filteredFiles.length - 1]
+ const sieFile = sieFiles[sieFiles.length - 1]
const parsed = parseSIEFile(sieFile.rawContent)
const validation = validateSIEFile(parsed)
+ if (!validation.valid) {
+ return NextResponse.json({
+ error: 'validation',
+ message: 'SIE file validation failed',
+ validation,
+ }, { status: 400 })
+ }
+
// Collect ALL unique accounts across ALL fiscal year files
- // so mappings cover every account that will be imported
const allAccountsMap = new Map()
- for (const file of filteredFiles) {
+ for (const file of sieFiles) {
const fileParsed = parseSIEFile(file.rawContent)
for (const acc of fileParsed.accounts) {
if (!allAccountsMap.has(acc.number)) {
@@ -394,14 +574,12 @@ export const arcimMigrationExtension: Extension = {
}
}
}
- // 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, ctx?.companyId ?? user.id)
+ const existingMappings = await loadMappings(supabase, companyId)
const existingRecords = [...existingMappings.values()].map(m => ({
id: '',
user_id: user.id,
@@ -414,7 +592,7 @@ export const arcimMigrationExtension: Extension = {
updated_at: '',
}))
- // Suggest mappings using accounts from ALL fiscal years
+ // Suggest mappings
const basAccounts = BAS_REFERENCE.map(b => ({
account_number: b.account_number,
account_name: b.account_name,
@@ -422,13 +600,32 @@ export const arcimMigrationExtension: Extension = {
const mappings = suggestMappings(allAccounts, basAccounts, existingRecords)
const mappingStats = getMappingStats(mappings)
- log.info(`Account mapping: ${allAccounts.length} unique accounts across ${filteredFiles.length} files, ${mappingStats.unmapped} unmapped`)
+ log.info(`Account mapping: ${allAccounts.length} unique accounts across ${sieFiles.length} files, ${mappingStats.unmapped} unmapped`)
- // Generate preview
const preview = generateImportPreview(parsed, mappings)
- // Collect all raw SIE content (filtered fiscal years only)
- const allRawContent = filteredFiles.map(f => f.rawContent)
+ // Check each file's hash against existing imports
+ const fileStatuses: { fiscalYear: number; rawContent: string; alreadyImported: boolean; importedAt: string | null }[] = []
+ for (const file of sieFiles) {
+ const fileHash = await calculateFileHash(file.rawContent)
+ const { data: existingImport } = await supabase
+ .from('sie_imports')
+ .select('imported_at')
+ .eq('company_id', companyId)
+ .eq('file_hash', fileHash)
+ .eq('status', 'completed')
+ .maybeSingle()
+
+ fileStatuses.push({
+ fiscalYear: file.fiscalYear,
+ rawContent: file.rawContent,
+ alreadyImported: !!existingImport,
+ importedAt: existingImport?.imported_at ?? null,
+ })
+ }
+
+ const allImported = fileStatuses.every(f => f.alreadyImported)
+ const newFiles = fileStatuses.filter(f => !f.alreadyImported)
return NextResponse.json({
parsed,
@@ -436,7 +633,14 @@ export const arcimMigrationExtension: Extension = {
mappingStats,
preview,
validation,
- rawContent: allRawContent,
+ rawContent: fileStatuses.map(f => f.rawContent),
+ fileStatuses: fileStatuses.map(f => ({
+ fiscalYear: f.fiscalYear,
+ alreadyImported: f.alreadyImported,
+ importedAt: f.importedAt,
+ })),
+ allImported,
+ newFileCount: newFiles.length,
basAccounts: BAS_REFERENCE,
})
} catch (error) {
@@ -462,6 +666,8 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ const companyId = ctx?.companyId ?? user.id
+
const { rawContent, mappings, options } = await request.json() as {
rawContent: string
mappings: import('@/lib/import/types').AccountMapping[]
@@ -478,14 +684,110 @@ export const arcimMigrationExtension: Extension = {
}
try {
- // Parse the SIE content
const parsed = parseSIEFile(rawContent)
- // Save the user's mappings for future use
+ // Validate all accounts are mapped (same as manual upload)
+ const unmapped = mappings.filter((m: import('@/lib/import/types').AccountMapping) => !m.targetAccount)
+ if (unmapped.length > 0) {
+ return NextResponse.json({
+ error: 'validation',
+ message: `${unmapped.length} account(s) are not mapped`,
+ unmappedAccounts: unmapped.map((m: import('@/lib/import/types').AccountMapping) => ({
+ account: m.sourceAccount,
+ name: m.sourceName,
+ })),
+ }, { status: 400 })
+ }
+
+ // Auto-activate mapped BAS accounts not yet in user's chart (same as manual upload)
+ const mappedAccountNumbers = [
+ ...new Set(mappings.filter((m: import('@/lib/import/types').AccountMapping) => m.targetAccount).map((m: import('@/lib/import/types').AccountMapping) => m.targetAccount)),
+ ]
+
+ const allCompanyAccounts = await fetchAllRows(({ from, to }) =>
+ supabase
+ .from('chart_of_accounts')
+ .select('account_number')
+ .eq('company_id', companyId)
+ .range(from, to)
+ )
+ const existingNumbers = new Set(allCompanyAccounts.map((a: { account_number: string }) => a.account_number))
+
+ const mappingNameLookup = new Map()
+ for (const m of mappings) {
+ if (m.targetAccount) {
+ mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName)
+ }
+ }
+
+ const accountsToActivate = mappedAccountNumbers
+ .filter((num) => !existingNumbers.has(num))
+ .map((num) => {
+ const ref = getBASReference(num)
+ if (ref) {
+ return {
+ user_id: user.id,
+ company_id: companyId,
+ account_number: ref.account_number,
+ account_name: ref.account_name,
+ account_class: ref.account_class,
+ account_group: ref.account_group,
+ account_type: ref.account_type,
+ normal_balance: ref.normal_balance,
+ plan_type: 'full_bas' as const,
+ is_active: true,
+ is_system_account: false,
+ description: ref.description,
+ sru_code: ref.sru_code,
+ sort_order: parseInt(ref.account_number),
+ }
+ }
+
+ const accountClass = parseInt(num.charAt(0), 10)
+ const accountGroup = num.substring(0, 2)
+ const accountName = mappingNameLookup.get(num) || `Konto ${num}`
+ const accountType =
+ accountClass === 1 ? 'asset'
+ : accountClass === 2 ? 'liability'
+ : accountClass === 3 ? 'revenue'
+ : 'expense'
+ const normalBalance =
+ accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit'
+
+ return {
+ user_id: user.id,
+ company_id: companyId,
+ account_number: num,
+ account_name: accountName,
+ account_class: accountClass,
+ account_group: accountGroup,
+ account_type: accountType,
+ normal_balance: normalBalance,
+ plan_type: 'full_bas' as const,
+ is_active: true,
+ is_system_account: false,
+ description: accountName,
+ sru_code: null,
+ sort_order: parseInt(num),
+ }
+ })
+
+ if (accountsToActivate.length > 0) {
+ const { error: activateError } = await supabase
+ .from('chart_of_accounts')
+ .insert(accountsToActivate)
+
+ if (activateError) {
+ return NextResponse.json({
+ error: `Failed to activate accounts: ${activateError.message}`,
+ }, { status: 500 })
+ }
+ log.info(`Auto-activated ${accountsToActivate.length} accounts`)
+ }
+
await saveMappings(supabase, user.id, mappings)
- // Execute the import via core engine
- const result = await executeSIEImport(supabase, ctx?.companyId ?? user.id, user.id, parsed, mappings, {
+ const result = await executeSIEImport(supabase, companyId, user.id, parsed, mappings, {
filename: `migration-sie-${Date.now()}.se`,
fileContent: rawContent,
createFiscalPeriod: options.createFiscalPeriod,
@@ -525,6 +827,8 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ const companyId = ctx?.companyId ?? user.id
+
const {
consentId,
importCompanyInfo = true,
@@ -546,7 +850,6 @@ export const arcimMigrationExtension: Extension = {
}
try {
- // Verify consent
const consent = await getConsent(consentId)
if (consent.status !== 1) {
return NextResponse.json(
@@ -559,8 +862,8 @@ export const arcimMigrationExtension: Extension = {
const results = await executeMigration({
consentId,
+ companyId,
userId: user.id,
- companyId: ctx?.companyId ?? user.id,
supabase,
importCompanyInfo,
importCustomers,
@@ -604,7 +907,6 @@ export const arcimMigrationExtension: Extension = {
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)
diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts
index 5c6d70b2..78e1b81a 100644
--- a/extensions/general/arcim-migration/lib/entity-mapper.ts
+++ b/extensions/general/arcim-migration/lib/entity-mapper.ts
@@ -16,7 +16,7 @@ import type {
CompanyInformationDto,
PostalAddress,
PartyDto,
-} from '../types'
+} from '@/lib/providers/dto'
// ── Helpers ─────────────────────────────────────────────────────────
diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts
index 7047c8d7..2817b547 100644
--- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts
+++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts
@@ -1,8 +1,8 @@
/**
* Migration orchestrator — coordinates the data migration from
- * an external accounting system via Arcim Sync into gnubok.
+ * an external accounting system directly via provider APIs into gnubok.
*
- * Bookkeeping data (accounts, balances, vouchers) is now imported
+ * Bookkeeping data (accounts, balances, vouchers) is imported
* via SIE files through the core SIE import engine. This orchestrator
* handles only entity-level imports:
* 1. Company info → pre-fill company_settings
@@ -14,13 +14,15 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { MigrationProgress, MigrationResults } from '../types'
+import type { ProviderName } from '@/lib/providers/types'
+import { resolveConsent } from '@/lib/providers/resolve-consent'
import {
- fetchCompanyInfo,
- fetchCustomers,
- fetchSuppliers,
- fetchSalesInvoices,
- fetchSupplierInvoices,
-} from './arcim-client'
+ fetchCompanyInfoDirect,
+ fetchCustomersDirect,
+ fetchSuppliersDirect,
+ fetchSalesInvoicesDirect,
+ fetchSupplierInvoicesDirect,
+} from '@/lib/providers/provider-data-fetcher'
import {
mapCustomer,
mapSupplier,
@@ -32,8 +34,8 @@ import {
export interface MigrationOptions {
consentId: string
- userId: string
companyId: string
+ userId: string
supabase: SupabaseClient
importCompanyInfo?: boolean
importCustomers?: boolean
@@ -50,15 +52,21 @@ function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
// ── Main orchestrator ─────────────────────────────────────────────
export async function executeMigration(options: MigrationOptions): Promise {
- const { consentId, userId, companyId, supabase } = options
+ const { consentId, companyId, userId, supabase } = options
const results: MigrationResults = {}
+ // Resolve consent to get access token and provider
+ const resolved = await resolveConsent(companyId, consentId)
+ const provider = resolved.consent.provider as ProviderName
+ const accessToken = resolved.accessToken
+ const providerCompanyId = resolved.providerCompanyId
+
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)
+ const companyInfo = await fetchCompanyInfoDirect(provider, accessToken, providerCompanyId)
if (companyInfo) {
const mapped = mapCompanyInfo(companyInfo)
const { data: existing } = await supabase
@@ -100,7 +108,7 @@ export async function executeMigration(options: MigrationOptions): Promise
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked'
)
@@ -324,7 +332,7 @@ export async function executeMigration(options: MigrationOptions): Promise
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked' || i.status === 'draft'
)
diff --git a/extensions/general/arcim-migration/lib/provider-client.ts b/extensions/general/arcim-migration/lib/provider-client.ts
new file mode 100644
index 00000000..3d51240d
--- /dev/null
+++ b/extensions/general/arcim-migration/lib/provider-client.ts
@@ -0,0 +1,262 @@
+/**
+ * Direct provider client — replaces arcim-client.ts.
+ *
+ * Instead of making HTTP calls to the Arcim Sync gateway, this module
+ * performs consent/OTC operations directly against Supabase and delegates
+ * data fetching to the provider clients in lib/providers/.
+ */
+
+import { createServiceClient } from '@/lib/supabase/server'
+import type { ProviderName } from '@/lib/providers/types'
+import { getOAuthConfig } from '@/lib/providers/oauth-config'
+import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth'
+import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
+import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth'
+import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
+import type { ConsentRecord, OtcResponse } from '../types'
+
+// Re-export data fetching functions from the provider layer
+export { resolveConsent } from '@/lib/providers/resolve-consent'
+export {
+ fetchCompanyInfoDirect,
+ fetchCustomersDirect,
+ fetchSuppliersDirect,
+ fetchSalesInvoicesDirect,
+ fetchSupplierInvoicesDirect,
+} from '@/lib/providers/provider-data-fetcher'
+
+// ── Consent lifecycle (direct Supabase) ─────────────────────────────
+
+export async function createConsent(
+ companyId: string,
+ provider: ProviderName,
+ name: string,
+ orgNumber?: string,
+ companyName?: string,
+): Promise {
+ const supabase = createServiceClient()
+
+ const { data, error } = await supabase
+ .from('provider_consents')
+ .insert({
+ company_id: companyId,
+ name,
+ provider,
+ org_number: orgNumber,
+ company_name: companyName,
+ status: 0, // Created
+ })
+ .select('*')
+ .single()
+
+ if (error || !data) {
+ throw new Error(`Failed to create consent: ${error?.message}`)
+ }
+
+ return {
+ id: data.id,
+ name: data.name,
+ provider: data.provider as ProviderName,
+ status: data.status,
+ orgNumber: data.org_number,
+ companyName: data.company_name,
+ }
+}
+
+export async function listConsents(companyId: string): Promise {
+ const supabase = createServiceClient()
+
+ const { data, error } = await supabase
+ .from('provider_consents')
+ .select('*')
+ .eq('company_id', companyId)
+ .in('status', [0, 1]) // Created or Accepted
+ .order('created_at', { ascending: false })
+
+ if (error) {
+ throw new Error(`Failed to list consents: ${error.message}`)
+ }
+
+ return (data ?? []).map(d => ({
+ id: d.id,
+ name: d.name,
+ provider: d.provider as ProviderName,
+ status: d.status,
+ orgNumber: d.org_number,
+ companyName: d.company_name,
+ createdAt: d.created_at,
+ updatedAt: d.updated_at,
+ }))
+}
+
+export async function getConsent(consentId: string): Promise {
+ const supabase = createServiceClient()
+
+ const { data, error } = await supabase
+ .from('provider_consents')
+ .select('*')
+ .eq('id', consentId)
+ .single()
+
+ if (error || !data) {
+ throw new Error(`Consent not found: ${error?.message}`)
+ }
+
+ return {
+ id: data.id,
+ name: data.name,
+ provider: data.provider as ProviderName,
+ status: data.status,
+ orgNumber: data.org_number,
+ companyName: data.company_name,
+ }
+}
+
+export async function deleteConsent(consentId: string): Promise {
+ const supabase = createServiceClient()
+
+ // Delete tokens first (cascade should handle this, but be explicit)
+ await supabase.from('provider_consent_tokens').delete().eq('consent_id', consentId)
+ await supabase.from('provider_otc').delete().eq('consent_id', consentId)
+
+ const { error } = await supabase
+ .from('provider_consents')
+ .delete()
+ .eq('id', consentId)
+
+ if (error) {
+ throw new Error(`Failed to delete consent: ${error.message}`)
+ }
+}
+
+export async function generateOtc(
+ consentId: string,
+ expiresInMinutes: number = 60,
+): Promise {
+ const supabase = createServiceClient()
+
+ const code = crypto.randomUUID().replace(/-/g, '').slice(0, 16)
+ const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString()
+
+ const { error } = await supabase
+ .from('provider_otc')
+ .insert({
+ code,
+ consent_id: consentId,
+ expires_at: expiresAt,
+ })
+
+ if (error) {
+ throw new Error(`Failed to generate OTC: ${error.message}`)
+ }
+
+ return { code, consentId, expiresAt }
+}
+
+// ── OAuth helpers (direct provider calls) ───────────────────────────
+
+export async function getAuthUrl(
+ provider: ProviderName,
+ state?: string,
+ redirectUri?: string,
+): Promise<{ url: string }> {
+ const config = getOAuthConfig(provider)
+
+ // Override redirect URI if provided (extension callback URL)
+ const effectiveConfig = redirectUri
+ ? { ...config, redirectUri }
+ : config
+
+ if (provider === 'fortnox') {
+ const url = buildFortnoxAuthUrl(effectiveConfig, { state })
+ return { url }
+ }
+
+ if (provider === 'visma') {
+ const url = buildVismaAuthUrl(effectiveConfig, { state })
+ return { url }
+ }
+
+ throw new Error(`OAuth is not supported for provider: ${provider}`)
+}
+
+export async function exchangeAuthToken(
+ consentId: string,
+ provider: ProviderName,
+ code: string,
+ redirectUri?: string,
+): Promise<{ success: boolean; consentId: string }> {
+ const config = getOAuthConfig(provider)
+ const effectiveConfig = redirectUri ? { ...config, redirectUri } : config
+ const supabase = createServiceClient()
+
+ let tokenResponse: { access_token: string; refresh_token: string; expires_in: number }
+
+ if (provider === 'fortnox') {
+ tokenResponse = await exchangeFortnoxCode(effectiveConfig, code)
+ } else if (provider === 'visma') {
+ tokenResponse = await exchangeVismaCode(effectiveConfig, code)
+ } else {
+ throw new Error(`OAuth exchange not supported for provider: ${provider}`)
+ }
+
+ const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
+
+ // Store tokens
+ await supabase
+ .from('provider_consent_tokens')
+ .upsert({
+ consent_id: consentId,
+ provider,
+ access_token: tokenResponse.access_token,
+ refresh_token: tokenResponse.refresh_token,
+ token_expires_at: expiresAt,
+ })
+
+ // Mark consent as accepted
+ await supabase
+ .from('provider_consents')
+ .update({ status: 1 })
+ .eq('id', consentId)
+
+ return { success: true, consentId }
+}
+
+export async function submitProviderToken(
+ consentId: string,
+ provider: ProviderName,
+ apiToken: string,
+ companyId?: string,
+): Promise<{ success: boolean; consentId: string }> {
+ const supabase = createServiceClient()
+
+ let accessToken = apiToken
+ let tokenExpiresAt: string | null = null
+
+ // BL uses client credentials — get a real token
+ if (provider === 'bjornlunden') {
+ const tokenResponse = await refreshBjornLundenToken()
+ accessToken = tokenResponse.access_token
+ tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
+ }
+
+ // Store tokens
+ await supabase
+ .from('provider_consent_tokens')
+ .upsert({
+ consent_id: consentId,
+ provider,
+ access_token: accessToken,
+ refresh_token: null,
+ token_expires_at: tokenExpiresAt,
+ provider_company_id: companyId,
+ })
+
+ // Mark consent as accepted
+ await supabase
+ .from('provider_consents')
+ .update({ status: 1 })
+ .eq('id', consentId)
+
+ return { success: true, consentId }
+}
diff --git a/extensions/general/arcim-migration/manifest.json b/extensions/general/arcim-migration/manifest.json
index 9538ec2e..63afd338 100644
--- a/extensions/general/arcim-migration/manifest.json
+++ b/extensions/general/arcim-migration/manifest.json
@@ -4,16 +4,28 @@
"exportName": "arcimMigrationExtension",
"entryPoint": "@/extensions/general/arcim-migration",
"workspace": "@/components/extensions/general/ArcimMigrationWorkspace",
- "requiredEnvVars": ["ARCIM_SYNC_GATEWAY_URL", "ARCIM_SYNC_API_KEY"],
- "optionalEnvVars": [],
- "npmDependencies": [],
+ "requiredEnvVars": [],
+ "optionalEnvVars": [
+ "FORTNOX_CLIENT_ID",
+ "FORTNOX_CLIENT_SECRET",
+ "FORTNOX_REDIRECT_URI",
+ "VISMA_CLIENT_ID",
+ "VISMA_CLIENT_SECRET",
+ "VISMA_REDIRECT_URI",
+ "BRIOX_CLIENT_ID",
+ "BJORN_LUNDEN_CLIENT_ID",
+ "BJORN_LUNDEN_CLIENT_SECRET",
+ "UPSTASH_REDIS_REST_URL",
+ "UPSTASH_REDIS_REST_TOKEN"
+ ],
+ "npmDependencies": ["@upstash/redis", "@upstash/ratelimit"],
"definition": {
- "name": "Systemmigration (Arcim Sync)",
+ "name": "Systemmigration",
"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."
+ "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 direkt med leverantören."
}
}
diff --git a/extensions/general/arcim-migration/types.ts b/extensions/general/arcim-migration/types.ts
index 8c28aecf..fc62eccc 100644
--- a/extensions/general/arcim-migration/types.ts
+++ b/extensions/general/arcim-migration/types.ts
@@ -1,198 +1,34 @@
/**
- * Types for the Arcim Sync migration extension.
+ * Types for the provider 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.
+ * DTO types are now imported from the canonical source at lib/providers/dto.ts
+ * instead of being duplicated here.
*/
-// ── Arcim Sync canonical DTOs (subset we consume) ──────────────────
+// Re-export canonical DTOs used by entity-mapper and migration-orchestrator
+export type {
+ AmountType,
+ PostalAddress,
+ Contact,
+ PartyIdentification,
+ PartyLegalEntity,
+ PartyDto,
+ PaginatedResponse,
+ TaxSubtotalDto,
+ TaxTotalDto,
+ LegalMonetaryTotalDto,
+ PaymentStatusDto,
+ CompanyInformationDto,
+ CustomerDto,
+ SupplierDto,
+ InvoiceStatusCode,
+ SalesInvoiceLineDto,
+ SalesInvoiceDto,
+ SupplierInvoiceLineDto,
+ SupplierInvoiceDto,
+} from '@/lib/providers/dto'
-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 {
- 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
-}
+export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
// ── Supported providers ─────────────────────────────────────────────
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index 151da4ab..0bd3653e 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -32,13 +32,13 @@ export const EXTENSION_DEFINITIONS: Record = {
},
{
"slug": "arcim-migration",
- "name": "Systemmigration (Arcim Sync)",
+ "name": "Systemmigration",
"sector": "general",
"category": "import",
"icon": "ArrowRightLeft",
"dataPattern": "manual",
"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."
+ "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 direkt med leverantören."
},
{
"slug": "tic",
diff --git a/lib/providers/bjornlunden/client.ts b/lib/providers/bjornlunden/client.ts
new file mode 100644
index 00000000..fc901ade
--- /dev/null
+++ b/lib/providers/bjornlunden/client.ts
@@ -0,0 +1,107 @@
+import { TokenBucketRateLimiter } from '../rate-limiter';
+import { withRetry } from '../retry';
+import { BL_BASE_URL, BL_RATE_LIMIT } from './config';
+
+export class BjornLundenApiError extends Error {
+ constructor(
+ message: string,
+ public readonly statusCode: number,
+ public readonly body?: string,
+ ) {
+ super(message);
+ this.name = 'BjornLundenApiError';
+ }
+}
+
+function isRetryableError(error: unknown): boolean {
+ if (error instanceof BjornLundenApiError) {
+ if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
+ return false;
+ }
+ return error.statusCode === 429 || error.statusCode >= 500;
+ }
+ return false;
+}
+
+interface BLPaginatedResponse {
+ pageRequested: number;
+ totalPages: number;
+ totalRows: number;
+ data: T[];
+}
+
+export class BjornLundenClient {
+ private readonly rateLimiter: TokenBucketRateLimiter;
+ private readonly baseUrl: string;
+
+ constructor(baseUrl?: string) {
+ this.baseUrl = baseUrl ?? BL_BASE_URL;
+ this.rateLimiter = new TokenBucketRateLimiter(BL_RATE_LIMIT, 'ratelimit:bjornlunden');
+ }
+
+ async get(accessToken: string, userKey: string, path: string): Promise {
+ return withRetry(
+ async () => {
+ await this.rateLimiter.acquire();
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'User-Key': userKey,
+ Accept: 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new BjornLundenApiError(
+ `Björn Lunden API error: ${response.status} ${response.statusText}`,
+ response.status,
+ body,
+ );
+ }
+
+ return response.json() as Promise;
+ },
+ {
+ maxAttempts: 3,
+ initialDelayMs: 1000,
+ shouldRetry: isRetryableError,
+ },
+ );
+ }
+
+ async getPage(
+ accessToken: string,
+ userKey: string,
+ relativePath: string,
+ options?: { page?: number; pageSize?: number },
+ ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
+ const params = new URLSearchParams();
+ params.set('pageRequested', String(options?.page ?? 1));
+ params.set('rowsRequested', String(options?.pageSize ?? 50));
+ params.set('rows', String(options?.pageSize ?? 50));
+
+ const path = `${relativePath}?${params.toString()}`;
+ const response = await this.get>(accessToken, userKey, path);
+
+ return {
+ items: Array.isArray(response.data) ? response.data : [],
+ page: response.pageRequested ?? (options?.page ?? 1),
+ totalPages: response.totalPages ?? 1,
+ totalCount: response.totalRows ?? 0,
+ };
+ }
+
+ async getAll(accessToken: string, userKey: string, path: string): Promise {
+ const response = await this.get>(accessToken, userKey, path);
+ if (Array.isArray(response)) {
+ return response;
+ }
+ return Array.isArray(response.data) ? response.data : [];
+ }
+
+ async getDetail(accessToken: string, userKey: string, path: string): Promise {
+ return this.get(accessToken, userKey, path);
+ }
+}
diff --git a/lib/providers/bjornlunden/config.ts b/lib/providers/bjornlunden/config.ts
new file mode 100644
index 00000000..fba19610
--- /dev/null
+++ b/lib/providers/bjornlunden/config.ts
@@ -0,0 +1,67 @@
+import { ResourceType } from '../dto';
+import type { BjornLundenResourceConfig, RateLimitConfig } from '../types';
+import {
+ mapBLToSalesInvoice,
+ mapBLToSupplierInvoice,
+ mapBLToCustomer,
+ mapBLToSupplier,
+ mapBLToJournal,
+ mapBLToAccountingAccount,
+ mapBLToCompanyInformation,
+} from './mapper';
+
+export const BL_BASE_URL = 'https://apigateway.blinfo.se/bla-api/v1/sp';
+export const BL_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 };
+
+export const BL_RESOURCE_CONFIGS: Partial> = {
+ [ResourceType.SalesInvoices]: {
+ listEndpoint: '/customerinvoice/batch',
+ detailEndpoint: '/customerinvoice/{id}',
+ idField: 'invoiceNumber',
+ mapper: mapBLToSalesInvoice,
+ paginated: true,
+ },
+ [ResourceType.SupplierInvoices]: {
+ listEndpoint: '/supplierinvoice/batch',
+ detailEndpoint: '/supplierinvoice/byId/{id}',
+ idField: 'entityId',
+ mapper: mapBLToSupplierInvoice,
+ paginated: true,
+ },
+ [ResourceType.Customers]: {
+ listEndpoint: '/customer',
+ detailEndpoint: '/customer/{id}',
+ idField: 'id',
+ mapper: mapBLToCustomer,
+ paginated: false,
+ },
+ [ResourceType.Suppliers]: {
+ listEndpoint: '/supplier',
+ detailEndpoint: '/supplier/{id}',
+ idField: 'id',
+ mapper: mapBLToSupplier,
+ paginated: false,
+ },
+ [ResourceType.Journals]: {
+ listEndpoint: '/journal/entry/batch',
+ detailEndpoint: '/journal/entry/{id}',
+ idField: 'entityId',
+ mapper: mapBLToJournal,
+ paginated: true,
+ },
+ [ResourceType.AccountingAccounts]: {
+ listEndpoint: '/account',
+ detailEndpoint: '/account/{id}',
+ idField: 'id',
+ mapper: mapBLToAccountingAccount,
+ paginated: false,
+ },
+ [ResourceType.CompanyInformation]: {
+ listEndpoint: '/details',
+ detailEndpoint: '',
+ idField: '',
+ mapper: mapBLToCompanyInformation,
+ singleton: true,
+ paginated: false,
+ },
+};
diff --git a/lib/providers/bjornlunden/mapper.ts b/lib/providers/bjornlunden/mapper.ts
new file mode 100644
index 00000000..ef22ecf3
--- /dev/null
+++ b/lib/providers/bjornlunden/mapper.ts
@@ -0,0 +1,302 @@
+import type {
+ SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
+ LegalMonetaryTotalDto, PaymentStatusDto,
+ SupplierInvoiceDto,
+ CustomerDto,
+ SupplierDto,
+ JournalDto, AccountingEntryDto,
+ AccountingAccountDto, AccountType,
+ CompanyInformationDto,
+ AmountType, PartyDto,
+} from '../dto';
+
+function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
+ return { value: value ?? 0, currencyCode: currency };
+}
+
+function deriveBLInvoiceStatus(raw: Record): InvoiceStatusCode {
+ if (raw['paid'] === true) return 'paid';
+ if (raw['preliminary'] === true) return 'draft';
+ const status = raw['status'] != null ? String(raw['status']).toLowerCase() : undefined;
+ if (status === 'cancelled') return 'cancelled';
+ if (status === 'credited') return 'credited';
+ if (status === 'sent') return 'sent';
+ return 'booked';
+}
+
+/**
+ * Map BL Customer Invoice to SalesInvoiceDto.
+ *
+ * BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency,
+ * customerId, customerName, amount, amountInLocalCurrency,
+ * amountPaidInLocalCurrency, paid, preliminary, status
+ */
+export function mapBLToSalesInvoice(raw: Record): SalesInvoiceDto {
+ const currency = (raw['currency'] as string) ?? 'SEK';
+ const totalAmount = (raw['amountInLocalCurrency'] as number) ?? (raw['amount'] as number) ?? 0;
+ const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0;
+ const balance = totalAmount - paidAmount;
+
+ const customer: PartyDto = {
+ name: (raw['customerName'] as string) ?? '',
+ identifications: raw['customerId'] ? [{ id: String(raw['customerId']), schemeId: 'BL:CUSTOMER_ID' }] : [],
+ };
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(totalAmount, currency),
+ taxInclusiveAmount: amount(totalAmount, currency),
+ payableAmount: amount(totalAmount, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: raw['paid'] === true,
+ balance: amount(balance, currency),
+ };
+
+ return {
+ id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''),
+ invoiceNumber: String(raw['invoiceNumber'] ?? ''),
+ issueDate: (raw['invoiceDate'] as string) ?? '',
+ dueDate: raw['dueDate'] as string | undefined,
+ currencyCode: currency,
+ status: deriveBLInvoiceStatus(raw),
+ supplier: { name: '', identifications: [] },
+ customer,
+ lines: [], // BL doesn't include line items in list responses
+ legalMonetaryTotal,
+ paymentStatus,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map BL Supplier Invoice to SupplierInvoiceDto.
+ *
+ * BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency,
+ * supplierId, supplierName, amountInLocalCurrency,
+ * amountPaidInLocalCurrency, amountRemainingInLocalCurrency, paid, preliminary, status
+ */
+export function mapBLToSupplierInvoice(raw: Record): SupplierInvoiceDto {
+ const currency = (raw['currency'] as string) ?? 'SEK';
+ const totalAmount = (raw['amountInLocalCurrency'] as number) ?? 0;
+ const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0;
+ const remaining = (raw['amountRemainingInLocalCurrency'] as number) ?? (totalAmount - paidAmount);
+
+ const supplier: PartyDto = {
+ name: (raw['supplierName'] as string) ?? '',
+ identifications: raw['supplierId'] ? [{ id: String(raw['supplierId']), schemeId: 'BL:SUPPLIER_ID' }] : [],
+ };
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(totalAmount, currency),
+ taxInclusiveAmount: amount(totalAmount, currency),
+ payableAmount: amount(totalAmount, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: raw['paid'] === true,
+ balance: amount(remaining, currency),
+ };
+
+ return {
+ id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''),
+ invoiceNumber: String(raw['invoiceNumber'] ?? ''),
+ issueDate: (raw['invoiceDate'] as string) ?? '',
+ dueDate: raw['dueDate'] as string | undefined,
+ currencyCode: currency,
+ status: deriveBLInvoiceStatus(raw),
+ supplier,
+ buyer: { name: '', identifications: [] },
+ lines: [], // BL doesn't include line items in list responses
+ legalMonetaryTotal,
+ paymentStatus,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map BL Customer to CustomerDto.
+ *
+ * BL fields: entityId, id, name, organisationNumber, street, box, zip, city,
+ * country, phone, email, currency, vatNumber, paymentTerms, closed
+ */
+export function mapBLToCustomer(raw: Record): CustomerDto {
+ const name = (raw['name'] as string) ?? '';
+ const orgNumber = raw['organisationNumber'] as string | undefined;
+
+ const party: PartyDto = {
+ name,
+ identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
+ postalAddress: {
+ streetName: raw['street'] as string | undefined,
+ additionalStreetName: raw['box'] as string | undefined,
+ postalZone: raw['zip'] as string | undefined,
+ cityName: raw['city'] as string | undefined,
+ countryCode: raw['country'] as string | undefined,
+ },
+ legalEntity: orgNumber ? {
+ registrationName: name,
+ companyId: orgNumber,
+ companyIdSchemeId: 'SE:ORGNR',
+ } : undefined,
+ contact: {
+ telephone: raw['phone'] as string | undefined,
+ email: raw['email'] as string | undefined,
+ },
+ };
+
+ return {
+ id: String(raw['id'] ?? raw['entityId'] ?? ''),
+ customerNumber: String(raw['id'] ?? ''),
+ type: 'company',
+ party,
+ active: raw['closed'] !== true,
+ vatNumber: raw['vatNumber'] as string | undefined,
+ defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map BL Supplier to SupplierDto.
+ *
+ * BL fields: entityId, id, name, organisationId, address1, address2, zipCode, city,
+ * countryCode, phone, email, bg, pg, iban, vatNr, paymentTerms, closed
+ */
+export function mapBLToSupplier(raw: Record): SupplierDto {
+ const name = (raw['name'] as string) ?? '';
+ const orgNumber = raw['organisationId'] as string | undefined;
+
+ const party: PartyDto = {
+ name,
+ identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
+ postalAddress: {
+ streetName: raw['address1'] as string | undefined,
+ additionalStreetName: raw['address2'] as string | undefined,
+ postalZone: raw['zipCode'] as string | undefined,
+ cityName: raw['city'] as string | undefined,
+ countryCode: raw['countryCode'] as string | undefined,
+ },
+ legalEntity: orgNumber ? {
+ registrationName: name,
+ companyId: orgNumber,
+ companyIdSchemeId: 'SE:ORGNR',
+ } : undefined,
+ contact: {
+ telephone: raw['phone'] as string | undefined,
+ email: raw['email'] as string | undefined,
+ },
+ };
+
+ return {
+ id: String(raw['id'] ?? raw['entityId'] ?? ''),
+ supplierNumber: String(raw['id'] ?? ''),
+ party,
+ active: raw['closed'] !== true,
+ vatNumber: raw['vatNr'] as string | undefined,
+ bankGiro: raw['bg'] as string | undefined,
+ plusGiro: raw['pg'] as string | undefined,
+ bankAccount: raw['iban'] as string | undefined,
+ defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map BL Journal/Ledger Entry to JournalDto.
+ *
+ * BL fields: entityId, journalId, journalEntryId, journalEntryDate,
+ * journalEntryText, financialYearId, ledgerEntries[{ accountId, amount, text }],
+ * totalCreditSum, totalDebitSum
+ */
+export function mapBLToJournal(raw: Record): JournalDto {
+ const rawEntries = (raw['ledgerEntries'] as Record[] | undefined) ?? [];
+ const entries: AccountingEntryDto[] = rawEntries.map((entry) => {
+ // BL LedgerEntry has a single `amount` field: positive = debit, negative = credit
+ const amt = (entry['amount'] as number) ?? 0;
+ return {
+ accountNumber: String(entry['accountId'] ?? ''),
+ debit: amt > 0 ? amt : 0,
+ credit: amt < 0 ? Math.abs(amt) : 0,
+ description: entry['text'] as string | undefined,
+ };
+ });
+
+ const totalCredit = (raw['totalCreditSum'] as number) ?? 0;
+ const totalDebit = (raw['totalDebitSum'] as number) ?? entries.reduce((sum, e) => sum + e.debit, 0);
+
+ return {
+ id: String(raw['entityId'] ?? raw['journalEntryId'] ?? ''),
+ journalNumber: String(raw['journalId'] ?? raw['journalEntryId'] ?? ''),
+ description: raw['journalEntryText'] as string | undefined,
+ registrationDate: (raw['journalEntryDate'] as string) ?? '',
+ fiscalYear: raw['financialYearId'] != null ? Number(raw['financialYearId']) : undefined,
+ entries,
+ totalDebit: { value: totalDebit, currencyCode: 'SEK' },
+ totalCredit: { value: totalCredit, currencyCode: 'SEK' },
+ _raw: raw,
+ };
+}
+
+/**
+ * Map BL Account to AccountingAccountDto.
+ *
+ * BL fields: entityId, id (account number), name, vatCode, sruCode, closed, type
+ * Type derived from BAS plan number ranges.
+ */
+export function mapBLToAccountingAccount(raw: Record): AccountingAccountDto {
+ const num = Number(raw['id']);
+
+ let type: AccountType | undefined;
+ if (num >= 1000 && num < 2000) type = 'asset';
+ else if (num >= 2000 && num < 3000) type = 'liability';
+ else if (num >= 3000 && num < 4000) type = 'revenue';
+ else if (num >= 4000 && num < 9000) type = 'expense';
+
+ return {
+ accountNumber: String(raw['id'] ?? ''),
+ name: (raw['name'] as string) ?? '',
+ type,
+ vatCode: raw['vatCode'] as string | undefined,
+ sruCode: raw['sruCode'] != null ? String(raw['sruCode']) : undefined,
+ active: raw['closed'] !== true,
+ balanceCarriedForward: (raw['debit'] != null || raw['credit'] != null)
+ ? (Number(raw['debit'] ?? 0) - Number(raw['credit'] ?? 0))
+ : undefined,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map BL Company Details to CompanyInformationDto.
+ *
+ * BL fields: name, orgNumber, street, box, zip, city, country,
+ * phone, email, bg, pg, iban, vatNumber, preferredSettings.currency
+ */
+export function mapBLToCompanyInformation(raw: Record): CompanyInformationDto {
+ const settings = raw['preferredSettings'] as Record | undefined;
+
+ return {
+ companyName: (raw['name'] as string) ?? '',
+ organizationNumber: raw['orgNumber'] as string | undefined,
+ legalEntity: {
+ registrationName: (raw['name'] as string) ?? '',
+ companyId: raw['orgNumber'] as string | undefined,
+ companyIdSchemeId: 'SE:ORGNR',
+ },
+ address: {
+ streetName: raw['street'] as string | undefined,
+ additionalStreetName: raw['box'] as string | undefined,
+ postalZone: raw['zip'] as string | undefined,
+ cityName: raw['city'] as string | undefined,
+ countryCode: raw['country'] as string | undefined,
+ },
+ contact: {
+ telephone: raw['phone'] as string | undefined,
+ email: raw['email'] as string | undefined,
+ },
+ vatNumber: raw['vatNumber'] as string | undefined,
+ baseCurrency: (settings?.['currency'] as string) ?? 'SEK',
+ _raw: raw,
+ };
+}
diff --git a/lib/providers/bjornlunden/oauth.ts b/lib/providers/bjornlunden/oauth.ts
new file mode 100644
index 00000000..4f615a53
--- /dev/null
+++ b/lib/providers/bjornlunden/oauth.ts
@@ -0,0 +1,44 @@
+import type { TokenResponse } from '../types';
+
+const BL_AUTH_URL = 'https://apigateway.blinfo.se/auth/oauth/v2/token';
+
+export async function fetchBjornLundenToken(
+ clientId: string,
+ clientSecret: string,
+): Promise {
+ const response = await fetch(BL_AUTH_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: clientId,
+ client_secret: clientSecret,
+ }).toString(),
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Björn Lunden token request failed: ${response.status} ${body}`);
+ }
+
+ const data = await response.json() as Record;
+ return {
+ access_token: data.access_token as string,
+ refresh_token: '',
+ token_type: (data.token_type as string) ?? 'Bearer',
+ expires_in: (data.expires_in as number) ?? 3600,
+ };
+}
+
+export async function refreshBjornLundenToken(): Promise {
+ const clientId = process.env.BJORN_LUNDEN_CLIENT_ID ?? '';
+ const clientSecret = process.env.BJORN_LUNDEN_CLIENT_SECRET ?? '';
+ if (!clientId || !clientSecret) {
+ throw new Error('BJORN_LUNDEN_CLIENT_ID and BJORN_LUNDEN_CLIENT_SECRET must be set');
+ }
+ return fetchBjornLundenToken(clientId, clientSecret);
+}
+
+export const storeBjornLundenToken = refreshBjornLundenToken;
diff --git a/lib/providers/bokio/client.ts b/lib/providers/bokio/client.ts
new file mode 100644
index 00000000..b58fdb6a
--- /dev/null
+++ b/lib/providers/bokio/client.ts
@@ -0,0 +1,149 @@
+import { TokenBucketRateLimiter } from '../rate-limiter';
+import { withRetry } from '../retry';
+import { BOKIO_BASE_URL, BOKIO_RATE_LIMIT } from './config';
+
+export class BokioApiError extends Error {
+ constructor(
+ message: string,
+ public readonly statusCode: number,
+ public readonly body?: string,
+ ) {
+ super(message);
+ this.name = 'BokioApiError';
+ }
+}
+
+function isRetryableError(error: unknown): boolean {
+ if (error instanceof BokioApiError) {
+ if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
+ return false;
+ }
+ return error.statusCode === 429 || error.statusCode >= 500;
+ }
+ return false;
+}
+
+interface BokioPaginatedResponse {
+ items: T[];
+ totalItems: number;
+ totalPages: number;
+ currentPage: number;
+}
+
+export class BokioClient {
+ private readonly rateLimiter: TokenBucketRateLimiter;
+ private readonly baseUrl: string;
+
+ constructor(baseUrl?: string) {
+ this.baseUrl = baseUrl ?? BOKIO_BASE_URL;
+ this.rateLimiter = new TokenBucketRateLimiter(BOKIO_RATE_LIMIT, 'ratelimit:bokio');
+ }
+
+ async get(accessToken: string, path: string): Promise {
+ return withRetry(
+ async () => {
+ await this.rateLimiter.acquire();
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new BokioApiError(
+ `Bokio API error: ${response.status} ${response.statusText}`,
+ response.status,
+ body,
+ );
+ }
+
+ return await response.json() as T;
+ },
+ {
+ maxAttempts: 3,
+ initialDelayMs: 1000,
+ shouldRetry: isRetryableError,
+ },
+ );
+ }
+
+ /**
+ * Fetch a paginated list endpoint.
+ * Bokio returns `{ data: [...], pagination: { page, pageSize, totalPages, totalCount } }`.
+ */
+ async getPage(
+ accessToken: string,
+ companyId: string,
+ relativePath: string,
+ options?: {
+ page?: number;
+ pageSize?: number;
+ query?: string;
+ },
+ ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
+ const params = new URLSearchParams();
+ params.set('page', String(options?.page ?? 1));
+ params.set('pageSize', String(options?.pageSize ?? 50));
+ if (options?.query) {
+ params.set('query', options.query);
+ }
+
+ const path = `/companies/${companyId}${relativePath}?${params.toString()}`;
+ const response = await this.get>(accessToken, path);
+
+ return {
+ items: Array.isArray(response.items) ? response.items : [],
+ page: response.currentPage ?? (options?.page ?? 1),
+ totalPages: response.totalPages ?? 1,
+ totalCount: response.totalItems ?? 0,
+ };
+ }
+
+ /**
+ * Fetch a non-paginated list endpoint (e.g. chart-of-accounts).
+ * Returns the full `data` array.
+ */
+ async getAll(
+ accessToken: string,
+ companyId: string,
+ relativePath: string,
+ ): Promise {
+ const path = `/companies/${companyId}${relativePath}`;
+ const response = await this.get(accessToken, path);
+ // Bokio returns a raw array for some endpoints (e.g. chart-of-accounts)
+ if (Array.isArray(response)) {
+ return response;
+ }
+ return Array.isArray(response.items) ? response.items : [];
+ }
+
+ /**
+ * Fetch a single resource detail.
+ * Bokio returns the object directly (no wrapper).
+ */
+ async getDetail(
+ accessToken: string,
+ companyId: string,
+ relativePath: string,
+ ): Promise {
+ const path = `/companies/${companyId}${relativePath}`;
+ return this.get(accessToken, path);
+ }
+
+ async getCompany(
+ accessToken: string,
+ companyId: string,
+ ): Promise {
+ try {
+ return await this.get(accessToken, `/companies/${companyId}`);
+ } catch (err) {
+ if (err instanceof BokioApiError && err.statusCode === 404) {
+ return null;
+ }
+ throw err;
+ }
+ }
+}
diff --git a/lib/providers/bokio/config.ts b/lib/providers/bokio/config.ts
new file mode 100644
index 00000000..9482830e
--- /dev/null
+++ b/lib/providers/bokio/config.ts
@@ -0,0 +1,51 @@
+import { ResourceType } from '../dto';
+import type { BokioResourceConfig, RateLimitConfig } from '../types';
+import {
+ mapBokioToSalesInvoice,
+ mapBokioToCustomer,
+ mapBokioToJournal,
+ mapBokioToAccountingAccount,
+ mapBokioToCompanyInformation,
+} from './mapper';
+
+export const BOKIO_BASE_URL = 'https://api.bokio.se/v1';
+export const BOKIO_RATE_LIMIT: RateLimitConfig = { maxRequests: 5, windowMs: 1000 };
+
+export const BOKIO_RESOURCE_CONFIGS: Partial> = {
+ [ResourceType.SalesInvoices]: {
+ listEndpoint: '/invoices',
+ detailEndpoint: '/invoices/{id}',
+ idField: 'id',
+ mapper: mapBokioToSalesInvoice,
+ paginated: true,
+ },
+ [ResourceType.Customers]: {
+ listEndpoint: '/customers',
+ detailEndpoint: '/customers/{id}',
+ idField: 'id',
+ mapper: mapBokioToCustomer,
+ paginated: true,
+ },
+ [ResourceType.Journals]: {
+ listEndpoint: '/journal-entries',
+ detailEndpoint: '/journal-entries/{id}',
+ idField: 'id',
+ mapper: mapBokioToJournal,
+ paginated: true,
+ },
+ [ResourceType.AccountingAccounts]: {
+ listEndpoint: '/chart-of-accounts',
+ detailEndpoint: '/chart-of-accounts/{id}',
+ idField: 'number',
+ mapper: mapBokioToAccountingAccount,
+ paginated: false,
+ },
+ [ResourceType.CompanyInformation]: {
+ listEndpoint: '',
+ detailEndpoint: '',
+ idField: 'id',
+ mapper: mapBokioToCompanyInformation,
+ singleton: true,
+ paginated: false,
+ },
+};
diff --git a/lib/providers/bokio/mapper.ts b/lib/providers/bokio/mapper.ts
new file mode 100644
index 00000000..98df69cb
--- /dev/null
+++ b/lib/providers/bokio/mapper.ts
@@ -0,0 +1,227 @@
+import type {
+ SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
+ LegalMonetaryTotalDto, PaymentStatusDto,
+ CustomerDto,
+ JournalDto, AccountingEntryDto,
+ AccountingAccountDto, AccountType,
+ CompanyInformationDto,
+ AmountType, PartyDto,
+} from '../dto';
+
+function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
+ return { value: value ?? 0, currencyCode: currency };
+}
+
+function deriveInvoiceStatus(raw: Record): InvoiceStatusCode {
+ const status = (raw['status'] as string | undefined)?.toLowerCase();
+ if (status === 'cancelled') return 'cancelled';
+ if (status === 'paid') return 'paid';
+ if (status === 'overdue') return 'overdue';
+ if (status === 'published') return 'sent';
+ if (status === 'draft') return 'draft';
+ return 'draft';
+}
+
+function buildParty(name: string, orgNumber?: string, address?: Record): PartyDto {
+ return {
+ name,
+ identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
+ postalAddress: address ? {
+ streetName: address['line1'] as string | undefined,
+ additionalStreetName: address['line2'] as string | undefined,
+ cityName: address['city'] as string | undefined,
+ postalZone: address['postalCode'] as string | undefined,
+ countryCode: address['country'] as string | undefined,
+ } : undefined,
+ legalEntity: orgNumber ? {
+ registrationName: name,
+ companyId: orgNumber,
+ companyIdSchemeId: 'SE:ORGNR',
+ } : undefined,
+ };
+}
+
+/**
+ * Map Bokio Invoice to SalesInvoiceDto.
+ *
+ * Bokio Invoice fields:
+ * - id, invoiceNumber, status (draft|published|paid|overdue|cancelled)
+ * - invoiceDate, dueDate, currency, totalAmount, totalTax, paidAmount
+ * - customerRef: { id, name }, lineItems: [{ id, description, quantity, unitPrice, taxRate, unitType }]
+ */
+export function mapBokioToSalesInvoice(raw: Record): SalesInvoiceDto {
+ const currency = (raw['currency'] as string) ?? 'SEK';
+ const totalAmount = (raw['totalAmount'] as number) ?? 0;
+ const totalTax = (raw['totalTax'] as number) ?? 0;
+ const paidAmount = (raw['paidAmount'] as number) ?? 0;
+ const balance = totalAmount - paidAmount;
+
+ const customerRef = raw['customerRef'] as Record | undefined;
+ const rawLines = (raw['lineItems'] as Record[] | undefined) ?? [];
+
+ const lines: SalesInvoiceLineDto[] = rawLines.map((line, idx) => {
+ const unitPrice = line['unitPrice'] as number | undefined;
+ const quantity = line['quantity'] as number | undefined;
+ const lineTotal = unitPrice != null && quantity != null ? unitPrice * quantity : 0;
+
+ return {
+ id: String(line['id'] ?? idx + 1),
+ description: line['description'] as string | undefined,
+ quantity,
+ unitCode: line['unitType'] as string | undefined,
+ unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined,
+ lineExtensionAmount: amount(lineTotal, currency),
+ taxPercent: line['taxRate'] as number | undefined,
+ };
+ });
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(totalAmount - totalTax, currency),
+ taxInclusiveAmount: amount(totalAmount, currency),
+ payableAmount: amount(totalAmount, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: paidAmount >= totalAmount && totalAmount > 0,
+ balance: amount(balance, currency),
+ };
+
+ return {
+ id: String(raw['id'] ?? ''),
+ invoiceNumber: String(raw['invoiceNumber'] ?? raw['id'] ?? ''),
+ issueDate: (raw['invoiceDate'] as string) ?? '',
+ dueDate: raw['dueDate'] as string | undefined,
+ currencyCode: currency,
+ status: deriveInvoiceStatus(raw),
+ supplier: buildParty(''),
+ customer: buildParty(
+ (customerRef?.['name'] as string) ?? '',
+ ),
+ lines,
+ legalMonetaryTotal,
+ paymentStatus,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map Bokio Customer to CustomerDto.
+ *
+ * Bokio Customer fields:
+ * - id, name, type (company|individual), orgNumber, vatNumber, paymentTerms
+ * - address: { line1, line2, city, postalCode, country }
+ * - contactsDetails: [{ email, phone, name }]
+ */
+export function mapBokioToCustomer(raw: Record): CustomerDto {
+ const name = (raw['name'] as string) ?? '';
+ const orgNumber = raw['orgNumber'] as string | undefined;
+ const address = raw['address'] as Record | undefined;
+ const contacts = (raw['contactsDetails'] as Record[] | undefined) ?? [];
+ const firstContact = contacts[0];
+
+ const party = buildParty(name, orgNumber, address);
+ if (firstContact) {
+ party.contact = {
+ email: firstContact['email'] as string | undefined,
+ telephone: firstContact['phone'] as string | undefined,
+ name: firstContact['name'] as string | undefined,
+ };
+ }
+
+ return {
+ id: String(raw['id'] ?? ''),
+ customerNumber: String(raw['id'] ?? ''),
+ type: raw['type'] === 'individual' ? 'private' : 'company',
+ party,
+ active: true,
+ vatNumber: raw['vatNumber'] as string | undefined,
+ defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map Bokio JournalEntry to JournalDto.
+ *
+ * Bokio JournalEntry fields:
+ * - id, date, title, number (int), createdAt
+ * - items: [{ accountNumber (int), debit, credit, description }]
+ */
+export function mapBokioToJournal(raw: Record): JournalDto {
+ const rawItems = (raw['items'] as Record[] | undefined) ?? [];
+ const entries: AccountingEntryDto[] = rawItems.map((item) => ({
+ accountNumber: String(item['account'] ?? item['accountNumber'] ?? ''),
+ debit: (item['debit'] as number) ?? 0,
+ credit: (item['credit'] as number) ?? 0,
+ description: item['description'] as string | undefined,
+ }));
+
+ return {
+ id: String(raw['id'] ?? ''),
+ journalNumber: String(raw['journalEntryNumber'] ?? raw['number'] ?? raw['id'] ?? ''),
+ description: raw['title'] as string | undefined,
+ registrationDate: (raw['date'] as string) ?? '',
+ entries,
+ createdAt: raw['createdAt'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map Bokio Account to AccountingAccountDto.
+ *
+ * Bokio Account fields:
+ * - number (int, used as ID), name, category (asset|liability|income|cost), isActive
+ */
+export function mapBokioToAccountingAccount(raw: Record): AccountingAccountDto {
+ // Bokio uses 'account' (int) as field name, not 'number' or 'accountNumber'
+ const rawNum = raw['account'] ?? raw['accountNumber'] ?? raw['number'];
+ const num = Number(rawNum);
+
+ // Bokio returns accountType: 'basePlanAccount' — derive type from BAS plan number range
+ let type: AccountType | undefined;
+ if (num >= 1000 && num < 2000) type = 'asset';
+ else if (num >= 2000 && num < 3000) type = 'liability';
+ else if (num >= 3000 && num < 4000) type = 'revenue';
+ else if (num >= 4000 && num < 9000) type = 'expense';
+
+ return {
+ accountNumber: String(rawNum ?? ''),
+ name: (raw['name'] as string) ?? '',
+ type,
+ active: raw['isActive'] !== false,
+ balanceCarriedForward: raw['accountBalance'] != null ? Number(raw['accountBalance']) : undefined,
+ _raw: raw,
+ };
+}
+
+/**
+ * Map Bokio Company to CompanyInformationDto.
+ *
+ * Bokio Company fields:
+ * - id, name, orgNumber, vatNumber, currency, country
+ * - address: { line1, line2, city, postalCode, country }
+ */
+export function mapBokioToCompanyInformation(raw: Record): CompanyInformationDto {
+ const address = raw['address'] as Record | undefined;
+
+ return {
+ companyName: (raw['name'] as string) ?? '',
+ organizationNumber: raw['orgNumber'] as string | undefined,
+ legalEntity: {
+ registrationName: (raw['name'] as string) ?? '',
+ companyId: raw['orgNumber'] as string | undefined,
+ companyIdSchemeId: 'SE:ORGNR',
+ },
+ address: address ? {
+ streetName: address['line1'] as string | undefined,
+ additionalStreetName: address['line2'] as string | undefined,
+ cityName: address['city'] as string | undefined,
+ postalZone: address['postalCode'] as string | undefined,
+ countryCode: address['country'] as string | undefined,
+ } : undefined,
+ vatNumber: raw['vatNumber'] as string | undefined,
+ baseCurrency: raw['currency'] as string | undefined,
+ _raw: raw,
+ };
+}
diff --git a/lib/providers/bokio/oauth.ts b/lib/providers/bokio/oauth.ts
new file mode 100644
index 00000000..dac68554
--- /dev/null
+++ b/lib/providers/bokio/oauth.ts
@@ -0,0 +1,10 @@
+import type { TokenResponse } from '../types';
+
+export function storeBokioToken(apiToken: string): TokenResponse {
+ return {
+ access_token: apiToken,
+ refresh_token: '',
+ token_type: 'Bearer',
+ expires_in: 0,
+ };
+}
diff --git a/lib/providers/briox/client.ts b/lib/providers/briox/client.ts
new file mode 100644
index 00000000..e6ce890f
--- /dev/null
+++ b/lib/providers/briox/client.ts
@@ -0,0 +1,159 @@
+import { TokenBucketRateLimiter } from '../rate-limiter';
+import { withRetry } from '../retry';
+import { BRIOX_BASE_URL, BRIOX_RATE_LIMIT } from './config';
+
+export class BrioxApiError extends Error {
+ constructor(
+ message: string,
+ public readonly statusCode: number,
+ public readonly body?: string,
+ ) {
+ super(message);
+ this.name = 'BrioxApiError';
+ }
+}
+
+function isRetryableError(error: unknown): boolean {
+ if (error instanceof BrioxApiError) {
+ if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
+ return false;
+ }
+ return error.statusCode === 429 || error.statusCode >= 500;
+ }
+ return false;
+}
+
+interface BrioxListResponse {
+ data: Record & {
+ metainformation?: {
+ total_pages: number;
+ current_page: number;
+ total_count: number;
+ };
+ };
+}
+
+export class BrioxClient {
+ private readonly rateLimiter: TokenBucketRateLimiter;
+ private readonly baseUrl: string;
+
+ constructor(baseUrl?: string) {
+ this.baseUrl = baseUrl ?? BRIOX_BASE_URL;
+ this.rateLimiter = new TokenBucketRateLimiter(BRIOX_RATE_LIMIT, 'ratelimit:briox');
+ }
+
+ async get(accessToken: string, path: string): Promise {
+ return withRetry(
+ async () => {
+ await this.rateLimiter.acquire();
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ headers: {
+ Authorization: accessToken,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new BrioxApiError(
+ `Briox API error: ${response.status} ${response.statusText}`,
+ response.status,
+ body,
+ );
+ }
+
+ return response.json() as Promise;
+ },
+ {
+ maxAttempts: 3,
+ initialDelayMs: 1000,
+ shouldRetry: isRetryableError,
+ },
+ );
+ }
+
+ async getPage(
+ accessToken: string,
+ path: string,
+ listKey: string,
+ options?: {
+ page?: number;
+ pageSize?: number;
+ fromModifiedDate?: string;
+ },
+ ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
+ const params = new URLSearchParams();
+ params.set('page', String(options?.page ?? 1));
+ if (options?.pageSize) {
+ params.set('limit', String(options.pageSize));
+ }
+ if (options?.fromModifiedDate) {
+ params.set('frommodifieddate', options.fromModifiedDate);
+ }
+
+ const separator = path.includes('?') ? '&' : '?';
+ const fullPath = `${path}${separator}${params.toString()}`;
+
+ const response = await this.get(accessToken, fullPath);
+
+ const meta = response.data?.metainformation;
+ const totalPages = meta?.total_pages ?? 1;
+ const currentPage = meta?.current_page ?? (options?.page ?? 1);
+ const totalCount = meta?.total_count ?? 0;
+
+ const items = listKey ? response.data?.[listKey] : response.data;
+
+ return {
+ items: Array.isArray(items) ? items as T[] : [],
+ page: currentPage,
+ totalPages,
+ totalCount,
+ };
+ }
+
+ async getPaginated(
+ accessToken: string,
+ path: string,
+ listKey: string,
+ options?: {
+ fromModifiedDate?: string;
+ pageSize?: number;
+ },
+ ): Promise {
+ const allItems: T[] = [];
+ let page = 1;
+ let totalPages = 1;
+
+ do {
+ const result = await this.getPage(accessToken, path, listKey, {
+ page,
+ pageSize: options?.pageSize,
+ fromModifiedDate: options?.fromModifiedDate,
+ });
+
+ allItems.push(...result.items);
+ totalPages = result.totalPages;
+ page++;
+ } while (page <= totalPages);
+
+ return allItems;
+ }
+
+ async getCurrentFinancialYear(accessToken: string): Promise {
+ const response = await this.get<{
+ data: {
+ financialyears: { id: string; fromdate: string; todate: string }[];
+ };
+ }>(accessToken, '/financialyear');
+
+ const years = response.data?.financialyears ?? [];
+ if (years.length === 0) {
+ throw new BrioxApiError('No financial years found in Briox', 404);
+ }
+ const now = new Date().toISOString().slice(0, 10);
+ const completed = years.filter((y) => y.todate < now);
+ return completed.length > 0 ? completed[completed.length - 1]!.id : years[0]!.id;
+ }
+}
diff --git a/lib/providers/briox/config.ts b/lib/providers/briox/config.ts
new file mode 100644
index 00000000..47486b8c
--- /dev/null
+++ b/lib/providers/briox/config.ts
@@ -0,0 +1,79 @@
+import { ResourceType } from '../dto';
+import type { BrioxResourceConfig, RateLimitConfig } from '../types';
+import {
+ mapBrioxToSalesInvoice,
+ mapBrioxToSupplierInvoice,
+ mapBrioxToCustomer,
+ mapBrioxToSupplier,
+ mapBrioxToJournal,
+ mapBrioxToAccountingAccount,
+ mapBrioxToCompanyInformation,
+} from './mapper';
+
+export const BRIOX_BASE_URL = 'https://api-se.briox.services/v2';
+export const BRIOX_TOKEN_URL = 'https://api-se.briox.services/v2/token';
+export const BRIOX_REFRESH_URL = 'https://api-se.briox.services/v2/tokenrefresh';
+export const BRIOX_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 };
+
+export const BRIOX_RESOURCE_CONFIGS: Partial> = {
+ [ResourceType.SalesInvoices]: {
+ listEndpoint: '/customerinvoice',
+ detailEndpoint: '/customerinvoice/{id}',
+ listKey: 'invoices',
+ idField: 'id',
+ mapper: mapBrioxToSalesInvoice,
+ supportsModifiedFilter: true,
+ },
+ [ResourceType.SupplierInvoices]: {
+ listEndpoint: '/supplierinvoice',
+ detailEndpoint: '/supplierinvoice/{id}',
+ listKey: 'supplierinvoices',
+ idField: 'id',
+ mapper: mapBrioxToSupplierInvoice,
+ supportsModifiedFilter: true,
+ },
+ [ResourceType.Customers]: {
+ listEndpoint: '/customer',
+ detailEndpoint: '/customer/{id}',
+ listKey: 'customers',
+ idField: 'id',
+ mapper: mapBrioxToCustomer,
+ supportsModifiedFilter: true,
+ },
+ [ResourceType.Suppliers]: {
+ listEndpoint: '/supplier',
+ detailEndpoint: '/supplier/{id}',
+ listKey: 'suppliers',
+ idField: 'id',
+ mapper: mapBrioxToSupplier,
+ supportsModifiedFilter: true,
+ },
+ [ResourceType.Journals]: {
+ listEndpoint: '/journal',
+ detailEndpoint: '/journal/{id}',
+ listKey: 'journals',
+ idField: 'id',
+ mapper: mapBrioxToJournal,
+ supportsModifiedFilter: false,
+ yearScoped: true,
+ supportsEntryHydration: true,
+ detailKey: 'journal',
+ },
+ [ResourceType.AccountingAccounts]: {
+ listEndpoint: '/account',
+ detailEndpoint: '/account/{id}',
+ listKey: 'accounts',
+ idField: 'id',
+ mapper: mapBrioxToAccountingAccount,
+ supportsModifiedFilter: false,
+ },
+ [ResourceType.CompanyInformation]: {
+ listEndpoint: '/user/info',
+ detailEndpoint: '/user/info',
+ listKey: '',
+ idField: 'id',
+ mapper: mapBrioxToCompanyInformation,
+ supportsModifiedFilter: false,
+ singleton: true,
+ },
+};
diff --git a/lib/providers/briox/mapper.ts b/lib/providers/briox/mapper.ts
new file mode 100644
index 00000000..12027dd3
--- /dev/null
+++ b/lib/providers/briox/mapper.ts
@@ -0,0 +1,275 @@
+import type {
+ SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
+ LegalMonetaryTotalDto, PaymentStatusDto,
+ SupplierInvoiceDto, SupplierInvoiceLineDto,
+ CustomerDto, SupplierDto,
+ JournalDto, AccountingEntryDto,
+ AccountingAccountDto, AccountType,
+ CompanyInformationDto,
+ AmountType, PartyDto,
+} from '../dto';
+
+function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
+ return { value: value ?? 0, currencyCode: currency };
+}
+
+function deriveInvoiceStatus(raw: Record): InvoiceStatusCode {
+ const status = raw['status'] as string | undefined;
+ if (status === 'cancelled') return 'cancelled';
+ if (status === 'credited') return 'credited';
+ if (status === 'paid' || raw['fully_paid'] === true) return 'paid';
+ if (status === 'booked' || raw['booked'] === true) return 'booked';
+ if (status === 'sent' || raw['sent'] === true) return 'sent';
+ if (status === 'overdue') return 'overdue';
+ return 'draft';
+}
+
+function buildParty(name: string, orgNumber?: string, raw?: Record): PartyDto {
+ return {
+ name,
+ identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
+ postalAddress: raw ? {
+ streetName: (raw['address1'] ?? raw['address']) as string | undefined,
+ additionalStreetName: raw['address2'] as string | undefined,
+ cityName: raw['city'] as string | undefined,
+ postalZone: (raw['zip_code'] ?? raw['postal_code']) as string | undefined,
+ countryCode: raw['country'] as string | undefined,
+ } : undefined,
+ legalEntity: orgNumber ? {
+ registrationName: name,
+ companyId: orgNumber,
+ companyIdSchemeId: 'SE:ORGNR',
+ } : undefined,
+ contact: {
+ email: raw?.['email'] as string | undefined,
+ telephone: raw?.['phone'] as string | undefined,
+ },
+ };
+}
+
+export function mapBrioxToSalesInvoice(raw: Record): SalesInvoiceDto {
+ const currency = (raw['currency_code'] as string) ?? 'SEK';
+ const total = raw['total_amount'] as number ?? 0;
+ const balance = raw['balance'] as number ?? 0;
+
+ const rows = (raw['rows'] as Record[] | undefined) ?? [];
+ const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
+ id: String(row['id'] ?? idx + 1),
+ description: row['description'] as string | undefined,
+ quantity: row['quantity'] as number | undefined,
+ unitCode: row['unit'] as string | undefined,
+ unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
+ lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
+ taxPercent: row['vat_rate'] as number | undefined,
+ accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
+ articleNumber: row['article_number'] as string | undefined,
+ itemName: row['description'] as string | undefined,
+ }));
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
+ taxInclusiveAmount: amount(total, currency),
+ payableAmount: amount(total, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: balance === 0 && total > 0,
+ balance: amount(balance, currency),
+ };
+
+ return {
+ id: String(raw['id'] ?? ''),
+ invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''),
+ issueDate: (raw['invoice_date'] as string) ?? '',
+ dueDate: raw['due_date'] as string | undefined,
+ currencyCode: currency,
+ status: deriveInvoiceStatus(raw),
+ supplier: buildParty(''),
+ customer: buildParty(
+ (raw['customer_name'] ?? '') as string,
+ raw['customer_org_number'] as string | undefined,
+ ),
+ lines,
+ legalMonetaryTotal,
+ paymentStatus,
+ paymentTerms: raw['payment_terms'] as string | undefined,
+ note: raw['remarks'] as string | undefined,
+ buyerReference: raw['your_reference'] as string | undefined,
+ orderReference: raw['your_order_number'] as string | undefined,
+ updatedAt: raw['modified_date'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapBrioxToSupplierInvoice(raw: Record): SupplierInvoiceDto {
+ const currency = (raw['currency_code'] as string) ?? 'SEK';
+ const total = raw['total_amount'] as number ?? 0;
+ const balance = raw['balance'] as number ?? 0;
+
+ const rows = (raw['rows'] as Record[] | undefined) ?? [];
+ const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
+ id: String(row['id'] ?? idx + 1),
+ description: row['description'] as string | undefined,
+ quantity: row['quantity'] as number | undefined,
+ unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
+ lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
+ accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
+ }));
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
+ taxInclusiveAmount: amount(total, currency),
+ payableAmount: amount(total, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: balance === 0 && total > 0,
+ balance: amount(balance, currency),
+ };
+
+ return {
+ id: String(raw['id'] ?? ''),
+ invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''),
+ issueDate: (raw['invoice_date'] as string) ?? '',
+ dueDate: raw['due_date'] as string | undefined,
+ currencyCode: currency,
+ status: deriveInvoiceStatus(raw),
+ supplier: buildParty(
+ (raw['supplier_name'] ?? '') as string,
+ raw['supplier_org_number'] as string | undefined,
+ ),
+ buyer: buildParty(''),
+ lines,
+ legalMonetaryTotal,
+ paymentStatus,
+ ocrNumber: raw['ocr'] as string | undefined,
+ updatedAt: raw['modified_date'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapBrioxToCustomer(raw: Record): CustomerDto {
+ const name = (raw['name'] as string) ?? '';
+ const orgNumber = raw['org_number'] as string | undefined;
+
+ return {
+ id: String(raw['id'] ?? ''),
+ customerNumber: String(raw['customer_number'] ?? raw['id'] ?? ''),
+ type: raw['type'] === 'private' ? 'private' : 'company',
+ party: buildParty(name, orgNumber, raw),
+ active: raw['active'] !== false,
+ vatNumber: raw['vat_number'] as string | undefined,
+ defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined,
+ note: raw['note'] as string | undefined,
+ updatedAt: raw['modified_date'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapBrioxToSupplier(raw: Record): SupplierDto {
+ const name = (raw['name'] as string) ?? '';
+ const orgNumber = raw['org_number'] as string | undefined;
+
+ return {
+ id: String(raw['id'] ?? ''),
+ supplierNumber: String(raw['supplier_number'] ?? raw['id'] ?? ''),
+ party: buildParty(name, orgNumber, raw),
+ active: raw['active'] !== false,
+ vatNumber: raw['vat_number'] as string | undefined,
+ bankAccount: raw['bank_account'] as string | undefined,
+ bankGiro: raw['bank_giro'] as string | undefined,
+ plusGiro: raw['plus_giro'] as string | undefined,
+ defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined,
+ note: raw['note'] as string | undefined,
+ updatedAt: raw['modified_date'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapBrioxToJournal(raw: Record): JournalDto {
+ // Briox detail API returns rows as "journal_rows" (list endpoint omits them)
+ const rows = (raw['journal_rows'] as Record[] | undefined)
+ ?? (raw['journalrows'] as Record[] | undefined) ?? [];
+ const entries: AccountingEntryDto[] = rows.map((row) => ({
+ // Briox uses "account" (not "account_number") for the account field
+ accountNumber: String(row['account'] ?? row['account_number'] ?? ''),
+ accountName: row['account_name'] as string | undefined,
+ // Briox returns debit/credit as strings
+ debit: Number(row['debit'] ?? 0),
+ credit: Number(row['credit'] ?? 0),
+ transactionDate: (row['transactiondate'] ?? row['transaction_date']) as string | undefined,
+ description: (row['transactioninfo'] ?? row['description']) as string | undefined,
+ }));
+
+ return {
+ id: String(raw['id'] ?? ''),
+ journalNumber: String(raw['id'] ?? raw['journal_number'] ?? ''),
+ series: raw['series'] ? {
+ id: String(raw['series']),
+ } : undefined,
+ // Briox uses "descr" for the journal description
+ description: (raw['descr'] ?? raw['description']) as string | undefined,
+ // Briox uses "transactiondate" for the date
+ registrationDate: ((raw['transactiondate'] ?? raw['journal_date'] ?? raw['date']) as string) ?? '',
+ fiscalYear: raw['year'] != null ? Number(raw['year']) : (raw['financial_year'] != null ? Number(raw['financial_year']) : undefined),
+ entries,
+ _raw: raw,
+ };
+}
+
+export function mapBrioxToAccountingAccount(raw: Record): AccountingAccountDto {
+ // Briox uses "id" as the account number field
+ const num = Number(raw['id'] ?? raw['account_number'] ?? raw['number']);
+ let type: AccountType | undefined;
+ if (num >= 1000 && num < 2000) type = 'asset';
+ else if (num >= 2000 && num < 3000) type = 'liability';
+ else if (num >= 3000 && num < 4000) type = 'revenue';
+ else if (num >= 4000 && num < 9000) type = 'expense';
+
+ return {
+ accountNumber: String(raw['id'] ?? raw['account_number'] ?? raw['number'] ?? ''),
+ // Briox uses "description" for the account name
+ name: ((raw['description'] ?? raw['name']) as string) ?? '',
+ type,
+ // Briox returns active as "1"/"0" strings
+ active: raw['active'] !== false && raw['active'] !== '0' && raw['active'] !== 0,
+ vatCode: raw['vat_code'] != null ? String(raw['vat_code']) : undefined,
+ // Briox uses "incoming_balance" for opening balance
+ balanceCarriedForward: raw['incoming_balance'] != null ? Number(raw['incoming_balance']) : undefined,
+ _raw: raw,
+ };
+}
+
+export function mapBrioxToCompanyInformation(raw: Record): CompanyInformationDto {
+ // /user/info returns { info: { company_name, accounts: [...] } }
+ const info = (raw['info'] as Record | undefined) ?? raw;
+ const accounts = (info['accounts'] as Record[] | undefined) ?? [];
+ const account = accounts[0] as Record | undefined;
+ const addr = account?.['address'] as Record | undefined;
+
+ const companyName = (info['company_name'] ?? account?.['database_label'] ?? '') as string;
+ const orgNumber = account?.['organization_number'] as string | undefined;
+
+ return {
+ companyName,
+ organizationNumber: orgNumber,
+ legalEntity: {
+ registrationName: companyName,
+ companyId: orgNumber,
+ companyIdSchemeId: 'SE:ORGNR',
+ },
+ address: {
+ streetName: addr?.['addressline1'] as string | undefined,
+ additionalStreetName: addr?.['addressline2'] as string | undefined,
+ cityName: addr?.['city'] as string | undefined,
+ postalZone: addr?.['zip'] as string | undefined,
+ countryCode: (addr?.['countrycode'] ?? addr?.['country']) as string | undefined,
+ },
+ contact: {
+ email: (account?.['email'] ?? info['email']) as string | undefined,
+ telephone: (account?.['phone'] ?? info['phone']) as string | undefined,
+ website: account?.['website'] as string | undefined,
+ },
+ _raw: raw,
+ };
+}
diff --git a/lib/providers/briox/oauth.ts b/lib/providers/briox/oauth.ts
new file mode 100644
index 00000000..2081abf5
--- /dev/null
+++ b/lib/providers/briox/oauth.ts
@@ -0,0 +1,68 @@
+import { BRIOX_TOKEN_URL, BRIOX_REFRESH_URL } from './config';
+import type { TokenResponse } from '../types';
+
+interface BrioxTokenData {
+ access_token: string;
+ refresh_token: string;
+ client_id: number;
+ expire_date: string;
+ expire_timestamp: number;
+}
+
+interface BrioxTokenApiResponse {
+ data: BrioxTokenData;
+}
+
+function toTokenResponse(brioxData: BrioxTokenData): TokenResponse {
+ const expiresIn = brioxData.expire_timestamp - Math.floor(Date.now() / 1000);
+ return {
+ access_token: brioxData.access_token,
+ refresh_token: brioxData.refresh_token,
+ token_type: 'Bearer',
+ expires_in: expiresIn > 0 ? expiresIn : 3600,
+ };
+}
+
+export async function exchangeBrioxCode(
+ clientId: string,
+ applicationToken: string,
+): Promise {
+ const url = `${BRIOX_TOKEN_URL}?clientid=${encodeURIComponent(clientId)}&token=${encodeURIComponent(applicationToken)}`;
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Briox token exchange failed: ${response.status} ${body}`);
+ }
+
+ const result = await response.json() as BrioxTokenApiResponse;
+ return toTokenResponse(result.data);
+}
+
+export async function refreshBrioxToken(
+ clientId: string,
+ refreshToken: string,
+): Promise {
+ const url = `${BRIOX_REFRESH_URL}?refreshtoken=${encodeURIComponent(refreshToken)}&token=${encodeURIComponent(refreshToken)}`;
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Briox token refresh failed: ${response.status} ${body}`);
+ }
+
+ const result = await response.json() as BrioxTokenApiResponse;
+ return toTokenResponse(result.data);
+}
diff --git a/lib/providers/dto.ts b/lib/providers/dto.ts
new file mode 100644
index 00000000..9d2bfed2
--- /dev/null
+++ b/lib/providers/dto.ts
@@ -0,0 +1,448 @@
+// Canonical DTO types for provider data normalization
+
+// ============================================
+// Resource Type
+// ============================================
+
+export const ResourceType = {
+ SalesInvoices: 'salesinvoices',
+ SupplierInvoices: 'supplierinvoices',
+ Customers: 'customers',
+ Suppliers: 'suppliers',
+ Journals: 'journals',
+ AccountingAccounts: 'accountingaccounts',
+ CompanyInformation: 'companyinformation',
+ AccountingPeriods: 'accountingperiods',
+ FinancialDimensions: 'financialdimensions',
+ BalanceSheet: 'balancesheet',
+ IncomeStatement: 'incomestatement',
+ TrialBalances: 'trialbalances',
+ Payments: 'payments',
+ Attachments: 'attachments',
+} as const;
+
+export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType];
+
+// ============================================
+// Common
+// ============================================
+
+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 FinancialDimensionRef {
+ dimensionId: string;
+ dimensionValueId: string;
+ name?: string;
+}
+
+export interface AllowanceChargeDto {
+ chargeIndicator: boolean;
+ reason?: string;
+ amount: AmountType;
+ taxPercent?: number;
+}
+
+export interface TaxTotalDto {
+ taxAmount: AmountType;
+ taxSubtotals?: TaxSubtotalDto[];
+}
+
+export interface TaxSubtotalDto {
+ taxableAmount: AmountType;
+ taxAmount: AmountType;
+ taxCategory?: string;
+ percent?: number;
+}
+
+export interface PaginatedResponse {
+ data: T[];
+ page: number;
+ pageSize: number;
+ totalCount: number;
+ hasMore: boolean;
+}
+
+// ============================================
+// Sales Invoice
+// ============================================
+
+export type InvoiceStatusCode = 'draft' | 'sent' | 'booked' | 'paid' | 'overdue' | 'cancelled' | 'credited';
+
+export interface LegalMonetaryTotalDto {
+ lineExtensionAmount: AmountType;
+ taxExclusiveAmount?: AmountType;
+ taxInclusiveAmount?: AmountType;
+ allowanceTotalAmount?: AmountType;
+ chargeTotalAmount?: AmountType;
+ payableRoundingAmount?: AmountType;
+ payableAmount: AmountType;
+}
+
+export interface PaymentStatusDto {
+ paid: boolean;
+ balance: AmountType;
+ lastPaymentDate?: string;
+}
+
+export interface SalesInvoiceLineDto {
+ id: string;
+ description?: string;
+ quantity?: number;
+ unitCode?: string;
+ unitPrice?: AmountType;
+ lineExtensionAmount: AmountType;
+ taxPercent?: number;
+ taxAmount?: AmountType;
+ accountNumber?: string;
+ itemName?: string;
+ articleNumber?: string;
+ financialDimensions?: FinancialDimensionRef[];
+}
+
+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[];
+ allowanceCharges?: AllowanceChargeDto[];
+ taxTotal?: TaxTotalDto;
+ legalMonetaryTotal: LegalMonetaryTotalDto;
+ paymentStatus: PaymentStatusDto;
+ paymentTerms?: string;
+ note?: string;
+ buyerReference?: string;
+ orderReference?: string;
+ financialDimensions?: FinancialDimensionRef[];
+ createdAt?: string;
+ updatedAt?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// 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;
+ articleNumber?: string;
+ financialDimensions?: FinancialDimensionRef[];
+}
+
+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[];
+ allowanceCharges?: AllowanceChargeDto[];
+ taxTotal?: TaxTotalDto;
+ legalMonetaryTotal: LegalMonetaryTotalDto;
+ paymentStatus: PaymentStatusDto;
+ paymentTerms?: string;
+ note?: string;
+ ocrNumber?: string;
+ financialDimensions?: FinancialDimensionRef[];
+ createdAt?: string;
+ updatedAt?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Customer
+// ============================================
+
+export type CustomerType = 'company' | 'private';
+
+export interface CustomerDto {
+ id: string;
+ customerNumber: string;
+ type?: CustomerType;
+ party: PartyDto;
+ deliveryAddresses?: PostalAddress[];
+ financialDimensions?: FinancialDimensionRef[];
+ active: boolean;
+ vatNumber?: string;
+ defaultPaymentTermsDays?: number;
+ note?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Supplier
+// ============================================
+
+export interface SupplierDto {
+ id: string;
+ supplierNumber: string;
+ party: PartyDto;
+ deliveryAddresses?: PostalAddress[];
+ financialDimensions?: FinancialDimensionRef[];
+ active: boolean;
+ vatNumber?: string;
+ bankAccount?: string;
+ bankGiro?: string;
+ plusGiro?: string;
+ defaultPaymentTermsDays?: number;
+ note?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Journal
+// ============================================
+
+export interface AccountingEntryDto {
+ accountNumber: string;
+ accountName?: string;
+ debit: number;
+ credit: number;
+ transactionDate?: string;
+ description?: string;
+ financialDimensions?: FinancialDimensionRef[];
+}
+
+export interface AccountingSeriesDto {
+ id: string;
+ description?: string;
+}
+
+export interface JournalDto {
+ id: string;
+ journalNumber: string;
+ series?: AccountingSeriesDto;
+ description?: string;
+ registrationDate: string;
+ fiscalYear?: number;
+ entries: AccountingEntryDto[];
+ totalDebit?: AmountType;
+ totalCredit?: AmountType;
+ createdAt?: string;
+ updatedAt?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Accounting Account
+// ============================================
+
+export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'other';
+
+export interface AccountingAccountDto {
+ accountNumber: string;
+ name: string;
+ description?: string;
+ type?: AccountType;
+ vatCode?: string;
+ active: boolean;
+ balanceBroughtForward?: number;
+ balanceCarriedForward?: number;
+ sruCode?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Company Information
+// ============================================
+
+export interface CompanyInformationDto {
+ companyName: string;
+ organizationNumber?: string;
+ legalEntity?: PartyLegalEntity;
+ address?: PostalAddress;
+ contact?: Contact;
+ vatNumber?: string;
+ fiscalYearStart?: string;
+ baseCurrency?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Payment
+// ============================================
+
+export type PaymentMethodCode = 'bank_transfer' | 'card' | 'cash' | 'autogiro' | 'bankgiro' | 'plusgiro' | 'swish' | 'other';
+
+export interface PaymentDto {
+ id: string;
+ paymentNumber?: string;
+ invoiceId: string;
+ paymentDate: string;
+ amount: AmountType;
+ paymentMethod?: PaymentMethodCode;
+ reference?: string;
+ note?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Accounting Period
+// ============================================
+
+export type PeriodStatus = 'open' | 'closed' | 'locked';
+
+export interface AccountingPeriodDto {
+ id: string;
+ fiscalYear: number;
+ fromDate: string;
+ toDate: string;
+ status?: PeriodStatus;
+ description?: string;
+ _raw?: Record;
+}
+
+// ============================================
+// Financial Dimension
+// ============================================
+
+export interface FinancialDimensionValueDto {
+ id: string;
+ code: string;
+ name: string;
+ active: boolean;
+}
+
+export interface FinancialDimensionDto {
+ id: string;
+ name: string;
+ description?: string;
+ values: FinancialDimensionValueDto[];
+ _raw?: Record;
+}
+
+// ============================================
+// Reports
+// ============================================
+
+export interface FinancialReportCategoryDto {
+ name: string;
+ amount: AmountType;
+ children?: FinancialReportCategoryDto[];
+ accounts?: { accountNumber: string; name?: string; amount: AmountType }[];
+}
+
+export interface BalanceSheetDto {
+ fiscalYear: number;
+ periodEnd: string;
+ baseCurrency: string;
+ assets: FinancialReportCategoryDto;
+ liabilities: FinancialReportCategoryDto;
+ equity: FinancialReportCategoryDto;
+ _raw?: Record;
+}
+
+export interface IncomeStatementDto {
+ fiscalYear: number;
+ periodStart: string;
+ periodEnd: string;
+ baseCurrency: string;
+ revenue: FinancialReportCategoryDto;
+ expenses: FinancialReportCategoryDto;
+ netIncome: AmountType;
+ _raw?: Record;
+}
+
+export interface TrialBalanceEntryDto {
+ accountNumber: string;
+ accountName?: string;
+ openingDebit: number;
+ openingCredit: number;
+ periodDebit: number;
+ periodCredit: number;
+ closingDebit: number;
+ closingCredit: number;
+}
+
+export interface TrialBalanceDto {
+ fiscalYear: number;
+ periodStart: string;
+ periodEnd: string;
+ baseCurrency: string;
+ entries: TrialBalanceEntryDto[];
+ _raw?: Record;
+}
+
+// ============================================
+// Attachment
+// ============================================
+
+export type AttachmentType = 'pdf' | 'image' | 'xml' | 'other';
+
+export interface AttachmentDto {
+ id: string;
+ fileName: string;
+ mimeType?: string;
+ type?: AttachmentType;
+ size?: number;
+ downloadUrl?: string;
+ createdAt?: string;
+ _raw?: Record;
+}
diff --git a/lib/providers/fortnox/client.ts b/lib/providers/fortnox/client.ts
new file mode 100644
index 00000000..9e2109b6
--- /dev/null
+++ b/lib/providers/fortnox/client.ts
@@ -0,0 +1,185 @@
+import { TokenBucketRateLimiter } from '../rate-limiter';
+import { withRetry } from '../retry';
+import { FORTNOX_BASE_URL, FORTNOX_RATE_LIMIT } from './config';
+
+export class FortnoxApiError extends Error {
+ constructor(
+ message: string,
+ public readonly statusCode: number,
+ public readonly body?: string,
+ public readonly retryAfterMs?: number,
+ ) {
+ super(message);
+ this.name = 'FortnoxApiError';
+ }
+}
+
+function isRetryableError(error: unknown): boolean {
+ if (error instanceof FortnoxApiError) {
+ if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
+ return false;
+ }
+ return error.statusCode === 429 || error.statusCode >= 500;
+ }
+ return false;
+}
+
+export class FortnoxClient {
+ private readonly rateLimiter: TokenBucketRateLimiter;
+ private readonly baseUrl: string;
+
+ constructor(baseUrl?: string) {
+ this.baseUrl = baseUrl ?? FORTNOX_BASE_URL;
+ this.rateLimiter = new TokenBucketRateLimiter(FORTNOX_RATE_LIMIT, 'ratelimit:fortnox');
+ }
+
+ async get(accessToken: string, path: string): Promise {
+ return withRetry(
+ async () => {
+ await this.rateLimiter.acquire();
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ let retryAfterMs: number | undefined;
+ if (response.status === 429) {
+ const retryAfter = response.headers.get('Retry-After');
+ retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
+ }
+ throw new FortnoxApiError(
+ `Fortnox API error: ${response.status} ${response.statusText}`,
+ response.status,
+ body,
+ retryAfterMs,
+ );
+ }
+
+ return response.json() as Promise;
+ },
+ {
+ maxAttempts: 6,
+ initialDelayMs: 2000,
+ maxDelayMs: 60_000,
+ shouldRetry: isRetryableError,
+ getDelayMs: (error) => {
+ if (error instanceof FortnoxApiError && error.retryAfterMs) {
+ return error.retryAfterMs;
+ }
+ return undefined;
+ },
+ },
+ );
+ }
+
+ async getText(accessToken: string, path: string): Promise {
+ return withRetry(
+ async () => {
+ await this.rateLimiter.acquire();
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ let retryAfterMs: number | undefined;
+ if (response.status === 429) {
+ const retryAfter = response.headers.get('Retry-After');
+ retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
+ }
+ throw new FortnoxApiError(
+ `Fortnox API error: ${response.status} ${response.statusText}`,
+ response.status,
+ body,
+ retryAfterMs,
+ );
+ }
+
+ return response.text();
+ },
+ {
+ maxAttempts: 6,
+ initialDelayMs: 2000,
+ maxDelayMs: 60_000,
+ shouldRetry: isRetryableError,
+ getDelayMs: (error) => {
+ if (error instanceof FortnoxApiError && error.retryAfterMs) {
+ return error.retryAfterMs;
+ }
+ return undefined;
+ },
+ },
+ );
+ }
+
+ async getPage(
+ accessToken: string,
+ path: string,
+ listKey: string,
+ options?: { page?: number; pageSize?: number; lastModified?: string },
+ ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
+ const params = new URLSearchParams();
+ params.set('page', String(options?.page ?? 1));
+ if (options?.pageSize) {
+ params.set('limit', String(options.pageSize));
+ }
+ if (options?.lastModified) {
+ params.set('lastmodified', options.lastModified);
+ }
+
+ const separator = path.includes('?') ? '&' : '?';
+ const fullPath = `${path}${separator}${params.toString()}`;
+
+ const response = await this.get>(accessToken, fullPath);
+
+ const meta = response['MetaInformation'] as
+ | { '@TotalPages': number; '@CurrentPage': number; '@TotalResources': number }
+ | undefined;
+
+ const totalPages = meta?.['@TotalPages'] ?? 1;
+ const currentPage = meta?.['@CurrentPage'] ?? 1;
+ const totalCount = meta?.['@TotalResources'] ?? 0;
+
+ const items = response[listKey];
+ return {
+ items: Array.isArray(items) ? (items as T[]) : [],
+ page: currentPage,
+ totalPages,
+ totalCount,
+ };
+ }
+
+ async getPaginated(
+ accessToken: string,
+ path: string,
+ listKey: string,
+ options?: { lastModified?: string; pageSize?: number },
+ ): Promise {
+ const allItems: T[] = [];
+ let page = 1;
+ let totalPages = 1;
+
+ do {
+ const result = await this.getPage(accessToken, path, listKey, {
+ page,
+ pageSize: options?.pageSize,
+ lastModified: options?.lastModified,
+ });
+
+ allItems.push(...result.items);
+ totalPages = result.totalPages;
+ page++;
+ } while (page <= totalPages);
+
+ return allItems;
+ }
+}
diff --git a/lib/providers/fortnox/config.ts b/lib/providers/fortnox/config.ts
new file mode 100644
index 00000000..4e373cc9
--- /dev/null
+++ b/lib/providers/fortnox/config.ts
@@ -0,0 +1,92 @@
+import { ResourceType } from '../dto';
+import type { FortnoxResourceConfig, RateLimitConfig } from '../types';
+import {
+ mapFortnoxToSalesInvoice,
+ mapFortnoxToSupplierInvoice,
+ mapFortnoxToCustomer,
+ mapFortnoxToSupplier,
+ mapFortnoxToJournal,
+ mapFortnoxToAccountingAccount,
+ mapFortnoxToCompanyInformation,
+} from './mapper';
+
+export const FORTNOX_BASE_URL = 'https://api.fortnox.se/3';
+export const FORTNOX_AUTH_URL = 'https://apps.fortnox.se/oauth-v1/auth';
+export const FORTNOX_TOKEN_URL = 'https://apps.fortnox.se/oauth-v1/token';
+export const FORTNOX_RATE_LIMIT: RateLimitConfig = { maxRequests: 4, windowMs: 1000 };
+
+export const FORTNOX_RESOURCE_CONFIGS: Partial> = {
+ [ResourceType.SalesInvoices]: {
+ listEndpoint: '/invoices',
+ listKey: 'Invoices',
+ detailEndpoint: '/invoices/{id}',
+ detailKey: 'Invoice',
+ idField: 'DocumentNumber',
+ mapper: mapFortnoxToSalesInvoice,
+ supportsLastModified: true,
+ },
+ [ResourceType.SupplierInvoices]: {
+ listEndpoint: '/supplierinvoices',
+ listKey: 'SupplierInvoices',
+ detailEndpoint: '/supplierinvoices/{id}',
+ detailKey: 'SupplierInvoice',
+ idField: 'GivenNumber',
+ mapper: mapFortnoxToSupplierInvoice,
+ supportsLastModified: true,
+ },
+ [ResourceType.Customers]: {
+ listEndpoint: '/customers',
+ listKey: 'Customers',
+ detailEndpoint: '/customers/{id}',
+ detailKey: 'Customer',
+ idField: 'CustomerNumber',
+ mapper: mapFortnoxToCustomer,
+ supportsLastModified: true,
+ },
+ [ResourceType.Suppliers]: {
+ listEndpoint: '/suppliers',
+ listKey: 'Suppliers',
+ detailEndpoint: '/suppliers/{id}',
+ detailKey: 'Supplier',
+ idField: 'SupplierNumber',
+ mapper: mapFortnoxToSupplier,
+ supportsLastModified: true,
+ },
+ [ResourceType.Journals]: {
+ listEndpoint: '/vouchers',
+ listKey: 'Vouchers',
+ detailEndpoint: '/vouchers/{id}',
+ detailKey: 'Voucher',
+ idField: 'VoucherNumber',
+ mapper: mapFortnoxToJournal,
+ supportsLastModified: false,
+ supportsEntryHydration: true,
+ resolveDetailPath: (resourceId, query) => {
+ const dashIdx = resourceId.indexOf('-');
+ const series = dashIdx >= 0 ? resourceId.slice(0, dashIdx) : resourceId;
+ const number = dashIdx >= 0 ? resourceId.slice(dashIdx + 1) : resourceId;
+ const fy = query?.['financialyear'] ?? '';
+ const params = fy ? `?financialyear=${fy}` : '';
+ return `/vouchers/${series}/${number}${params}`;
+ },
+ },
+ [ResourceType.AccountingAccounts]: {
+ listEndpoint: '/accounts',
+ listKey: 'Accounts',
+ detailEndpoint: '/accounts/{id}',
+ detailKey: 'Account',
+ idField: 'Number',
+ mapper: mapFortnoxToAccountingAccount,
+ supportsLastModified: false,
+ },
+ [ResourceType.CompanyInformation]: {
+ listEndpoint: '/companyinformation',
+ listKey: 'CompanyInformation',
+ detailEndpoint: '/companyinformation',
+ detailKey: 'CompanyInformation',
+ idField: 'OrganizationNumber',
+ mapper: mapFortnoxToCompanyInformation,
+ supportsLastModified: false,
+ singleton: true,
+ },
+};
diff --git a/lib/providers/fortnox/mapper.ts b/lib/providers/fortnox/mapper.ts
new file mode 100644
index 00000000..73ab21a3
--- /dev/null
+++ b/lib/providers/fortnox/mapper.ts
@@ -0,0 +1,274 @@
+import type {
+ SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
+ LegalMonetaryTotalDto, PaymentStatusDto,
+ SupplierInvoiceDto, SupplierInvoiceLineDto,
+ CustomerDto, SupplierDto,
+ JournalDto, AccountingEntryDto,
+ AccountingAccountDto, AccountType,
+ CompanyInformationDto,
+ PaymentDto,
+ AmountType, PartyDto,
+} from '../dto';
+
+function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
+ return { value: value ?? 0, currencyCode: currency };
+}
+
+function deriveInvoiceStatus(raw: Record): InvoiceStatusCode {
+ if (raw['Cancelled'] === true) return 'cancelled';
+ if (raw['Credit'] === true) return 'credited';
+ if (raw['FullyPaid'] === true || raw['Balance'] === 0) return 'paid';
+ if (raw['Booked'] === true) return 'booked';
+ if (raw['Sent'] === true) return 'sent';
+ return 'draft';
+}
+
+function buildParty(name: string, orgNumber?: string, address?: Record): PartyDto {
+ return {
+ name,
+ identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
+ postalAddress: address ? {
+ streetName: (address['Address1'] ?? address['Address']) as string | undefined,
+ additionalStreetName: address['Address2'] as string | undefined,
+ cityName: (address['City'] ?? address['CityName']) as string | undefined,
+ postalZone: (address['ZipCode'] ?? address['PostalCode']) as string | undefined,
+ countryCode: address['Country'] as string | undefined,
+ } : undefined,
+ legalEntity: orgNumber ? {
+ registrationName: name,
+ companyId: orgNumber,
+ companyIdSchemeId: 'SE:ORGNR',
+ } : undefined,
+ contact: {
+ email: (address?.['Email'] ?? address?.['EmailInvoice']) as string | undefined,
+ telephone: address?.['Phone1'] as string | undefined,
+ },
+ };
+}
+
+export function mapFortnoxToSalesInvoice(raw: Record): SalesInvoiceDto {
+ const currency = (raw['Currency'] as string) ?? 'SEK';
+ const total = raw['Total'] as number ?? 0;
+ const balance = raw['Balance'] as number ?? 0;
+
+ const rows = (raw['InvoiceRows'] as Record[] | undefined) ?? [];
+ const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
+ id: String(row['RowId'] ?? idx + 1),
+ description: row['Description'] as string | undefined,
+ quantity: row['DeliveredQuantity'] as number | undefined,
+ unitCode: row['Unit'] as string | undefined,
+ unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
+ lineExtensionAmount: amount(row['Total'] as number ?? 0, currency),
+ taxPercent: row['VAT'] as number | undefined,
+ accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
+ articleNumber: row['ArticleNumber'] as string | undefined,
+ itemName: row['Description'] as string | undefined,
+ }));
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
+ taxInclusiveAmount: amount(total, currency),
+ payableAmount: amount(total, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: balance === 0 && total > 0,
+ balance: amount(balance, currency),
+ };
+
+ return {
+ id: String(raw['DocumentNumber'] ?? ''),
+ invoiceNumber: String(raw['DocumentNumber'] ?? ''),
+ issueDate: (raw['InvoiceDate'] as string) ?? '',
+ dueDate: raw['DueDate'] as string | undefined,
+ currencyCode: currency,
+ status: deriveInvoiceStatus(raw),
+ supplier: buildParty(
+ (raw['CompanyName'] ?? '') as string,
+ raw['OrganisationNumber'] as string | undefined,
+ ),
+ customer: buildParty(
+ (raw['CustomerName'] ?? '') as string,
+ raw['OrganisationNumber'] as string | undefined,
+ raw as Record,
+ ),
+ lines,
+ legalMonetaryTotal,
+ paymentStatus,
+ paymentTerms: raw['TermsOfPayment'] as string | undefined,
+ note: raw['Remarks'] as string | undefined,
+ buyerReference: raw['YourReference'] as string | undefined,
+ orderReference: raw['YourOrderNumber'] as string | undefined,
+ updatedAt: raw['@LastModified'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapFortnoxToSupplierInvoice(raw: Record): SupplierInvoiceDto {
+ const currency = (raw['Currency'] as string) ?? 'SEK';
+ const total = raw['Total'] as number ?? 0;
+ const balance = raw['Balance'] as number ?? 0;
+
+ const rows = (raw['SupplierInvoiceRows'] as Record[] | undefined) ?? [];
+ const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
+ id: String(row['RowId'] ?? idx + 1),
+ description: row['Description'] as string | undefined,
+ quantity: row['Quantity'] as number | undefined,
+ unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
+ lineExtensionAmount: amount(row['Total'] as number ?? 0, currency),
+ accountNumber: row['Account'] != null ? String(row['Account']) : undefined,
+ articleNumber: row['ArticleNumber'] as string | undefined,
+ }));
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
+ taxInclusiveAmount: amount(total, currency),
+ payableAmount: amount(total, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: balance === 0 && total > 0,
+ balance: amount(balance, currency),
+ };
+
+ return {
+ id: String(raw['GivenNumber'] ?? ''),
+ invoiceNumber: String(raw['GivenNumber'] ?? ''),
+ issueDate: (raw['InvoiceDate'] as string) ?? '',
+ dueDate: raw['DueDate'] as string | undefined,
+ currencyCode: currency,
+ status: deriveInvoiceStatus(raw),
+ supplier: buildParty(
+ (raw['SupplierName'] ?? '') as string,
+ raw['OrganisationNumber'] as string | undefined,
+ ),
+ buyer: buildParty(''),
+ lines,
+ legalMonetaryTotal,
+ paymentStatus,
+ ocrNumber: raw['OCR'] as string | undefined,
+ updatedAt: raw['@LastModified'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapFortnoxToCustomer(raw: Record): CustomerDto {
+ const name = (raw['Name'] as string) ?? '';
+ const orgNumber = raw['OrganisationNumber'] as string | undefined;
+
+ return {
+ id: String(raw['CustomerNumber'] ?? ''),
+ customerNumber: String(raw['CustomerNumber'] ?? ''),
+ type: raw['Type'] === 'PRIVATE' ? 'private' : 'company',
+ party: buildParty(name, orgNumber, raw),
+ active: raw['Active'] !== false,
+ vatNumber: raw['VATNumber'] as string | undefined,
+ defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined,
+ note: raw['Comments'] as string | undefined,
+ updatedAt: raw['@LastModified'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapFortnoxToSupplier(raw: Record): SupplierDto {
+ const name = (raw['Name'] as string) ?? '';
+ const orgNumber = raw['OrganisationNumber'] as string | undefined;
+
+ return {
+ id: String(raw['SupplierNumber'] ?? ''),
+ supplierNumber: String(raw['SupplierNumber'] ?? ''),
+ party: buildParty(name, orgNumber, raw),
+ active: raw['Active'] !== false,
+ vatNumber: raw['VATNumber'] as string | undefined,
+ bankAccount: raw['BankAccountNumber'] as string | undefined,
+ bankGiro: raw['BG'] as string | undefined,
+ plusGiro: raw['PG'] as string | undefined,
+ defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined,
+ note: raw['Comments'] as string | undefined,
+ updatedAt: raw['@LastModified'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapFortnoxToJournal(raw: Record): JournalDto {
+ const voucherRows = (raw['VoucherRows'] as Record[] | undefined) ?? [];
+ const entries: AccountingEntryDto[] = voucherRows.map((row) => ({
+ accountNumber: String(row['Account'] ?? ''),
+ accountName: row['AccountDescription'] as string | undefined,
+ debit: (row['Debit'] as number) ?? 0,
+ credit: (row['Credit'] as number) ?? 0,
+ transactionDate: row['TransactionDate'] as string | undefined,
+ description: row['Description'] as string | undefined,
+ }));
+
+ return {
+ id: `${raw['VoucherSeries'] ?? ''}-${raw['VoucherNumber'] ?? ''}`,
+ journalNumber: String(raw['VoucherNumber'] ?? ''),
+ series: raw['VoucherSeries'] ? {
+ id: String(raw['VoucherSeries']),
+ description: raw['VoucherSeriesDescription'] as string | undefined,
+ } : undefined,
+ description: raw['Description'] as string | undefined,
+ registrationDate: (raw['TransactionDate'] as string) ?? '',
+ fiscalYear: raw['Year'] != null ? Number(raw['Year']) : undefined,
+ entries,
+ _raw: raw,
+ };
+}
+
+export function mapFortnoxToAccountingAccount(raw: Record): AccountingAccountDto {
+ let type: AccountType | undefined;
+ const num = Number(raw['Number']);
+ if (num >= 1000 && num < 2000) type = 'asset';
+ else if (num >= 2000 && num < 3000) type = 'liability';
+ else if (num >= 3000 && num < 4000) type = 'revenue';
+ else if (num >= 4000 && num < 9000) type = 'expense';
+
+ return {
+ accountNumber: String(raw['Number'] ?? ''),
+ name: (raw['Description'] as string) ?? '',
+ type,
+ vatCode: raw['VATCode'] as string | undefined,
+ active: raw['Active'] !== false,
+ balanceBroughtForward: raw['BalanceBroughtForward'] as number | undefined,
+ balanceCarriedForward: raw['BalanceCarriedForward'] as number | undefined,
+ sruCode: raw['SRU'] != null ? String(raw['SRU']) : undefined,
+ _raw: raw,
+ };
+}
+
+export function mapFortnoxToCompanyInformation(raw: Record): CompanyInformationDto {
+ return {
+ companyName: (raw['CompanyName'] as string) ?? '',
+ organizationNumber: raw['OrganizationNumber'] as string | undefined,
+ legalEntity: {
+ registrationName: (raw['CompanyName'] as string) ?? '',
+ companyId: raw['OrganizationNumber'] as string | undefined,
+ companyIdSchemeId: 'SE:ORGNR',
+ },
+ address: {
+ streetName: raw['Address'] as string | undefined,
+ cityName: raw['City'] as string | undefined,
+ postalZone: raw['ZipCode'] as string | undefined,
+ countryCode: raw['Country'] as string | undefined,
+ },
+ contact: {
+ email: raw['Email'] as string | undefined,
+ telephone: raw['Phone1'] as string | undefined,
+ website: raw['WWW'] as string | undefined,
+ },
+ _raw: raw,
+ };
+}
+
+export function mapFortnoxToPayment(raw: Record, invoiceId?: string): PaymentDto {
+ return {
+ id: String(raw['Number'] ?? ''),
+ paymentNumber: String(raw['Number'] ?? ''),
+ invoiceId: invoiceId ?? String(raw['InvoiceNumber'] ?? ''),
+ paymentDate: (raw['PaymentDate'] as string) ?? '',
+ amount: amount(raw['Amount'] as number ?? 0, (raw['Currency'] as string) ?? 'SEK'),
+ reference: raw['Reference'] as string | undefined,
+ _raw: raw,
+ };
+}
diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts
new file mode 100644
index 00000000..af333ed1
--- /dev/null
+++ b/lib/providers/fortnox/oauth.ts
@@ -0,0 +1,105 @@
+import { FORTNOX_AUTH_URL, FORTNOX_TOKEN_URL } from './config';
+import type { OAuthConfig, TokenResponse } from '../types';
+
+const DEFAULT_SCOPES = [
+ 'companyinformation',
+ 'invoice',
+ 'supplierinvoice',
+ 'customer',
+ 'supplier',
+ 'bookkeeping',
+];
+
+export function buildFortnoxAuthUrl(
+ config: OAuthConfig,
+ options?: { scopes?: string[]; state?: string },
+): string {
+ const params = new URLSearchParams({
+ client_id: config.clientId,
+ redirect_uri: config.redirectUri,
+ response_type: 'code',
+ access_type: 'offline',
+ });
+
+ const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES;
+ params.set('scope', scopes.join(' '));
+
+ if (options?.state) {
+ params.set('state', options.state);
+ }
+
+ return `${FORTNOX_AUTH_URL}?${params.toString()}`;
+}
+
+function basicAuthHeader(config: OAuthConfig): string {
+ const encoded = btoa(`${config.clientId}:${config.clientSecret}`);
+ return `Basic ${encoded}`;
+}
+
+export async function exchangeFortnoxCode(
+ config: OAuthConfig,
+ code: string,
+): Promise {
+ const response = await fetch(FORTNOX_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: basicAuthHeader(config),
+ },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: config.redirectUri,
+ }).toString(),
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Fortnox token exchange failed: ${response.status} ${body}`);
+ }
+
+ return response.json() as Promise;
+}
+
+export async function refreshFortnoxToken(
+ config: OAuthConfig,
+ refreshToken: string,
+): Promise {
+ const response = await fetch(FORTNOX_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: basicAuthHeader(config),
+ },
+ body: new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ }).toString(),
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Fortnox token refresh failed: ${response.status} ${body}`);
+ }
+
+ return response.json() as Promise;
+}
+
+export async function revokeFortnoxToken(
+ config: OAuthConfig,
+ refreshToken: string,
+): Promise {
+ const response = await fetch(FORTNOX_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: basicAuthHeader(config),
+ },
+ body: new URLSearchParams({
+ token: refreshToken,
+ token_type_hint: 'refresh_token',
+ }).toString(),
+ });
+
+ return response.ok;
+}
diff --git a/lib/providers/oauth-config.ts b/lib/providers/oauth-config.ts
new file mode 100644
index 00000000..b5123759
--- /dev/null
+++ b/lib/providers/oauth-config.ts
@@ -0,0 +1,44 @@
+import type { OAuthConfig } from './types';
+
+export function getOAuthConfig(provider: string): OAuthConfig {
+ if (provider === 'fortnox') {
+ return {
+ clientId: process.env.FORTNOX_CLIENT_ID ?? '',
+ clientSecret: process.env.FORTNOX_CLIENT_SECRET ?? '',
+ redirectUri: process.env.FORTNOX_REDIRECT_URI ?? '',
+ };
+ }
+ if (provider === 'visma') {
+ return {
+ clientId: process.env.VISMA_CLIENT_ID ?? '',
+ clientSecret: process.env.VISMA_CLIENT_SECRET ?? '',
+ redirectUri: process.env.VISMA_REDIRECT_URI ?? '',
+ };
+ }
+ if (provider === 'briox') {
+ return {
+ clientId: process.env.BRIOX_CLIENT_ID ?? '',
+ clientSecret: '',
+ redirectUri: '',
+ };
+ }
+ if (provider === 'bokio') {
+ return {
+ clientId: '',
+ clientSecret: '',
+ redirectUri: '',
+ };
+ }
+ if (provider === 'bjornlunden') {
+ return {
+ clientId: process.env.BJORN_LUNDEN_CLIENT_ID ?? '',
+ clientSecret: process.env.BJORN_LUNDEN_CLIENT_SECRET ?? '',
+ redirectUri: '',
+ };
+ }
+ throw new Error(`Unknown provider: ${provider}`);
+}
+
+export function validateProvider(provider: string): boolean {
+ return provider === 'fortnox' || provider === 'visma' || provider === 'briox' || provider === 'bokio' || provider === 'bjornlunden';
+}
diff --git a/lib/providers/provider-data-fetcher.ts b/lib/providers/provider-data-fetcher.ts
new file mode 100644
index 00000000..301bb8b6
--- /dev/null
+++ b/lib/providers/provider-data-fetcher.ts
@@ -0,0 +1,285 @@
+import type {
+ CompanyInformationDto,
+ CustomerDto,
+ SupplierDto,
+ SalesInvoiceDto,
+ SupplierInvoiceDto,
+} from './dto';
+import type { ProviderName } from './types';
+
+import { FortnoxClient } from './fortnox/client';
+import { FORTNOX_RESOURCE_CONFIGS } from './fortnox/config';
+import { VismaClient } from './visma/client';
+import { VISMA_RESOURCE_CONFIGS } from './visma/config';
+import { BrioxClient } from './briox/client';
+import { BRIOX_RESOURCE_CONFIGS } from './briox/config';
+import { BokioClient } from './bokio/client';
+import { BOKIO_RESOURCE_CONFIGS } from './bokio/config';
+import { BjornLundenClient } from './bjornlunden/client';
+import { BL_RESOURCE_CONFIGS } from './bjornlunden/config';
+import { ResourceType } from './dto';
+
+// Singleton clients (they hold rate limiters)
+const fortnoxClient = new FortnoxClient();
+const vismaClient = new VismaClient();
+const brioxClient = new BrioxClient();
+const bokioClient = new BokioClient();
+const bjornLundenClient = new BjornLundenClient();
+
+// ── Helper to paginate Bokio (uses getPage with companyId) ──────────
+
+async function bokioPaginate(
+ accessToken: string,
+ companyId: string,
+ path: string,
+): Promise {
+ const allItems: T[] = [];
+ let page = 1;
+ let totalPages = 1;
+
+ do {
+ const result = await bokioClient.getPage(accessToken, companyId, path, { page });
+ allItems.push(...result.items);
+ totalPages = result.totalPages;
+ page++;
+ } while (page <= totalPages);
+
+ return allItems;
+}
+
+// ── Helper to paginate BjornLunden (uses getPage with userKey) ──────
+
+async function blPaginate(
+ accessToken: string,
+ userKey: string,
+ path: string,
+): Promise {
+ const allItems: T[] = [];
+ let page = 1;
+ let totalPages = 1;
+
+ do {
+ const result = await bjornLundenClient.getPage(accessToken, userKey, path, { page });
+ allItems.push(...result.items);
+ totalPages = result.totalPages;
+ page++;
+ } while (page <= totalPages);
+
+ return allItems;
+}
+
+// ── Public fetch functions ──────────────────────────────────────────
+
+export async function fetchCompanyInfoDirect(
+ provider: ProviderName,
+ accessToken: string,
+ providerCompanyId?: string,
+): Promise {
+ try {
+ if (provider === 'fortnox') {
+ const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
+ const response = await fortnoxClient.get>(accessToken, config.listEndpoint);
+ const data = response[config.detailKey];
+ return data ? config.mapper(data as Record) as CompanyInformationDto : null;
+ }
+
+ if (provider === 'visma') {
+ const config = VISMA_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
+ const response = await vismaClient.get>(accessToken, config.listEndpoint);
+ return config.mapper(response) as CompanyInformationDto;
+ }
+
+ if (provider === 'briox') {
+ const config = BRIOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
+ const response = await brioxClient.get>(accessToken, config.listEndpoint);
+ return config.mapper(response) as CompanyInformationDto;
+ }
+
+ if (provider === 'bokio') {
+ const config = BOKIO_RESOURCE_CONFIGS[ResourceType.CompanyInformation];
+ if (!config || !providerCompanyId) return null;
+ const response = await bokioClient.getCompany>(accessToken, providerCompanyId);
+ return response ? config.mapper(response) as CompanyInformationDto : null;
+ }
+
+ if (provider === 'bjornlunden') {
+ const config = BL_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
+ if (!providerCompanyId) return null;
+ const response = await bjornLundenClient.get>(accessToken, providerCompanyId, config.listEndpoint);
+ return config.mapper(response) as CompanyInformationDto;
+ }
+
+ return null;
+ } catch (error) {
+ console.error(`[provider-data-fetcher] Failed to fetch company info from ${provider}:`, error);
+ return null;
+ }
+}
+
+export async function fetchCustomersDirect(
+ provider: ProviderName,
+ accessToken: string,
+ providerCompanyId?: string,
+): Promise {
+ if (provider === 'fortnox') {
+ const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Customers]!;
+ const items = await fortnoxClient.getPaginated>(
+ accessToken, config.listEndpoint, config.listKey,
+ );
+ return items.map((item) => config.mapper(item) as CustomerDto);
+ }
+
+ if (provider === 'visma') {
+ const config = VISMA_RESOURCE_CONFIGS[ResourceType.Customers]!;
+ const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as CustomerDto);
+ }
+
+ if (provider === 'briox') {
+ const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Customers]!;
+ const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey);
+ return items.map((item) => config.mapper(item) as CustomerDto);
+ }
+
+ if (provider === 'bokio') {
+ const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Customers];
+ if (!config || !providerCompanyId) return [];
+ const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as CustomerDto);
+ }
+
+ if (provider === 'bjornlunden') {
+ const config = BL_RESOURCE_CONFIGS[ResourceType.Customers]!;
+ if (!providerCompanyId) return [];
+ const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as CustomerDto);
+ }
+
+ return [];
+}
+
+export async function fetchSuppliersDirect(
+ provider: ProviderName,
+ accessToken: string,
+ providerCompanyId?: string,
+): Promise {
+ if (provider === 'fortnox') {
+ const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
+ const items = await fortnoxClient.getPaginated>(
+ accessToken, config.listEndpoint, config.listKey,
+ );
+ return items.map((item) => config.mapper(item) as SupplierDto);
+ }
+
+ if (provider === 'visma') {
+ const config = VISMA_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
+ const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SupplierDto);
+ }
+
+ if (provider === 'briox') {
+ const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
+ const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey);
+ return items.map((item) => config.mapper(item) as SupplierDto);
+ }
+
+ if (provider === 'bokio') {
+ const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Suppliers];
+ if (!config || !providerCompanyId) return [];
+ const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SupplierDto);
+ }
+
+ if (provider === 'bjornlunden') {
+ const config = BL_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
+ if (!providerCompanyId) return [];
+ const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SupplierDto);
+ }
+
+ return [];
+}
+
+export async function fetchSalesInvoicesDirect(
+ provider: ProviderName,
+ accessToken: string,
+ providerCompanyId?: string,
+): Promise {
+ if (provider === 'fortnox') {
+ const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
+ const items = await fortnoxClient.getPaginated>(
+ accessToken, config.listEndpoint, config.listKey,
+ );
+ return items.map((item) => config.mapper(item) as SalesInvoiceDto);
+ }
+
+ if (provider === 'visma') {
+ const config = VISMA_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
+ const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SalesInvoiceDto);
+ }
+
+ if (provider === 'briox') {
+ const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
+ const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey);
+ return items.map((item) => config.mapper(item) as SalesInvoiceDto);
+ }
+
+ if (provider === 'bokio') {
+ const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SalesInvoices];
+ if (!config || !providerCompanyId) return [];
+ const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SalesInvoiceDto);
+ }
+
+ if (provider === 'bjornlunden') {
+ const config = BL_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
+ if (!providerCompanyId) return [];
+ const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SalesInvoiceDto);
+ }
+
+ return [];
+}
+
+export async function fetchSupplierInvoicesDirect(
+ provider: ProviderName,
+ accessToken: string,
+ providerCompanyId?: string,
+): Promise {
+ if (provider === 'fortnox') {
+ const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
+ const items = await fortnoxClient.getPaginated>(
+ accessToken, config.listEndpoint, config.listKey,
+ );
+ return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
+ }
+
+ if (provider === 'visma') {
+ const config = VISMA_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
+ const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
+ }
+
+ if (provider === 'briox') {
+ const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
+ const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey);
+ return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
+ }
+
+ if (provider === 'bokio') {
+ const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SupplierInvoices];
+ if (!config || !providerCompanyId) return [];
+ const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
+ }
+
+ if (provider === 'bjornlunden') {
+ const config = BL_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
+ if (!providerCompanyId) return [];
+ const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint);
+ return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
+ }
+
+ return [];
+}
diff --git a/lib/providers/rate-limiter.ts b/lib/providers/rate-limiter.ts
new file mode 100644
index 00000000..0eea1380
--- /dev/null
+++ b/lib/providers/rate-limiter.ts
@@ -0,0 +1,95 @@
+import { Ratelimit } from '@upstash/ratelimit';
+import { Redis } from '@upstash/redis';
+import type { RateLimitConfig } from './types';
+
+let redis: Redis | null = null;
+
+function getRedis(): Redis | null {
+ if (redis) return redis;
+ const url = process.env.UPSTASH_REDIS_REST_URL;
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN;
+ if (!url || !token) return null;
+ redis = new Redis({ url, token });
+ return redis;
+}
+
+/**
+ * Distributed rate limiter backed by Upstash Redis.
+ * Falls back to in-memory token bucket when Upstash env vars are not set (local dev).
+ */
+export class TokenBucketRateLimiter {
+ private readonly upstashLimiter: Ratelimit | null;
+
+ // In-memory fallback fields
+ private tokens: number;
+ private lastRefill: number;
+ private readonly maxTokens: number;
+ private readonly refillRateMs: number;
+
+ constructor(config: RateLimitConfig, prefix?: string) {
+ this.maxTokens = config.maxRequests;
+ this.tokens = config.maxRequests;
+ this.refillRateMs = config.windowMs / config.maxRequests;
+ this.lastRefill = Date.now();
+
+ const redisClient = getRedis();
+ if (redisClient) {
+ this.upstashLimiter = new Ratelimit({
+ redis: redisClient,
+ limiter: Ratelimit.slidingWindow(config.maxRequests, `${config.windowMs} ms`),
+ prefix: prefix ?? 'ratelimit',
+ });
+ } else {
+ this.upstashLimiter = null;
+ }
+ }
+
+ async acquire(): Promise {
+ if (this.upstashLimiter) {
+ return this.acquireDistributed();
+ }
+ return this.acquireLocal();
+ }
+
+ private async acquireDistributed(): Promise {
+ const { success, reset } = await this.upstashLimiter!.limit('global');
+ if (success) return;
+
+ // Wait until the window resets, then retry
+ const waitMs = Math.max(0, reset - Date.now());
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
+
+ // Retry once after waiting
+ const retry = await this.upstashLimiter!.limit('global');
+ if (!retry.success) {
+ // Still limited — wait for the new reset
+ const retryWait = Math.max(0, retry.reset - Date.now());
+ await new Promise((resolve) => setTimeout(resolve, retryWait));
+ }
+ }
+
+ private refill(): void {
+ const now = Date.now();
+ const elapsed = now - this.lastRefill;
+ const newTokens = Math.floor(elapsed / this.refillRateMs);
+ if (newTokens > 0) {
+ this.tokens = Math.min(this.maxTokens, this.tokens + newTokens);
+ this.lastRefill = now;
+ }
+ }
+
+ private async acquireLocal(): Promise {
+ this.refill();
+ if (this.tokens > 0) {
+ this.tokens--;
+ return;
+ }
+
+ const waitMs = this.refillRateMs - (Date.now() - this.lastRefill);
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, waitMs)));
+ this.refill();
+ if (this.tokens > 0) {
+ this.tokens--;
+ }
+ }
+}
diff --git a/lib/providers/resolve-consent.ts b/lib/providers/resolve-consent.ts
new file mode 100644
index 00000000..4c68c85c
--- /dev/null
+++ b/lib/providers/resolve-consent.ts
@@ -0,0 +1,121 @@
+import { createServiceClient } from '@/lib/supabase/server';
+import type { TokenResponse } from './types';
+import { getOAuthConfig } from './oauth-config';
+import { refreshFortnoxToken } from './fortnox/oauth';
+import { refreshVismaToken } from './visma/oauth';
+import { refreshBrioxToken } from './briox/oauth';
+import { refreshBjornLundenToken } from './bjornlunden/oauth';
+
+export interface ResolvedConsent {
+ consent: Record;
+ accessToken: string;
+ providerCompanyId?: string;
+}
+
+export async function resolveConsent(companyId: string, consentId: string): Promise {
+ const supabase = createServiceClient();
+
+ // Load consent
+ const { data: consentRows } = await supabase
+ .from('provider_consents')
+ .select('*')
+ .eq('id', consentId)
+ .eq('company_id', companyId)
+ .limit(1);
+
+ if (!consentRows || consentRows.length === 0) {
+ throw { status: 404, message: 'Consent not found' };
+ }
+
+ const consent = consentRows[0]!;
+ if (consent.status !== 1) {
+ throw { status: 403, message: 'Consent is not in Accepted status' };
+ }
+
+ if (!consent.provider) {
+ throw { status: 400, message: 'Consent has no provider set — complete onboarding first' };
+ }
+
+ // Load tokens
+ const { data: tokenRows } = await supabase
+ .from('provider_consent_tokens')
+ .select('*')
+ .eq('consent_id', consentId)
+ .limit(1);
+
+ if (!tokenRows || tokenRows.length === 0) {
+ throw { status: 401, message: 'No tokens found for this consent — complete OAuth first' };
+ }
+
+ const tokens = tokenRows[0]!;
+
+ // Bokio: private API tokens that don't expire
+ if (consent.provider === 'bokio') {
+ return {
+ consent,
+ accessToken: tokens.access_token as string,
+ providerCompanyId: tokens.provider_company_id as string | undefined,
+ };
+ }
+
+ // Björn Lunden: client credentials — auto-refresh when expired
+ if (consent.provider === 'bjornlunden') {
+ if (tokens.token_expires_at && new Date(tokens.token_expires_at as string) < new Date()) {
+ const refreshed = await refreshBjornLundenToken();
+ const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
+
+ await supabase
+ .from('provider_consent_tokens')
+ .update({
+ access_token: refreshed.access_token,
+ token_expires_at: newExpiresAt,
+ })
+ .eq('consent_id', consentId);
+
+ return {
+ consent,
+ accessToken: refreshed.access_token,
+ providerCompanyId: tokens.provider_company_id as string | undefined,
+ };
+ }
+
+ return {
+ consent,
+ accessToken: tokens.access_token as string,
+ providerCompanyId: tokens.provider_company_id as string | undefined,
+ };
+ }
+
+ // Check expiry, auto-refresh if needed
+ if (tokens.token_expires_at && new Date(tokens.token_expires_at as string) < new Date()) {
+ if (!tokens.refresh_token) {
+ throw { status: 401, message: 'Access token expired and no refresh token available' };
+ }
+
+ const config = getOAuthConfig(consent.provider as string);
+ let refreshed: TokenResponse;
+
+ if (consent.provider === 'fortnox') {
+ refreshed = await refreshFortnoxToken(config, tokens.refresh_token as string);
+ } else if (consent.provider === 'briox') {
+ refreshed = await refreshBrioxToken(config.clientId, tokens.refresh_token as string);
+ } else {
+ refreshed = await refreshVismaToken(config, tokens.refresh_token as string);
+ }
+
+ const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
+
+ await supabase
+ .from('provider_consent_tokens')
+ .update({
+ access_token: refreshed.access_token,
+ refresh_token: refreshed.refresh_token,
+ token_expires_at: newExpiresAt,
+ })
+ .eq('consent_id', consentId);
+
+ return { consent, accessToken: refreshed.access_token };
+ }
+
+ return { consent, accessToken: tokens.access_token as string };
+}
diff --git a/lib/providers/retry.ts b/lib/providers/retry.ts
new file mode 100644
index 00000000..76ffcfd7
--- /dev/null
+++ b/lib/providers/retry.ts
@@ -0,0 +1,48 @@
+export interface RetryOptions {
+ maxAttempts?: number;
+ initialDelayMs?: number;
+ maxDelayMs?: number;
+ backoffMultiplier?: number;
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
+ /** Return a custom delay in ms for this error, or undefined to use default backoff. */
+ getDelayMs?: (error: unknown, attempt: number) => number | undefined;
+}
+
+const DEFAULT_OPTIONS = {
+ maxAttempts: 3,
+ initialDelayMs: 1000,
+ maxDelayMs: 30_000,
+ backoffMultiplier: 2,
+};
+
+export async function withRetry(
+ fn: () => Promise,
+ options?: RetryOptions,
+): Promise {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+ let lastError: unknown;
+
+ for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
+ try {
+ return await fn();
+ } catch (error) {
+ lastError = error;
+
+ if (attempt === opts.maxAttempts) break;
+
+ if (options?.shouldRetry && !options.shouldRetry(error, attempt)) {
+ break;
+ }
+
+ const customDelay = options?.getDelayMs?.(error, attempt);
+ const delay = customDelay ?? Math.min(
+ opts.initialDelayMs * opts.backoffMultiplier ** (attempt - 1),
+ opts.maxDelayMs,
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+
+ throw lastError;
+}
diff --git a/lib/providers/types.ts b/lib/providers/types.ts
new file mode 100644
index 00000000..58abdbd1
--- /dev/null
+++ b/lib/providers/types.ts
@@ -0,0 +1,58 @@
+import type { ResourceType } from './dto';
+
+export type ProviderName = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden';
+
+export interface RateLimitConfig {
+ maxRequests: number;
+ windowMs: number;
+}
+
+export interface OAuthConfig {
+ clientId: string;
+ clientSecret: string;
+ redirectUri: string;
+}
+
+export interface TokenResponse {
+ access_token: string;
+ refresh_token: string;
+ token_type: string;
+ expires_in: number;
+}
+
+export interface ResourceConfig {
+ listEndpoint: string;
+ detailEndpoint: string;
+ idField: string;
+ mapper: (raw: Record) => unknown;
+ singleton?: boolean;
+}
+
+export interface FortnoxResourceConfig extends ResourceConfig {
+ listKey: string;
+ detailKey: string;
+ supportsLastModified: boolean;
+ resolveDetailPath?: (resourceId: string, query?: Record) => string;
+ supportsEntryHydration?: boolean;
+}
+
+export interface VismaResourceConfig extends ResourceConfig {
+ supportsModifiedFilter: boolean;
+ modifiedField?: string;
+}
+
+export interface BrioxResourceConfig extends ResourceConfig {
+ listKey: string;
+ supportsModifiedFilter: boolean;
+ yearScoped?: boolean;
+ supportsEntryHydration?: boolean;
+ detailKey?: string;
+}
+
+export interface BokioResourceConfig extends ResourceConfig {
+ paginated?: boolean;
+}
+
+export interface BjornLundenResourceConfig extends ResourceConfig {
+ paginated?: boolean;
+}
diff --git a/lib/providers/visma/client.ts b/lib/providers/visma/client.ts
new file mode 100644
index 00000000..8a71a571
--- /dev/null
+++ b/lib/providers/visma/client.ts
@@ -0,0 +1,140 @@
+import { TokenBucketRateLimiter } from '../rate-limiter';
+import { withRetry } from '../retry';
+import { VISMA_BASE_URL, VISMA_RATE_LIMIT } from './config';
+
+export class VismaApiError extends Error {
+ constructor(
+ message: string,
+ public readonly statusCode: number,
+ public readonly body?: string,
+ ) {
+ super(message);
+ this.name = 'VismaApiError';
+ }
+}
+
+function isRetryableError(error: unknown): boolean {
+ if (error instanceof VismaApiError) {
+ if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
+ return false;
+ }
+ return error.statusCode === 429 || error.statusCode >= 500;
+ }
+ return false;
+}
+
+interface VismaPaginatedResponse {
+ Meta?: { TotalNumberOfPages?: number };
+ Data: T[];
+}
+
+export class VismaClient {
+ private readonly rateLimiter: TokenBucketRateLimiter;
+ private readonly baseUrl: string;
+
+ constructor(baseUrl?: string) {
+ this.baseUrl = baseUrl ?? VISMA_BASE_URL;
+ this.rateLimiter = new TokenBucketRateLimiter(VISMA_RATE_LIMIT, 'ratelimit:visma');
+ }
+
+ async get(accessToken: string, path: string): Promise {
+ return withRetry(
+ async () => {
+ await this.rateLimiter.acquire();
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new VismaApiError(
+ `Visma API error: ${response.status} ${response.statusText}`,
+ response.status,
+ body,
+ );
+ }
+
+ return response.json() as Promise;
+ },
+ {
+ maxAttempts: 3,
+ initialDelayMs: 1000,
+ shouldRetry: isRetryableError,
+ },
+ );
+ }
+
+ async getPage(
+ accessToken: string,
+ path: string,
+ options?: {
+ page?: number;
+ pageSize?: number;
+ modifiedSince?: string;
+ modifiedField?: string;
+ },
+ ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
+ const pageSize = options?.pageSize ?? 100;
+ const page = options?.page ?? 1;
+
+ const params = new URLSearchParams();
+ params.set('$top', String(pageSize));
+ params.set('$skip', String((page - 1) * pageSize));
+
+ if (options?.modifiedSince && options?.modifiedField) {
+ params.set(
+ '$filter',
+ `${options.modifiedField} gt ${options.modifiedSince}`,
+ );
+ }
+
+ const separator = path.includes('?') ? '&' : '?';
+ const fullPath = `${path}${separator}${params.toString()}`;
+
+ const response = await this.get & { Meta?: { TotalNumberOfResults?: number; TotalNumberOfPages?: number } }>(accessToken, fullPath);
+
+ const totalPages = response.Meta?.TotalNumberOfPages ?? 1;
+ const totalCount = response.Meta?.TotalNumberOfResults ?? 0;
+
+ return {
+ items: Array.isArray(response.Data) ? response.Data : [],
+ page,
+ totalPages,
+ totalCount,
+ };
+ }
+
+ async getPaginated(
+ accessToken: string,
+ path: string,
+ options?: {
+ modifiedSince?: string;
+ modifiedField?: string;
+ pageSize?: number;
+ },
+ ): Promise {
+ const allItems: T[] = [];
+ let page = 1;
+ let totalPages = 1;
+
+ do {
+ const result = await this.getPage(accessToken, path, {
+ page,
+ pageSize: options?.pageSize,
+ modifiedSince: options?.modifiedSince,
+ modifiedField: options?.modifiedField,
+ });
+
+ allItems.push(...result.items);
+ totalPages = result.totalPages;
+ page++;
+ } while (page <= totalPages);
+
+ return allItems;
+ }
+}
diff --git a/lib/providers/visma/config.ts b/lib/providers/visma/config.ts
new file mode 100644
index 00000000..6aa32fb1
--- /dev/null
+++ b/lib/providers/visma/config.ts
@@ -0,0 +1,74 @@
+import { ResourceType } from '../dto';
+import type { VismaResourceConfig, RateLimitConfig } from '../types';
+import {
+ mapVismaToSalesInvoice,
+ mapVismaToSupplierInvoice,
+ mapVismaToCustomer,
+ mapVismaToSupplier,
+ mapVismaToJournal,
+ mapVismaToAccountingAccount,
+ mapVismaToCompanyInformation,
+} from './mapper';
+
+export const VISMA_BASE_URL = 'https://eaccountingapi.vismaonline.com/v2';
+export const VISMA_AUTH_URL = 'https://identity.vismaonline.com/connect/authorize';
+export const VISMA_TOKEN_URL = 'https://identity.vismaonline.com/connect/token';
+export const VISMA_REVOKE_URL = 'https://identity.vismaonline.com/connect/revocation';
+export const VISMA_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 };
+
+export const VISMA_RESOURCE_CONFIGS: Partial> = {
+ [ResourceType.SalesInvoices]: {
+ listEndpoint: '/customerinvoices',
+ detailEndpoint: '/customerinvoices/{id}',
+ idField: 'Id',
+ mapper: mapVismaToSalesInvoice,
+ supportsModifiedFilter: true,
+ modifiedField: 'ModifiedUtc',
+ },
+ [ResourceType.SupplierInvoices]: {
+ listEndpoint: '/supplierinvoices',
+ detailEndpoint: '/supplierinvoices/{id}',
+ idField: 'Id',
+ mapper: mapVismaToSupplierInvoice,
+ supportsModifiedFilter: true,
+ modifiedField: 'ModifiedUtc',
+ },
+ [ResourceType.Customers]: {
+ listEndpoint: '/customers',
+ detailEndpoint: '/customers/{id}',
+ idField: 'Id',
+ mapper: mapVismaToCustomer,
+ supportsModifiedFilter: true,
+ modifiedField: 'ChangedUtc',
+ },
+ [ResourceType.Suppliers]: {
+ listEndpoint: '/suppliers',
+ detailEndpoint: '/suppliers/{id}',
+ idField: 'Id',
+ mapper: mapVismaToSupplier,
+ supportsModifiedFilter: true,
+ modifiedField: 'ModifiedUtc',
+ },
+ [ResourceType.Journals]: {
+ listEndpoint: '/vouchers',
+ detailEndpoint: '/vouchers/{id}',
+ idField: 'Id',
+ mapper: mapVismaToJournal,
+ supportsModifiedFilter: false,
+ },
+ [ResourceType.AccountingAccounts]: {
+ listEndpoint: '/accounts',
+ detailEndpoint: '/accounts/{id}',
+ idField: 'Number',
+ mapper: mapVismaToAccountingAccount,
+ supportsModifiedFilter: false,
+ },
+ [ResourceType.CompanyInformation]: {
+ listEndpoint: '/companysettings',
+ detailEndpoint: '/companysettings',
+ idField: 'CorporateIdentityNumber',
+ mapper: mapVismaToCompanyInformation,
+ supportsModifiedFilter: false,
+ singleton: true,
+ },
+};
diff --git a/lib/providers/visma/mapper.ts b/lib/providers/visma/mapper.ts
new file mode 100644
index 00000000..865709f0
--- /dev/null
+++ b/lib/providers/visma/mapper.ts
@@ -0,0 +1,239 @@
+import type {
+ SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
+ LegalMonetaryTotalDto, PaymentStatusDto,
+ SupplierInvoiceDto, SupplierInvoiceLineDto,
+ CustomerDto, SupplierDto,
+ JournalDto, AccountingEntryDto,
+ AccountingAccountDto, AccountType,
+ CompanyInformationDto,
+ AmountType, PartyDto,
+} from '../dto';
+
+function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
+ return { value: value ?? 0, currencyCode: currency };
+}
+
+function deriveInvoiceStatus(raw: Record): InvoiceStatusCode {
+ const remaining = raw['RemainingAmount'] as number ?? 0;
+ const total = raw['TotalAmount'] as number ?? 0;
+ if (raw['IsCancelled'] === true) return 'cancelled';
+ if (remaining === 0 && total > 0) return 'paid';
+ if (raw['IsBooked'] === true) return 'booked';
+ if (raw['IsSent'] === true || raw['SendType'] != null) return 'sent';
+ return 'draft';
+}
+
+function buildParty(name: string, orgNumber?: string, raw?: Record): PartyDto {
+ return {
+ name,
+ identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
+ postalAddress: raw ? {
+ streetName: (raw['InvoiceAddress1'] ?? raw['Address1']) as string | undefined,
+ additionalStreetName: (raw['InvoiceAddress2'] ?? raw['Address2']) as string | undefined,
+ cityName: (raw['InvoiceCity'] ?? raw['City']) as string | undefined,
+ postalZone: (raw['InvoicePostalCode'] ?? raw['PostalCode']) as string | undefined,
+ countryCode: raw['CountryCode'] as string | undefined,
+ } : undefined,
+ legalEntity: orgNumber ? {
+ registrationName: name,
+ companyId: orgNumber,
+ companyIdSchemeId: 'SE:ORGNR',
+ } : undefined,
+ contact: {
+ email: (raw?.['EmailAddress'] ?? raw?.['Email']) as string | undefined,
+ telephone: (raw?.['Telephone'] ?? raw?.['Phone']) as string | undefined,
+ },
+ };
+}
+
+export function mapVismaToSalesInvoice(raw: Record): SalesInvoiceDto {
+ const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
+ const total = raw['TotalAmount'] as number ?? 0;
+ const remaining = raw['RemainingAmount'] as number ?? 0;
+
+ const rows = (raw['Rows'] as Record[] | undefined) ?? [];
+ const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
+ id: String(row['LineNumber'] ?? idx + 1),
+ description: row['Text'] as string | undefined,
+ quantity: row['Quantity'] as number | undefined,
+ unitCode: row['UnitAbbreviation'] as string | undefined,
+ unitPrice: row['UnitPrice'] != null ? amount(row['UnitPrice'] as number, currency) : undefined,
+ lineExtensionAmount: amount(row['LineTotal'] as number ?? 0, currency),
+ taxPercent: row['VatRatePercent'] as number | undefined,
+ accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
+ articleNumber: row['ArticleNumber'] as string | undefined,
+ }));
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(total, currency),
+ payableAmount: amount(total, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: remaining === 0 && total > 0,
+ balance: amount(remaining, currency),
+ };
+
+ return {
+ id: String(raw['Id'] ?? ''),
+ invoiceNumber: String(raw['InvoiceNumber'] ?? ''),
+ issueDate: (raw['InvoiceDate'] as string) ?? '',
+ dueDate: raw['DueDate'] as string | undefined,
+ currencyCode: currency,
+ status: deriveInvoiceStatus(raw),
+ supplier: buildParty(''),
+ customer: buildParty(
+ (raw['InvoiceCustomerName'] ?? '') as string,
+ undefined,
+ ),
+ lines,
+ legalMonetaryTotal,
+ paymentStatus,
+ createdAt: raw['CreatedUtc'] as string | undefined,
+ updatedAt: raw['ModifiedUtc'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapVismaToSupplierInvoice(raw: Record): SupplierInvoiceDto {
+ const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
+ const total = raw['TotalAmount'] as number ?? 0;
+ const remaining = raw['RemainingAmount'] as number ?? 0;
+
+ const rows = (raw['Rows'] as Record[] | undefined) ?? [];
+ const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => {
+ const debit = (row['DebetAmount'] as number) ?? 0;
+ const credit = (row['CreditAmount'] as number) ?? 0;
+ const rowAmount = debit || credit;
+ return {
+ id: String(row['LineNumber'] ?? idx + 1),
+ description: row['TransactionText'] as string | undefined,
+ quantity: row['Quantity'] as number | undefined,
+ lineExtensionAmount: amount(rowAmount, currency),
+ accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
+ };
+ });
+
+ const legalMonetaryTotal: LegalMonetaryTotalDto = {
+ lineExtensionAmount: amount(total, currency),
+ payableAmount: amount(total, currency),
+ };
+
+ const paymentStatus: PaymentStatusDto = {
+ paid: remaining === 0 && total > 0,
+ balance: amount(remaining, currency),
+ };
+
+ return {
+ id: String(raw['Id'] ?? ''),
+ invoiceNumber: String(raw['InvoiceNumber'] ?? ''),
+ issueDate: (raw['InvoiceDate'] as string) ?? '',
+ dueDate: raw['DueDate'] as string | undefined,
+ currencyCode: currency,
+ status: deriveInvoiceStatus(raw),
+ supplier: buildParty((raw['SupplierName'] ?? '') as string),
+ buyer: buildParty(''),
+ lines,
+ legalMonetaryTotal,
+ paymentStatus,
+ updatedAt: raw['ModifiedUtc'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapVismaToCustomer(raw: Record): CustomerDto {
+ return {
+ id: String(raw['Id'] ?? ''),
+ customerNumber: String(raw['CustomerNumber'] ?? ''),
+ type: raw['IsPrivatePerson'] === true ? 'private' : 'company',
+ party: buildParty(
+ (raw['Name'] as string) ?? '',
+ raw['CorporateIdentityNumber'] as string | undefined,
+ raw,
+ ),
+ active: raw['IsActive'] !== false,
+ note: raw['Note'] as string | undefined,
+ updatedAt: raw['ChangedUtc'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapVismaToSupplier(raw: Record): SupplierDto {
+ return {
+ id: String(raw['Id'] ?? ''),
+ supplierNumber: String(raw['SupplierNumber'] ?? ''),
+ party: buildParty(
+ (raw['Name'] as string) ?? '',
+ raw['CorporateIdentityNumber'] as string | undefined,
+ raw,
+ ),
+ active: raw['IsActive'] !== false,
+ bankAccount: raw['BankAccountNumber'] as string | undefined,
+ bankGiro: raw['BankGiro'] as string | undefined,
+ plusGiro: raw['PlusGiro'] as string | undefined,
+ updatedAt: raw['ModifiedUtc'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapVismaToAccountingAccount(raw: Record): AccountingAccountDto {
+ const num = Number(raw['Number']);
+ let type: AccountType | undefined;
+ if (num >= 1000 && num < 2000) type = 'asset';
+ else if (num >= 2000 && num < 3000) type = 'liability';
+ else if (num >= 3000 && num < 4000) type = 'revenue';
+ else if (num >= 4000 && num < 9000) type = 'expense';
+
+ return {
+ accountNumber: String(raw['Number'] ?? ''),
+ name: (raw['Name'] as string) ?? '',
+ type,
+ vatCode: raw['VatCodeId'] != null ? String(raw['VatCodeId']) : undefined,
+ active: raw['IsActive'] !== false,
+ _raw: raw,
+ };
+}
+
+export function mapVismaToCompanyInformation(raw: Record): CompanyInformationDto {
+ return {
+ companyName: (raw['Name'] as string) ?? '',
+ organizationNumber: raw['CorporateIdentityNumber'] as string | undefined,
+ legalEntity: {
+ registrationName: (raw['Name'] as string) ?? '',
+ companyId: raw['CorporateIdentityNumber'] as string | undefined,
+ companyIdSchemeId: 'SE:ORGNR',
+ },
+ address: {
+ streetName: raw['Address1'] as string | undefined,
+ cityName: raw['City'] as string | undefined,
+ postalZone: raw['PostalCode'] as string | undefined,
+ countryCode: raw['CountryCode'] as string | undefined,
+ },
+ contact: {
+ email: raw['Email'] as string | undefined,
+ telephone: raw['Phone'] as string | undefined,
+ },
+ baseCurrency: raw['CurrencyCode'] as string | undefined,
+ _raw: raw,
+ };
+}
+
+export function mapVismaToJournal(raw: Record): JournalDto {
+ const rows = (raw['Rows'] as Record[] | undefined) ?? [];
+ const entries: AccountingEntryDto[] = rows.map((row) => ({
+ accountNumber: String(row['AccountNumber'] ?? ''),
+ accountName: row['AccountName'] as string | undefined,
+ debit: (row['DebitAmount'] as number) ?? 0,
+ credit: (row['CreditAmount'] as number) ?? 0,
+ description: row['Description'] as string | undefined,
+ }));
+
+ return {
+ id: String(raw['Id'] ?? ''),
+ journalNumber: String(raw['VoucherNumber'] ?? raw['Number'] ?? ''),
+ description: raw['Description'] as string | undefined,
+ registrationDate: (raw['VoucherDate'] as string) ?? '',
+ entries,
+ _raw: raw,
+ };
+}
diff --git a/lib/providers/visma/oauth.ts b/lib/providers/visma/oauth.ts
new file mode 100644
index 00000000..7f7899f4
--- /dev/null
+++ b/lib/providers/visma/oauth.ts
@@ -0,0 +1,106 @@
+import { VISMA_AUTH_URL, VISMA_TOKEN_URL, VISMA_REVOKE_URL } from './config';
+import type { OAuthConfig, TokenResponse } from '../types';
+
+const DEFAULT_SCOPES = [
+ 'ea:api',
+ 'offline_access',
+ 'ea:sales_readonly',
+ 'ea:accounting_readonly',
+ 'ea:purchase_readonly',
+];
+
+const EACCOUNTING_ACR_VALUE = 'service:44643EB1-3F76-4C1C-A672-402AE8085934';
+
+export function buildVismaAuthUrl(
+ config: OAuthConfig,
+ options?: { scopes?: string[]; state?: string; acrValues?: string },
+): string {
+ const params = new URLSearchParams({
+ client_id: config.clientId,
+ redirect_uri: config.redirectUri,
+ response_type: 'code',
+ acr_values: options?.acrValues ?? EACCOUNTING_ACR_VALUE,
+ });
+
+ const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES;
+ params.set('scope', scopes.join(' '));
+
+ if (options?.state) {
+ params.set('state', options.state);
+ }
+
+ return `${VISMA_AUTH_URL}?${params.toString()}`;
+}
+
+function basicAuthHeader(config: OAuthConfig): string {
+ const encoded = btoa(`${config.clientId}:${config.clientSecret}`);
+ return `Basic ${encoded}`;
+}
+
+export async function exchangeVismaCode(
+ config: OAuthConfig,
+ code: string,
+): Promise {
+ const response = await fetch(VISMA_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: basicAuthHeader(config),
+ },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: config.redirectUri,
+ }).toString(),
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Visma token exchange failed: ${response.status} ${body}`);
+ }
+
+ return response.json() as Promise;
+}
+
+export async function refreshVismaToken(
+ config: OAuthConfig,
+ refreshToken: string,
+): Promise {
+ const response = await fetch(VISMA_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: basicAuthHeader(config),
+ },
+ body: new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ }).toString(),
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Visma token refresh failed: ${response.status} ${body}`);
+ }
+
+ return response.json() as Promise;
+}
+
+export async function revokeVismaToken(
+ config: OAuthConfig,
+ refreshToken: string,
+): Promise {
+ const response = await fetch(VISMA_REVOKE_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: basicAuthHeader(config),
+ },
+ body: new URLSearchParams({
+ token: refreshToken,
+ token_type_hint: 'refresh_token',
+ }).toString(),
+ });
+
+ return response.ok;
+}
diff --git a/package-lock.json b/package-lock.json
index dfc37cf1..7a2d31f1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,8 @@
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.1",
"@tailwindcss/typography": "^0.5.19",
+ "@upstash/ratelimit": "^2.0.8",
+ "@upstash/redis": "^1.37.0",
"@use-gesture/react": "^10.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -7433,6 +7435,39 @@
"win32"
]
},
+ "node_modules/@upstash/core-analytics": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz",
+ "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@upstash/redis": "^1.28.3"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@upstash/ratelimit": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz",
+ "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@upstash/core-analytics": "^0.0.10"
+ },
+ "peerDependencies": {
+ "@upstash/redis": "^1.34.3"
+ }
+ },
+ "node_modules/@upstash/redis": {
+ "version": "1.37.0",
+ "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.37.0.tgz",
+ "integrity": "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==",
+ "license": "MIT",
+ "dependencies": {
+ "uncrypto": "^0.1.3"
+ }
+ },
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
@@ -15194,6 +15229,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/uncrypto": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
+ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
diff --git a/package.json b/package.json
index 96543b90..24ea16f6 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,8 @@
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.1",
"@tailwindcss/typography": "^0.5.19",
+ "@upstash/ratelimit": "^2.0.8",
+ "@upstash/redis": "^1.37.0",
"@use-gesture/react": "^10.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/supabase/migrations/20260402010000_provider_consents.sql b/supabase/migrations/20260402010000_provider_consents.sql
new file mode 100644
index 00000000..80c4ba35
--- /dev/null
+++ b/supabase/migrations/20260402010000_provider_consents.sql
@@ -0,0 +1,112 @@
+-- Provider consents for direct accounting provider integrations
+-- Replaces the external dependency on the Arcim Sync gateway
+
+-- Provider consents (one per connected accounting company)
+CREATE TABLE IF NOT EXISTS provider_consents (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ status INTEGER NOT NULL DEFAULT 0, -- 0=Created, 1=Accepted, 2=Revoked, 3=Inactive
+ provider TEXT, -- fortnox, visma, briox, bokio, bjornlunden
+ org_number TEXT,
+ company_name TEXT,
+ etag TEXT NOT NULL DEFAULT gen_random_uuid()::text,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ expires_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_provider_consents_company ON provider_consents(company_id);
+CREATE INDEX idx_provider_consents_company_provider ON provider_consents(company_id, provider);
+
+ALTER TABLE provider_consents ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY provider_consents_select ON provider_consents
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+
+CREATE POLICY provider_consents_insert ON provider_consents
+ FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
+
+CREATE POLICY provider_consents_update ON provider_consents
+ FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
+
+CREATE POLICY provider_consents_delete ON provider_consents
+ FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
+ ));
+
+CREATE TRIGGER update_provider_consents_updated_at
+ BEFORE UPDATE ON provider_consents
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Encrypted token storage for provider OAuth/API tokens
+CREATE TABLE IF NOT EXISTS provider_consent_tokens (
+ consent_id UUID PRIMARY KEY REFERENCES provider_consents(id) ON DELETE CASCADE,
+ provider TEXT NOT NULL,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT,
+ token_expires_at TIMESTAMPTZ,
+ provider_company_id TEXT,
+ scopes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+ALTER TABLE provider_consent_tokens ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY provider_consent_tokens_select ON provider_consent_tokens
+ FOR SELECT USING (consent_id IN (
+ SELECT id FROM provider_consents WHERE company_id IN (
+ SELECT company_id FROM team_members WHERE user_id = auth.uid()
+ )
+ ));
+
+CREATE POLICY provider_consent_tokens_insert ON provider_consent_tokens
+ FOR INSERT WITH CHECK (consent_id IN (
+ SELECT id FROM provider_consents WHERE company_id IN (
+ SELECT company_id FROM team_members WHERE user_id = auth.uid()
+ )
+ ));
+
+CREATE POLICY provider_consent_tokens_update ON provider_consent_tokens
+ FOR UPDATE USING (consent_id IN (
+ SELECT id FROM provider_consents WHERE company_id IN (
+ SELECT company_id FROM team_members WHERE user_id = auth.uid()
+ )
+ ));
+
+CREATE TRIGGER update_provider_consent_tokens_updated_at
+ BEFORE UPDATE ON provider_consent_tokens
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- One-time codes for OAuth callback validation
+CREATE TABLE IF NOT EXISTS provider_otc (
+ code TEXT PRIMARY KEY,
+ consent_id UUID NOT NULL REFERENCES provider_consents(id) ON DELETE CASCADE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_provider_otc_consent ON provider_otc(consent_id);
+
+ALTER TABLE provider_otc ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY provider_otc_select ON provider_otc
+ FOR SELECT USING (consent_id IN (
+ SELECT id FROM provider_consents WHERE company_id IN (
+ SELECT company_id FROM team_members WHERE user_id = auth.uid()
+ )
+ ));
+
+CREATE POLICY provider_otc_insert ON provider_otc
+ FOR INSERT WITH CHECK (consent_id IN (
+ SELECT id FROM provider_consents WHERE company_id IN (
+ SELECT company_id FROM team_members WHERE user_id = auth.uid()
+ )
+ ));
+
+CREATE POLICY provider_otc_update ON provider_otc
+ FOR UPDATE USING (consent_id IN (
+ SELECT id FROM provider_consents WHERE company_id IN (
+ SELECT company_id FROM team_members WHERE user_id = auth.uid()
+ )
+ ));