Files
accounted/extensions/general/arcim-migration/index.ts
T
Mattsson db8983ba9e Add/bokslut (#718)
* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:35:30 +02:00

1146 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import {
createConsent,
getConsent,
listConsents,
generateOtc,
getAuthUrl,
exchangeAuthToken,
submitProviderToken,
acceptConsent,
deleteConsent,
resolveConsent,
fetchCompanyInfoDirect,
ProviderTokenInvalidError,
ConsentNotFoundError,
} from './lib/provider-client'
import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import type { ProviderName } from '@/lib/providers/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
import { createLogger } from '@/lib/logger'
const moduleLog = createLogger('extensions/arcim-migration')
/**
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
* Swedish guidance. Falls back to the raw provider message so we never hide
* unknown errors from the user.
*/
function translateOAuthError(error: string, description: string | null): string {
const haystack = `${error} ${description ?? ''}`.toLowerCase()
if (haystack.includes('missing license') || haystack.includes('not have enough licenses')) {
return 'Du behöver aktivera tilläggstjänsten "Fortnox Integration" (~149 kr/mån) på ditt Fortnox-konto innan du kan ansluta. Aktivera den under Inställningar → Tilläggstjänster i Fortnox och försök igen.'
}
if (error === 'access_denied') {
return 'Du avbröt anslutningen i leverantörens inloggning. Försök igen om du vill koppla kontot.'
}
if (error === 'invalid_scope') {
return 'Tredjepartsappen har inte rätt behörigheter för ditt konto. Kontakta supporten.'
}
return description ? `${error}: ${description}` : error
}
/**
* Provider Migration extension
*
* Migrates bookkeeping data from external Swedish accounting systems
* (Fortnox, Visma, Bokio, Björn Lundén, Briox) into Accounted by talking
* directly to each provider's API.
*
* Bookkeeping data (accounts, balances, vouchers) is imported via SIE
* files fetched from providers. Entity data (customers, suppliers,
* invoices) is imported via the provider REST APIs.
*/
export const arcimMigrationExtension: Extension = {
id: 'arcim-migration',
name: 'Systemmigration',
version: '2.0.0',
apiRoutes: [
// ── List available providers ───────────────────────────────────
{
method: 'GET',
path: '/providers',
handler: async () => {
return NextResponse.json({ providers: ARCIM_PROVIDERS })
},
},
// ── Check existing connections and import history ──────────────
{
method: 'GET',
path: '/status',
handler: async (request: Request, ctx?: ExtensionContext) => {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
try {
// Get accepted consents only (status 1) — not abandoned/created ones
const allConsents = await listConsents(companyId)
const consents = allConsents.filter(c => c.status === 1)
// Get SIE import history
const { data: sieImports } = await supabase
.from('sie_imports')
.select('id, filename, status, accounts_count, transactions_count, company_name, fiscal_year_start, fiscal_year_end, imported_at, created_at')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.limit(10)
// Get entity counts (to show what's already been imported)
const [
{ count: customerCount },
{ count: supplierCount },
{ count: invoiceCount },
] = await Promise.all([
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('suppliers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
])
return NextResponse.json({
consents: consents.map(c => ({
id: c.id,
provider: c.provider,
status: c.status,
companyName: c.companyName,
createdAt: c.createdAt,
})),
sieImports: sieImports ?? [],
entityCounts: {
customers: customerCount ?? 0,
suppliers: supplierCount ?? 0,
invoices: invoiceCount ?? 0,
},
})
} catch (error) {
moduleLog.error('arcim status failed', error as Error, { companyId })
return errorResponseFromCode('PROVIDER_STATUS_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
// ── Start consent flow (create consent + OTC) ─────────────────
{
method: 'POST',
path: '/connect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
const { provider, companyName, orgNumber } = await request.json() as {
provider: ArcimProvider
companyName?: string
orgNumber?: string
}
if (!provider) {
return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
details: { field: 'provider', reason: 'required' },
})
}
const providerInfo = ARCIM_PROVIDERS.find(p => p.id === provider)
if (!providerInfo) {
return errorResponseFromCode('PROVIDER_INVALID', moduleLog, {
details: { provider },
})
}
try {
const { createServiceClient: createSvc } = await import('@/lib/supabase/server')
// Reuse existing accepted consent if one exists for this provider
const existingConsents = await listConsents(companyId)
const accepted = existingConsents.find(c => c.provider === provider && c.status === 1)
if (accepted) {
// Already connected — skip OAuth, go straight to preview
if (ctx?.settings) {
await ctx.settings.set('consent_id', accepted.id)
await ctx.settings.set('provider', provider)
}
return NextResponse.json({
consentId: accepted.id,
authType: providerInfo.authType,
alreadyConnected: true,
})
}
// Check for status 0 consents that already have tokens stored (credentials submitted but migration not completed)
const pending = existingConsents.filter(c => c.provider === provider && c.status === 0)
if (pending.length > 0) {
const svc = createSvc()
for (const p of pending) {
const { data: tokens } = await svc
.from('provider_consent_tokens')
.select('id')
.eq('consent_id', p.id)
.limit(1)
if (tokens && tokens.length > 0) {
// Tokens exist — reuse this consent, skip credential entry
if (ctx?.settings) {
await ctx.settings.set('consent_id', p.id)
await ctx.settings.set('provider', provider)
}
return NextResponse.json({
consentId: p.id,
authType: providerInfo.authType,
alreadyConnected: true,
})
}
}
// No tokens found — clean up abandoned consents
for (const p of pending) {
await deleteConsent(p.id)
}
}
// Create new consent
const consent = await createConsent(
companyId,
provider,
`gnubok-migration-${user.id}`,
orgNumber,
companyName
)
if (ctx?.settings) {
await ctx.settings.set('consent_id', consent.id)
await ctx.settings.set('provider', provider)
}
if (providerInfo.authType === 'oauth') {
// Generate OTC for OAuth flow
const otc = await generateOtc(consent.id)
// Build the OAuth callback URL. Prefer a provider-specific override
// (e.g. VISMA_REDIRECT_URI) when set — this lets dev environments
// route through a single registered URI (production) rather than
// requiring every ngrok URL to be registered on the OAuth client.
// Falls back to NEXT_PUBLIC_APP_URL + the canonical callback path.
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const providerRedirectEnv =
provider === 'visma'
? process.env.VISMA_REDIRECT_URI
: provider === 'fortnox'
? process.env.FORTNOX_REDIRECT_URI
: undefined
const callbackUrl =
providerRedirectEnv && providerRedirectEnv.trim().length > 0
? providerRedirectEnv
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Encode consentId + provider in state
const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider })
const stateEncoded = Buffer.from(statePayload).toString('base64url')
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
return NextResponse.json({
consentId: consent.id,
authType: 'oauth',
authUrl: url,
otcCode: otc.code,
})
} else {
// Token-based providers: consent is ready for direct use
return NextResponse.json({
consentId: consent.id,
authType: 'token',
})
}
} catch (error) {
log.error('arcim connect failed', error as Error, { provider })
return errorResponseFromCode('PROVIDER_CONNECT_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
// ── Submit API token for token-based providers (Bokio, etc.) ──
{
method: 'POST',
path: '/submit-token',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// The caller's tenant — NOT the provider-side company id below.
const ownerCompanyId = ctx?.companyId ?? user.id
// `companyId` in the body is the PROVIDER-side company identifier
// (BL User-Key / Briox account ID / Bokio company GUID).
const { consentId, provider, apiToken, companyId: providerCompanyId } = await request.json() as {
consentId: string
provider: ArcimProvider
apiToken: string
companyId?: string
}
if (!consentId || !provider) {
return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
details: { fields: ['consentId', 'provider'], reason: 'required' },
})
}
// BL uses server-side client credentials — only needs companyId
if (provider !== 'bjornlunden' && !apiToken) {
return errorResponseFromCode('PROVIDER_TOKEN_REQUIRED', moduleLog, {
details: { provider },
})
}
// Briox needs the account ID (the /token clientid param) alongside
// the application token; Bokio/BL need their company GUID.
if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox') && !providerCompanyId) {
return errorResponseFromCode('PROVIDER_COMPANY_ID_REQUIRED', moduleLog, {
details: { provider },
})
}
try {
await submitProviderToken(
consentId,
provider,
apiToken || 'client_credentials',
providerCompanyId,
ownerCompanyId,
)
return NextResponse.json({ success: true, consentId })
} catch (error) {
log.error('arcim submit-token failed', error as Error, { provider })
// Consent missing or owned by another company — same 404 either way.
if (error instanceof ConsentNotFoundError) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
details: { consentId },
})
}
// Wrong credentials (provider actively rejected them) — tell the
// user to re-check the pasted values instead of a generic 500.
if (error instanceof ProviderTokenInvalidError) {
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
details: { provider, reason: error.message },
})
}
return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
// ── OAuth callback ────────────────────────────────────────────
{
method: 'GET',
path: '/callback',
skipAuth: true,
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const url = new URL(request.url)
const code = url.searchParams.get('code')
const stateRaw = url.searchParams.get('state')
const oauthError = url.searchParams.get('error')
const oauthErrorDescription = url.searchParams.get('error_description')
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
// JSON-encode for safe embedding inside <script>. Escapes quotes/unicode
// and `</` so the value can't break out of the script tag.
const jsLiteral = (value: unknown) =>
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
const respondWithError = (reason: string) => {
const fallbackUrl = new URL(`${appUrl}/import`)
fallbackUrl.searchParams.set('migration', 'error')
fallbackUrl.searchParams.set('reason', reason)
const escapedReason = reason
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(fallbackUrl.toString())};
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html' },
})
}
// Provider returned an OAuth error (user cancelled, missing API
// subscription on the Fortnox side, invalid scope, etc.)
if (oauthError) {
log.error('OAuth callback returned provider error', {
error: oauthError,
errorDescription: oauthErrorDescription,
hasCode: !!code,
hasState: !!stateRaw,
})
return respondWithError(translateOAuthError(oauthError, oauthErrorDescription))
}
if (!code || !stateRaw) {
log.error('OAuth callback missing code or state', {
hasCode: !!code,
hasState: !!stateRaw,
queryKeys: Array.from(url.searchParams.keys()),
})
return respondWithError('Återanropet saknade code eller state. Försök igen.')
}
try {
let consentId: string | null = null
let provider: ArcimProvider | null = null
try {
const decoded = JSON.parse(Buffer.from(stateRaw, 'base64url').toString())
if (decoded.consentId && decoded.provider) {
consentId = decoded.consentId
provider = decoded.provider as ArcimProvider
}
} catch {
// Legacy fallback
}
if (!consentId || !provider) {
consentId = ctx?.settings
? await ctx.settings.get<string>('consent_id')
: null
provider = ctx?.settings
? await ctx.settings.get<ArcimProvider>('provider')
: null
}
if (!consentId || !provider) {
log.error('OAuth callback could not resolve consent or provider', {
hasConsentId: !!consentId,
hasProvider: !!provider,
})
return respondWithError('Ingen aktiv migrationssession hittades. Starta om anslutningen.')
}
const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Exchange OAuth code directly with the provider
await exchangeAuthToken(consentId, provider, code, redirectUri)
// Return an HTML page that notifies the opener tab and closes itself
const successUrl = `${appUrl}/import?migration=connected&consentId=${encodeURIComponent(consentId)}`
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-success', consentId: ${jsLiteral(consentId)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(successUrl)};
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html' },
})
} catch (error) {
log.error('OAuth callback exchange failed', error)
const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.'
return respondWithError(reason)
}
},
},
// ── Preview: fetch company info + SIE stats before migration ──
{
method: 'GET',
path: '/preview',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
const url = new URL(request.url)
const consentId = url.searchParams.get('consentId')
if (!consentId) {
return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
details: { field: 'consentId', reason: 'required' },
})
}
try {
const consent = await getConsent(consentId)
if (consent.status !== 0 && consent.status !== 1) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, {
details: { consentId, status: consent.status },
})
}
// Resolve consent to get access token
const resolved = await resolveConsent(companyId, consentId)
const provider = resolved.consent.provider as ProviderName
// Fetch company info directly from provider
let mapped = null
try {
const companyInfo = await fetchCompanyInfoDirect(provider, resolved.accessToken, resolved.providerCompanyId)
mapped = companyInfo ? mapCompanyInfo(companyInfo) : null
} catch (err) {
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
}
// Try to fetch SIE data (Fortnox and Briox serve SIE over the API)
let sieAvailable = false
let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
if (providerSupportsSie(provider)) {
try {
log.info(`Fetching SIE export from ${provider} for consent ${consentId}...`)
// Fetch SIE type 4 for the most recent allowed year to get stats
const { files, availableYears } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
resolved.providerCompanyId,
{ latestOnly: true },
)
if (files.length > 0) {
const parsed = parseSIEFile(files[files.length - 1].rawContent)
sieAvailable = true
sieStats = {
accountCount: parsed.accounts.length,
transactionCount: parsed.vouchers.length,
fiscalYears: availableYears,
}
}
} catch (err) {
log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
}
}
// Check if the company already has completed SIE imports (from manual upload)
const { count: sieImportCount } = await supabase
.from('sie_imports')
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'completed')
return NextResponse.json({
consent: {
id: consent.id,
provider: consent.provider,
status: consent.status,
companyName: consent.companyName,
},
companyInfo: mapped,
sieAvailable,
sieStats,
hasSieData: (sieImportCount ?? 0) > 0,
})
} catch (error) {
log.error('arcim preview failed', error as Error)
// Classify HTTP failures into typed codes so the toast can suggest
// reconnect / retry instead of a generic "preview failed".
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'PROVIDER_PREVIEW_FAILED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
}
},
},
// ── Fetch + parse SIE data for mapping step ───────────────────
{
method: 'GET',
path: '/sie-data',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
const url = new URL(request.url)
const consentId = url.searchParams.get('consentId')
if (!consentId) {
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
}
try {
// Resolve consent
const resolved = await resolveConsent(companyId, consentId)
const provider = resolved.consent.provider as ProviderName
if (!providerSupportsSie(provider)) {
return errorResponseFromCode('PROVIDER_SIE_NOT_SUPPORTED', moduleLog, {
details: { provider },
})
}
// Fetch SIE type 4 for each allowed fiscal year
const { files: sieFiles, failedYears } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
resolved.providerCompanyId,
)
if (sieFiles.length === 0) {
// The allowed window is rolling (current year and the two before
// it) — interpolate the actual range instead of the static
// registry message so the text never goes stale.
const allowedYears = [...getAllowedFiscalYears()].sort((a, b) => a - b)
const range = `${allowedYears[0]}${allowedYears[allowedYears.length - 1]}`
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog, {
messageSv: `Inga räkenskapsår ${range} hittades hos leverantören.`,
messageEn: `No fiscal years available for ${range}.`,
...(failedYears.length > 0 ? { details: { failedYears } } : {}),
})
}
// Parse most recent file for preview/validation
const sieFile = sieFiles[sieFiles.length - 1]
const parsed = parseSIEFile(sieFile.rawContent)
const validation = validateSIEFile(parsed)
if (!validation.valid) {
log.warn(
`arcim sie-data validation failed for ${provider} fiscal year ${sieFile.fiscalYear}: ` +
`${validation.errors.length} error(s) — ${validation.errors.slice(0, 3).join(' | ')}`,
)
return NextResponse.json({
error: 'validation',
message: 'SIE file validation failed',
validation,
}, { status: 400 })
}
// Collect ALL unique accounts across ALL fiscal year files
const allAccountsMap = new Map<string, { number: string; name: string }>()
for (const file of sieFiles) {
const fileParsed = parseSIEFile(file.rawContent)
for (const acc of fileParsed.accounts) {
if (!allAccountsMap.has(acc.number)) {
allAccountsMap.set(acc.number, { number: acc.number, name: acc.name })
}
}
}
const allAccounts = [...allAccountsMap.values()]
.filter(a => !isSystemAccount(a.number))
.map(a => ({ number: a.number, name: a.name }))
// Load existing user mappings
const existingMappings = await loadMappings(supabase, companyId)
const existingRecords = [...existingMappings.values()].map(m => ({
id: '',
user_id: user.id,
source_account: m.sourceAccount,
source_name: m.sourceName,
target_account: m.targetAccount,
confidence: m.confidence,
match_type: m.matchType,
created_at: '',
updated_at: '',
}))
// Suggest mappings
const basAccounts = BAS_REFERENCE.map(b => ({
account_number: b.account_number,
account_name: b.account_name,
}))
const mappings = suggestMappings(allAccounts, basAccounts, existingRecords)
const mappingStats = getMappingStats(mappings)
log.info(`Account mapping: ${allAccounts.length} unique accounts across ${sieFiles.length} files, ${mappingStats.unmapped} unmapped`)
const preview = generateImportPreview(parsed, mappings)
// Detect prior imports by *fiscal period overlap*, not file hash.
// Providers embed the export-time #GEN date in every SIE export so
// the hash always changes between syncs; only the period stays
// stable. A re-sync replaces the prior import for the same period.
const fileStatuses: {
fiscalYear: number
rawContent: string
previousImport: {
importedAt: string | null
fiscalYearStart: string | null
fiscalYearEnd: string | null
} | null
}[] = []
for (const file of sieFiles) {
const fileParsed = parseSIEFile(file.rawContent)
const fyStart = fileParsed.stats.fiscalYearStart
const fyEnd = fileParsed.stats.fiscalYearEnd
let priorImport: {
imported_at: string | null
fiscal_year_start: string | null
fiscal_year_end: string | null
} | null = null
if (fyStart && fyEnd) {
const { data } = await supabase
.from('sie_imports')
.select('imported_at, fiscal_year_start, fiscal_year_end')
.eq('company_id', companyId)
.eq('status', 'completed')
.lte('fiscal_year_start', fyEnd)
.gte('fiscal_year_end', fyStart)
.limit(1)
.maybeSingle()
priorImport = data
}
fileStatuses.push({
fiscalYear: file.fiscalYear,
rawContent: file.rawContent,
previousImport: priorImport
? {
importedAt: priorImport.imported_at,
fiscalYearStart: priorImport.fiscal_year_start,
fiscalYearEnd: priorImport.fiscal_year_end,
}
: null,
})
}
const replacedFileCount = fileStatuses.filter(f => f.previousImport).length
return NextResponse.json({
parsed,
mappings,
mappingStats,
preview,
validation,
rawContent: fileStatuses.map(f => f.rawContent),
fileStatuses: fileStatuses.map(f => ({
fiscalYear: f.fiscalYear,
previousImport: f.previousImport,
// Back-compat for older wizard builds: an `alreadyImported`
// boolean. The new wizard reads `previousImport` directly.
alreadyImported: !!f.previousImport,
importedAt: f.previousImport?.importedAt ?? null,
})),
allImported: false,
newFileCount: fileStatuses.length - replacedFileCount,
replacedFileCount,
// Allowed years whose provider export failed — the wizard warns
// the user before proceeding so an IB/UB gap cannot slip through.
failedYears,
basAccounts: BAS_REFERENCE,
})
} catch (error) {
log.error('arcim sie-data fetch failed', error as Error)
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'PROVIDER_SIE_FETCH_FAILED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
}
},
},
// ── Import SIE data (accounts, balances, vouchers) ────────────
{
method: 'POST',
path: '/import-sie',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
const { rawContent, mappings, options } = await request.json() as {
rawContent: string
mappings: import('@/lib/import/types').AccountMapping[]
options: {
createFiscalPeriod: boolean
importOpeningBalances: boolean
importTransactions: boolean
voucherSeries?: string
updateAccountNames?: boolean
}
}
if (!rawContent || !mappings) {
return NextResponse.json({ error: 'rawContent and mappings are required' }, { status: 400 })
}
try {
const parsed = parseSIEFile(rawContent)
// Validate all accounts are mapped (same as manual upload)
const unmapped = mappings.filter((m: import('@/lib/import/types').AccountMapping) => !m.targetAccount)
if (unmapped.length > 0) {
return NextResponse.json({
error: 'validation',
message: `${unmapped.length} account(s) are not mapped`,
unmappedAccounts: unmapped.map((m: import('@/lib/import/types').AccountMapping) => ({
account: m.sourceAccount,
name: m.sourceName,
})),
}, { status: 400 })
}
// Account creation (and #KONTO renames) happen inside
// executeSIEImport via syncMappedAccounts — the auto-activate block
// that used to live here was a duplicate of that logic.
await saveMappings(supabase, user.id, mappings)
const result = await executeSIEImport(supabase, companyId, user.id, parsed, mappings, {
filename: `migration-sie-${Date.now()}.se`,
fileContent: rawContent,
createFiscalPeriod: options.createFiscalPeriod,
importOpeningBalances: options.importOpeningBalances,
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries,
// Default ON: re-syncs keep account names current with the source
// system (idempotent — equal names are a no-op in the rename pass).
updateAccountNames: options.updateAccountNames ?? true,
// Provider re-sync semantics: a prior completed import for the
// same fiscal year is automatically replaced (its imported
// entries are cancelled) so the user can pull updated data
// without manual cleanup. Manual SIE upload keeps default
// 'block' behavior.
onExistingPeriod: 'replace',
})
log.info('SIE import completed:', {
success: result.success,
journalEntriesCreated: result.journalEntriesCreated,
errors: result.errors.length,
errorDetails: result.errors.slice(0, 10),
})
return NextResponse.json(result)
} catch (error) {
log.error('arcim sie import failed', error as Error)
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'SIE_IMPORT_UNEXPECTED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
}
},
},
// ── Execute entity migration (customers, suppliers, invoices) ──
{
method: 'POST',
path: '/migrate',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
const {
consentId,
importCompanyInfo = true,
importCustomers = true,
importSuppliers = true,
importSalesInvoices = true,
importSupplierInvoices = true,
reconcileVouchers = true,
} = await request.json() as {
consentId: string
importCompanyInfo?: boolean
importCustomers?: boolean
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
reconcileVouchers?: boolean
}
if (!consentId) {
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
}
try {
const consent = await getConsent(consentId)
if (consent.status !== 0 && consent.status !== 1) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, {
details: { consentId, status: consent.status },
})
}
// ── Guard: a completed SIE import is required before entity import ──
// Most providers expose ONLY entity data (customers, suppliers,
// invoices) via API — never the general ledger. Fortnox pulls the GL
// itself via SIE-over-API and is exempt. Briox and Björn Lundén also
// serve SIE over the API, but the wizard runs /import-sie before
// /migrate, so this guard stays satisfied — and keeps protecting
// against a skipped SIE step. Importing entities without the
// SIE-derived ledger (kontoplan,
// ingående balanser, verifikationer) would leave an incomplete
// bokföring under BFL: a subledger with no chart of accounts and no
// opening balances, so every subsequent posting and balance is wrong.
// The wizard surfaces this as an advisory banner, but it must be
// enforced here so the rule cannot be bypassed by a direct API call,
// a skipped wizard step, or a stale client.
if (consent.provider !== 'fortnox') {
const { count: completedSieImports } = await supabase
.from('sie_imports')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'completed')
if (!completedSieImports || completedSieImports < 1) {
return errorResponseFromCode('PROVIDER_SIE_IMPORT_REQUIRED', moduleLog, {
details: { provider: consent.provider },
})
}
}
log.info(`Starting migration for user ${user.id} from ${consent.provider}`)
const results = await executeMigration({
consentId,
companyId,
userId: user.id,
supabase,
importCompanyInfo,
importCustomers,
importSuppliers,
importSalesInvoices,
importSupplierInvoices,
reconcileVouchers,
})
log.info('Migration completed:', results)
// Mark consent as fully accepted now that data has been imported
await acceptConsent(consentId)
return NextResponse.json({ success: true, results })
} catch (error) {
log.error('arcim migration failed', error as Error)
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'PROVIDER_MIGRATE_FAILED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
}
},
},
// ── Reconcile supplier invoices to GL payment vouchers ────────
// Re-runnable maintenance endpoint. The migration runs this automatically as
// its final step, but SIE (the GL) and entity import are two separate HTTP
// requests whose order is UI-driven — so if the GL lands after the entity
// import, or a company was migrated before this feature existed, call this to
// auto-link settled supplier invoices to their existing vouchers. Pass
// { dryRun: true } to preview the plan (incl. items needing manual review)
// without writing.
{
method: 'POST',
path: '/reconcile',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
let dryRun = false
try {
const body = (await request.json()) as { dryRun?: boolean }
dryRun = body?.dryRun === true
} catch {
// empty body is fine — default to a real run
}
try {
const result = await reconcileSupplierInvoiceVouchers({
supabase,
companyId,
userId: user.id,
dryRun,
})
log.info('arcim reconcile completed', {
companyId,
dryRun,
autoLinked: result.autoLinked,
ambiguous: result.ambiguous,
unmatched: result.unmatched,
})
return NextResponse.json({ success: true, dryRun, result })
} catch (error) {
log.error('arcim reconcile failed', error as Error)
return errorResponseFromCode('PROVIDER_MIGRATE_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
// ── Accept consent (mark as fully connected after import) ─────
{
method: 'POST',
path: '/accept',
handler: async (request: Request, ctx?: ExtensionContext) => {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
const { consentId } = await request.json() as { consentId: string }
if (!consentId) {
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
}
// Verify consent belongs to this company before mutating
const { data: consent } = await supabase
.from('provider_consents')
.select('id')
.eq('id', consentId)
.eq('company_id', companyId)
.single()
if (!consent) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog)
}
try {
await acceptConsent(consentId)
return NextResponse.json({ success: true })
} catch (error) {
moduleLog.error('arcim accept failed', error as Error, { consentId })
return errorResponseFromCode('PROVIDER_ACCEPT_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
// ── Disconnect / revoke consent ───────────────────────────────
{
method: 'DELETE',
path: '/disconnect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
const { consentId } = await request.json() as { consentId: string }
if (!consentId) {
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
}
// Verify consent belongs to this company before mutating
const { data: consent } = await supabase
.from('provider_consents')
.select('id')
.eq('id', consentId)
.eq('company_id', companyId)
.single()
if (!consent) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog)
}
try {
await deleteConsent(consentId)
if (ctx?.settings) {
await ctx.settings.clear('consent_id')
await ctx.settings.clear('provider')
}
return NextResponse.json({ success: true })
} catch (error) {
log.error('arcim disconnect failed', error as Error, { consentId })
return errorResponseFromCode('PROVIDER_DISCONNECT_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
],
eventHandlers: [],
}