Files
accounted/extensions/general/arcim-migration/index.ts
T
1976129478 feat(import): let the Fortnox import fetch older fiscal years: the three-year limit becomes a default selection (#2280)
* fix(import): say which fiscal years the Fortnox connection fetches, list the ones left out

Root cause: the guided provider migration fetches SIE only for fiscal years
that start within a rolling three-calendar-year window
(getAllowedFiscalYears in extensions/general/arcim-migration/lib/sie-fetcher.ts:
current year and the two before it). A first, broken year 2022/2023 starts
in 2022 and falls outside the window in 2026, and the wizard said nothing:
not before the import, not after. Users concluded the books were complete,
or that they had done something wrong (issue #2211, second report via
support 2026-08-27).

Fix:
- The fetcher already lists every fiscal year at the source before applying
  the window, so the left-out years are derived from that same list at no
  extra provider call: `omittedYears` (years starting before the window,
  oldest first, with the provider's own from/to dates so a broken year is
  named as "2022-09-01 till 2023-12-31"). Fortnox and Briox year refs now
  carry those bounds; WINT's listYears reports the unfiltered year list.
- GET /preview returns `fiscalYearWindow` and `omittedYears`; GET /sie-data
  returns `omittedYears` next to `failedYears`.
- Wizard, preview step (before the import runs): one muted sentence that the
  direct connection fetches the three latest fiscal years (years starting in
  {fromYear} or later); when years are left out, they are named with a link
  to the SIE import (one SIE file per year under Import, oldest first).
- Wizard, result step: a "Räkenskapsår som inte följde med" section naming
  the omitted years with the same SIE pointer, shown when SIE data was
  imported in the run.
- MCP: the connect_migration tool description, its instructions and the
  onboarding skill claimed the wizard "fetches every fiscal year"; they now
  say three latest, older years via SIE.
- Strings in both messages/sv.json and messages/en.json.

Out of scope: fetching more years through the connection (#2238).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

* feat(import): make the Fortnox import's three-year limit a default selection, not a cap

Root cause, from first principles: the guided provider migration fetched
SIE only for fiscal years starting within a rolling three-calendar-year
window (getAllowedFiscalYears in
extensions/general/arcim-migration/lib/sie-fetcher.ts, introduced in #718
with no stated reason). The window was a silent cap: the wizard never said
it existed, and a first broken year 2022/2023 simply never arrived
(#2211). The user's actual problem is that the year is missing from the
books, so explaining the cap (the previous commit) treats the symptom.

What the window gated, by evidence: only the SIE fetch. Documents already
list every Fortnox financial year and match against the vouchers that
exist locally (import-documents.ts), invoices, customers, suppliers and
assets are not year-gated, and the SIE import itself is one request per
year (hosted function limit 300 s, import_sie_journal_entries
statement_timeout 290 s), so its cost is linear in wall time and bounded
per year regardless of how many years are imported. The only place the
number of years multiplies inside one invocation is /preview and
/sie-data: one SIE export per year (Fortnox client: 15 s per-call timeout,
3 attempts, backoff up to 30 s, 4 req/s) fetched and parsed inside a
single 300 s function, and /sie-data returns every raw file in one
response. The repo holds no measurement of Fortnox's per-year SIE export
latency, and the maintainer's memory is that a full history can take
unreasonably long, so a fixed lift to every year cannot be shown safe for
a long history.

Fix: the window becomes the DEFAULT selection, and the user chooses.
- sie-fetcher: fetchProviderSieFiles takes `years` (explicit start years);
  without it the default window applies. The result carries `sourceYears`
  (every year at the source, oldest first, with the provider's own bounds
  and an inDefaultSelection flag) and `omittedYears` (source years outside
  the selection). Both derived from the year list already fetched: no
  extra provider call. Fortnox and Briox year refs carry their bounds;
  WINT's listYears reports the unfiltered list and its voucher chain
  follows the selection.
- GET /preview returns `sourceYears`; GET /sie-data honours `?years=`
  (validated, deduplicated, oldest first; 400 VALIDATION_ERROR when
  malformed) and returns `omittedYears`. PROVIDER_SIE_NO_YEARS names the
  selection.
- Wizard, preview step: a "Räkenskapsår att hämta" picker with one
  checkbox row per source year, the three latest ticked by default, older
  years marked "tar längre tid"; Fortsätt is disabled with an attn line
  until at least one year is ticked. The selection is sent to /sie-data,
  so each extra year is the user's own wait, and it fails loudly there,
  before any ledger write, if it is too much.
- Wizard, result step: the per-year lines already report exactly what was
  imported; a "Räkenskapsår som inte hämtades" section names the source
  years outside the selection, with the re-run path (documents come
  along) and the SIE path.
- MCP connect_migration description, instructions and the onboarding skill
  say "three latest by default, older years selectable" instead of "every
  fiscal year".
- Strings in both messages/sv.json and messages/en.json.

Closes #2238 as well: the wish to fetch more years is the same control.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

* fix(import): bound the fiscal-year selection per import run

Superagent P2 on #2280: the `years` selection was unbounded and every
selected year is one provider export fetched and parsed inside the single
300 s /sie-data invocation, so nothing bounded the work before provider
calls.

The bound: MAX_SELECTED_FISCAL_YEARS = 6, exported from sie-fetcher.ts with
the derivation. One export call is 15 s per attempt (Fortnox client
FETCH_TIMEOUT_MS), 3 attempts with 1 s and 2 s backoff (retry defaults),
so a year that times out on every attempt costs 48 s; six such years are
288 s, leaving 12 s of the 300 s hosted function for the year listing,
parsing and the response; seven would be 336 s.

Enforced server-side:
- /sie-data refuses a selection of more than the cap with 400
  VALIDATION_ERROR naming the cap, before the consent is resolved, so an
  oversized request does no provider work.
- fetchProviderSieFiles throws FiscalYearSelectionError for a selected year
  the source does not have, right after the year listing and before any
  export; /sie-data maps it to 400 VALIDATION_ERROR naming the year.
- /preview returns maxSelectedYears so the picker enforces the same number
  without a client-side copy: Fortsätt is disabled and an attn line says how
  many can be fetched at once and that older years go in a second run
  (sv + en).

Tests: cap accepted at 6 and refused at 7 with no provider call, unknown
year refused (route and fetcher), the cap's arithmetic, maxSelectedYears
on /preview.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:14:49 +02:00

1821 lines
78 KiB
TypeScript

import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import {
createConsent,
getConsent,
listConsents,
generateOtc,
consumeOAuthState,
getAuthUrl,
exchangeAuthToken,
submitProviderToken,
acceptConsent,
deleteConsent,
resolveConsent,
fetchCompanyInfoDirect,
ProviderTokenInvalidError,
ProviderCompanyMismatchError,
ConsentNotFoundError,
} from './lib/provider-client'
import {
providerSupportsSie,
fetchProviderSieFiles,
getAllowedFiscalYears,
FiscalYearSelectionError,
MAX_SELECTED_FISCAL_YEARS,
type SourceFiscalYear,
} from './lib/sie-fetcher'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import {
FortnoxDocumentScopesRequiredError,
importProviderDocuments,
} from './lib/import-documents'
import { fetchFortnoxAssetPreview } from './lib/import-assets'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import { relinkRegistrationVouchers } from './lib/relink-registration-vouchers'
import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
import { mergeParsedSIEFiles } from '@/lib/import/sie-merge'
import { scanSieForCp1252Artifacts, formatSieArtifactWarning } from '@/lib/import/sie-artifact-scan'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { loadMappings, generateImportPreview, executeSIEImport, findOverlappingPeriodImports } from '@/lib/import/sie-import'
import { buildMappingTargets } from './lib/mapping-targets'
import type { ProviderName } from '@/lib/providers/types'
import { FORTNOX_DOCUMENT_SCOPES_APPROVED } from '@/lib/providers/fortnox/oauth'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
import { getProviderResourceForbiddenMessage } from '@/lib/errors/get-error-message'
import { FortnoxApiError, fortnoxErrorMessage } from '@/lib/providers/fortnox/client'
import { createLogger } from '@/lib/logger'
const moduleLog = createLogger('extensions/arcim-migration')
/**
* The one answer the unauthenticated OAuth callback gives for every state
* failure: forged, unknown, expired, replayed, or pointing at a consent that no
* longer exists. Distinguishing them would turn the callback into a probe for
* other tenants' consent ids.
*/
const STATE_REJECTED_MESSAGE =
'Ingen giltig migrationssession hittades. Starta om anslutningen.'
/**
* A valid state reached the callback but the browser carries no session. The
* state is already spent by then (consumeOAuthState is atomic and runs first),
* so unlike the bank/Stripe callbacks there is nothing to resume after a
* login: the user starts the connect again from the wizard.
*/
const SESSION_MISSING_MESSAGE =
'Ingen inloggad session hittades i det här fönstret. Logga in och starta om anslutningen.'
/**
* 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
}
/**
* Resolve the OAuth callback URL for a provider. Single source of truth for
* BOTH legs of the flow: the redirect_uri sent in the authorization request
* and the redirect_uri sent in the token exchange must be byte-identical, or
* the provider rejects the code exchange (RFC 6749 §4.1.3). The two legs used
* to resolve this independently: authorize honored the FORTNOX_REDIRECT_URI /
* VISMA_REDIRECT_URI override while the exchange hardcoded the
* NEXT_PUBLIC_APP_URL fallback, so any override differing from the fallback
* (dev ngrok URI, or an env var left on the old app domain after a domain
* cutover) silently broke every OAuth connect at the exchange step.
*
* The override exists so dev environments can route through a single
* registered URI instead of registering every ngrok URL on the OAuth client.
*/
/**
* WINT ships dark until the connection is verified against a live WINT
* account (its API is spec-derived, not sandbox-verified) and the relationship
* question is settled. Flipping WINT_MIGRATION_ENABLED=true is the launch
* switch; the rest of the provider is fully wired.
*/
function enabledProviders(): typeof ARCIM_PROVIDERS {
if (process.env.WINT_MIGRATION_ENABLED === 'true') return ARCIM_PROVIDERS
return ARCIM_PROVIDERS.filter(p => p.id !== 'wint')
}
function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string {
const providerRedirectEnv =
provider === 'visma'
? process.env.VISMA_REDIRECT_URI
: provider === 'fortnox'
? process.env.FORTNOX_REDIRECT_URI
: undefined
if (providerRedirectEnv && providerRedirectEnv.trim().length > 0) {
return providerRedirectEnv
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
return `${appUrl}/api/extensions/ext/arcim-migration/callback`
}
/**
* Build a provider OAuth authorization URL bound to an EXISTING consent id.
* Used by both first-time connect and reconnect (token revival): the callback
* runs exchangeAuthToken(consentId, …) which upserts the fresh tokens keyed by
* consent_id, so re-running OAuth against the same consent overwrites a dead
* refresh-token pair in place: no disconnect/recreate needed.
*/
async function buildArcimOAuthUrl(
consentId: string,
provider: ArcimProvider,
initiatedByUserId: string,
options?: { documentScopes?: boolean },
): Promise<string> {
// Server-side state row: consent id, provider (via the consent), the user who
// started the flow, expiry and a consumed marker all live in provider_otc.
// The `state` handed to the provider is that row's opaque random primary
// key, nothing more.
const otc = await generateOtc(consentId, initiatedByUserId)
const callbackUrl = resolveArcimCallbackUrl(provider)
// The state is the one-time code itself: an unguessable pointer to the row
// above. It deliberately encodes NOTHING. The previous base64url JSON payload
// was attacker-authored input the callback trusted, so anyone who learned a
// consent id could redirect their own provider tokens onto that consent.
const { url } = await getAuthUrl(provider, otc.code, callbackUrl, {
documentScopes: options?.documentScopes,
})
return url
}
/**
* Answer a failed provider call with its classified code, falling back to the
* caller's own code when the failure is not provider-shaped.
*
* PROVIDER_RESOURCE_FORBIDDEN carries its message here instead of from the
* shared registry: the useful half is the provider's own sentence naming the
* register it refused ("Saknar behörighet för leverantörsregister."), which
* only the error itself holds. Without the override the code would fall
* through entryFor() to INTERNAL_ERROR and answer 500.
*/
function providerFailureResponse(error: unknown, fallbackCode: string): NextResponse {
const classified = classifyProviderError(error)
const ctx = {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
}
if (classified === 'PROVIDER_RESOURCE_FORBIDDEN') {
const reason = fortnoxErrorMessage(error)
return errorResponseFromCode(classified, moduleLog, {
...ctx,
status: 403,
messageSv: getProviderResourceForbiddenMessage(reason, 'sv'),
messageEn: getProviderResourceForbiddenMessage(reason, 'en'),
})
}
return errorResponseFromCode(classified ?? fallbackCode, moduleLog, ctx)
}
/**
* Map a failed /migrate run to its structured error response. Shared by the
* JSON path (returned as-is) and the NDJSON path (body re-sent as the
* terminal `error` event, since the stream's 200 status is already
* committed). Consent missing or owned by another company: same 404 either way.
*/
function migrateFailureResponse(error: unknown, consentId: string): NextResponse {
if (error instanceof ConsentNotFoundError) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
details: { consentId },
})
}
return providerFailureResponse(error, 'PROVIDER_MIGRATE_FAILED')
}
/**
* 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: enabledProviders() })
},
},
// ── 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,
reconnect,
documentScopes,
} = await request.json() as {
provider: ArcimProvider
companyName?: string
orgNumber?: string
reconnect?: boolean
/**
* Reconnect specifically to grant the voucher-attachment scopes.
* Only the underlag follow-up sets it, so an ordinary connect never
* asks the customer for Arkivplats and Koppla filer.
*/
documentScopes?: boolean
}
if (!provider) {
return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
details: { field: 'provider', reason: 'required' },
})
}
const providerInfo = enabledProviders().find(p => p.id === provider)
if (!providerInfo) {
return errorResponseFromCode('PROVIDER_INVALID', moduleLog, {
details: { provider },
})
}
try {
const { createServiceClient: createSvc } = await import('@/lib/supabase/server')
const existingConsents = await listConsents(companyId)
// Reconnect: an existing connection's stored tokens are dead (refresh
// failed → PROVIDER_AUTH_EXPIRED). Re-run auth against the SAME consent
// so fresh tokens overwrite the dead pair in place: no disconnect, no
// duplicate consent, import history preserved. Bypasses the
// alreadyConnected short-circuit below (which would otherwise skip the
// auth that's the whole point here).
if (reconnect) {
const stale = existingConsents.find(
c => c.provider === provider && (c.status === 0 || c.status === 1),
)
if (stale) {
if (ctx?.settings) {
await ctx.settings.set('consent_id', stale.id)
await ctx.settings.set('provider', provider)
}
if (providerInfo.authType === 'oauth') {
const authUrl = await buildArcimOAuthUrl(stale.id, provider, user.id, {
documentScopes: documentScopes === true,
})
return NextResponse.json({
consentId: stale.id,
authType: 'oauth',
authUrl,
reconnect: true,
})
}
// Token-based providers re-authorize by re-entering credentials
return NextResponse.json({
consentId: stale.id,
authType: 'token',
reconnect: true,
})
}
// No existing consent to revive: fall through to a normal connect.
}
// Reuse existing accepted consent if one exists for this provider
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')
// consent_id is the PK: there is no `id` column. Selecting `id`
// errors silently (only `data` is read), so `tokens` was always
// null and the reuse branch below never fired, deleting valid
// status-0 consents as "abandoned".
.select('consent_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') {
const authUrl = await buildArcimOAuthUrl(consent.id, provider, user.id)
return NextResponse.json({
consentId: consent.id,
authType: 'oauth',
authUrl,
})
} 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. WINT
// reuses the field for the login mail (paired with the password in
// apiToken; both are exchanged for tokens and never stored).
if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox' || provider === 'wint') && !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 },
})
}
// Provider rejected authentication or could not resolve the Bokio
// company: return the actionable problem instead of a generic 500.
if (error instanceof ProviderTokenInvalidError) {
if (error.kind === 'company-not-found') {
return errorResponseFromCode('BOKIO_COMPANY_NOT_FOUND', moduleLog, {
details: { provider, reason: error.message },
})
}
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
details: { provider, reason: error.message },
})
}
// Valid credentials, wrong company: name both org numbers so the user
// can see at a glance which company the token actually opened.
if (error instanceof ProviderCompanyMismatchError) {
return errorResponseFromCode('PROVIDER_COMPANY_MISMATCH', moduleLog, {
details: {
provider,
expectedOrgNumber: error.expectedOrgNumber,
actualOrgNumber: error.actualOrgNumber,
actualCompanyName: error.actualCompanyName,
},
})
}
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, consentId?: string) => {
const fallbackUrl = new URL(`${appUrl}/import`)
fallbackUrl.searchParams.set('migration', 'error')
fallbackUrl.searchParams.set('reason', reason)
if (consentId) fallbackUrl.searchParams.set('consentId', consentId)
const escapedReason = reason
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// The popup deliberately stays open on error: the postMessage is
// dropped whenever the popup's origin differs from the opener's
// (browser targetOrigin/origin checks), and closing anyway turns any
// such config drift into an invisible failure the user can only
// describe as "nothing happens". Leaving the reason on screen keeps
// every error diagnosable; the wizard also shows it when the message
// does arrive.
//
// location.replace, not href: the callback URL carries a one-time
// state that is already spent, so a Back or a reload onto it can
// only fail. no-store keeps it out of the browser cache for the same
// reason.
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
} else {
window.location.replace(${jsLiteral(fallbackUrl.toString())});
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p><p>Du kan stänga detta fönster.</p></body></html>`
return new Response(html, {
status: 200,
headers: {
// charset is required: without it browsers default to Latin-1 and
// render the Swedish text as mojibake (the "rätt behörigheter" bug).
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
})
}
// Provider returned an OAuth error (user cancelled, missing API
// subscription on the Fortnox side, invalid scope, etc.)
if (oauthError) {
// access_denied is the user clicking "avbryt" in the provider's
// consent screen: an expected outcome of an optional flow, so it is
// logged at warn and stays out of the error panel. Every other
// provider error is still an error.
const oauthErrorDetails = {
error: oauthError,
errorDescription: oauthErrorDescription,
hasCode: !!code,
hasState: !!stateRaw,
}
if (oauthError === 'access_denied') {
log.warn('OAuth callback cancelled by the user at the provider', oauthErrorDetails)
} else {
log.error('OAuth callback returned provider error', oauthErrorDetails)
}
let consentId: string | undefined
if (stateRaw) {
try {
consentId = (await consumeOAuthState(stateRaw))?.consentId
} catch (error) {
log.error('OAuth callback could not resolve failed consent', error)
}
}
return respondWithError(
translateOAuthError(oauthError, oauthErrorDescription),
consentId,
)
}
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.')
}
let callbackConsentId: string | undefined
try {
// Single source of truth for who this callback belongs to: the
// server-written provider_otc row, consumed atomically here. The row
// carries the consent; the consent carries the provider. Nothing is
// read from the query string except the opaque state token itself, so
// an attacker who knows a victim's consent id still cannot steer their
// own provider tokens onto it.
const resolvedState = await consumeOAuthState(stateRaw)
if (!resolvedState) {
// Unknown/forged state, expired state, replayed state and deleted
// consent all end here with the SAME message: this route is
// unauthenticated, so telling the caller which one it was would
// disclose whether a given consent exists.
log.error('OAuth callback state rejected', {
hasCode: !!code,
stateLength: stateRaw.length,
})
return respondWithError(STATE_REJECTED_MESSAGE)
}
const { consentId, provider, userId: initiatedByUserId } = resolvedState
// The state proves this callback belongs to a flow WE started; it
// says nothing about who is finishing it. Before the code is
// exchanged, the completing browser's own session must belong to the
// user recorded on the state row at connect time. Otherwise a victim
// lured into approving a consent someone else started has their
// Fortnox/Visma account bound to that someone's consent, and the
// next migration imports the victim's ledger into a stranger's
// company. Checked before callbackConsentId is set: a refused
// completion must not hand the consent id to whoever is refused.
if (!initiatedByUserId) {
// Row minted before provider_otc.user_id existed (or written
// outside generateOtc): nobody to bind to, so nobody may finish it.
// Such rows expire within 10 minutes of the deploy.
log.error('OAuth callback state carries no initiator; refusing', { consentId })
return respondWithError(STATE_REJECTED_MESSAGE)
}
const initiator = await requireFlowInitiator(request, initiatedByUserId, {
flow: 'arcim-migration.callback',
})
if (!initiator.ok) {
log.error('OAuth callback refused: completing session is not the initiator', {
consentId,
reason: initiator.reason,
})
return respondWithError(
initiator.reason === 'no_session'
? SESSION_MISSING_MESSAGE
: FLOW_INITIATOR_MISMATCH_MESSAGE,
)
}
callbackConsentId = consentId
// Must match the redirect_uri the authorization request was built
// with, so both come from resolveArcimCallbackUrl.
const redirectUri = resolveArcimCallbackUrl(provider)
// 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)}`
// location.replace + no-store: see respondWithError above. The state
// is spent the moment consumeOAuthState returns, and prod caught the
// consequence of leaving the URL in history: a second delivery 19
// seconds after a successful connect, answered with a red "ingen
// giltig migrationssession" about a connection that had just worked.
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.replace(${jsLiteral(successUrl)});
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
return new Response(html, {
status: 200,
headers: {
// charset is required: without it browsers default to Latin-1 and
// render the Swedish text as mojibake (the "rätt behörigheter" bug).
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
})
} catch (error) {
log.error('OAuth callback exchange failed', error)
// Valid tokens for the WRONG company: exchangeAuthToken stored
// nothing. Show the user-facing sentence from the error registry
// (the same one /submit-token answers with for Bokio/WINT) instead
// of the English diagnostic on the error object.
if (error instanceof ProviderCompanyMismatchError) {
return respondWithError(
getErrorEntry('PROVIDER_COMPANY_MISMATCH')?.message_sv ?? error.message,
callbackConsentId,
)
}
const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.'
return respondWithError(reason, callbackConsentId)
}
},
},
// ── 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 {
// Company-scoped read: the status below is returned to the caller, so
// an unscoped lookup would let any authenticated user probe another
// tenant's consent id for existence and connection state. A foreign
// consent throws ConsentNotFoundError, same as a nonexistent one.
const consent = await getConsent(consentId, companyId)
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) {
// A missing integration license / inactive API module dooms every
// later call in the wizard too: fail the preview with the typed
// code (via the outer catch) so the user reads the remediation at
// connect time, not after a silently empty migration. Other
// failures stay soft: the preview is still useful without company
// info (e.g. SIE-over-API can work with narrower scopes).
const classified = classifyProviderError(err)
if (classified === 'PROVIDER_API_MODULE_INACTIVE' || classified === 'PROVIDER_LICENSE_MISSING') {
throw 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
// Every fiscal year the source has, with the default selection
// marked: the preview step renders them as the year picker, so the
// user chooses BEFORE the import runs and no year is left out
// silently (issues #2211, #2238). The stats below stay on the
// default selection: that is what the preview fetched.
let sourceYears: SourceFiscalYear[] = []
if (providerSupportsSie(provider)) {
try {
log.info(`Fetching SIE export from ${provider} for consent ${consentId}...`)
// Fetch SIE type 4 for EVERY allowed year, not latestOnly: the
// stats below render as "Hittade X konton och Y verifikationer"
// on the connect step, and a mid-year export has few or zero
// vouchers in the newest year (the One.com migration previewed
// "0 verifikationer" and then imported 4153). Costs one SIE
// export per extra year, the same work /sie-data repeats right
// after: honest numbers are worth it.
const { files, availableYears, sourceYears: source } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
resolved.providerCompanyId,
)
sourceYears = source
if (files.length > 0) {
const merged = mergeParsedSIEFiles(files.map((f) => parseSIEFile(f.rawContent)))
sieAvailable = true
sieStats = {
accountCount: merged.accounts.length,
transactionCount: merged.vouchers.length,
fiscalYears: availableYears,
}
}
} catch (err) {
log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
}
}
// Asset register stats (Fortnox only). Soft: a consent without the
// assets scope (or licence) just omits the line; anything else is
// logged and omitted rather than failing an otherwise good preview.
let assetStats: { total: number; importable: number } | null = null
if (provider === 'fortnox') {
try {
assetStats = await fetchFortnoxAssetPreview(resolved.accessToken)
} catch (err) {
log.info('Asset preview 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,
sourceYears,
// The picker enforces the same cap as /sie-data, read from here
// rather than duplicated in the client.
maxSelectedYears: MAX_SELECTED_FISCAL_YEARS,
assetStats,
hasSieData: (sieImportCount ?? 0) > 0,
})
} catch (error) {
log.error('arcim preview failed', error as Error)
// Consent missing or owned by another company: same 404 either way.
if (error instanceof ConsentNotFoundError) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
details: { consentId },
})
}
// Classify HTTP failures into typed codes so the toast can suggest
// reconnect / retry instead of a generic "preview failed".
return providerFailureResponse(error, 'PROVIDER_PREVIEW_FAILED')
}
},
},
// ── 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 })
}
// `years`: the fiscal years the user ticked in the preview step
// (start years, comma-separated). Absent = the default selection.
// Every selected year is one SIE export fetched and parsed in THIS
// invocation, so the selection is the user's own wait; it fails
// loudly here, before any ledger write, if it is too much.
const yearsParam = url.searchParams.get('years')
let requestedYears: number[] | undefined
if (yearsParam !== null) {
const parsed = yearsParam.split(',').filter(Boolean).map(Number)
const valid = parsed.length > 0 && parsed.every((y) => Number.isInteger(y) && y >= 1900 && y <= 2200)
if (!valid) {
return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
details: { field: 'years', reason: 'comma-separated fiscal year start years' },
})
}
requestedYears = [...new Set(parsed)].sort((a, b) => a - b)
// The resource bound: each selected year is one provider export
// fetched and parsed in this invocation (derivation on the
// constant). Rejected before any provider call.
if (requestedYears.length > MAX_SELECTED_FISCAL_YEARS) {
return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
messageSv: `Högst ${MAX_SELECTED_FISCAL_YEARS} räkenskapsår kan hämtas per körning. Välj färre år; äldre år kan hämtas i en ny körning.`,
messageEn: `At most ${MAX_SELECTED_FISCAL_YEARS} fiscal years can be fetched per run. Select fewer years; older years can be fetched in a second run.`,
details: { field: 'years', reason: 'too_many', max: MAX_SELECTED_FISCAL_YEARS, requested: requestedYears.length },
})
}
}
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 selected fiscal year. A selected year
// the source does not have is refused by the fetcher right after
// the year listing, before any export is fetched.
let fetched: Awaited<ReturnType<typeof fetchProviderSieFiles>>
try {
fetched = await fetchProviderSieFiles(
provider,
resolved.accessToken,
resolved.providerCompanyId,
requestedYears ? { years: requestedYears } : undefined,
)
} catch (err) {
if (err instanceof FiscalYearSelectionError) {
const years = err.unknownYears.join(', ')
return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
messageSv: `Räkenskapsår ${years} finns inte hos leverantören.`,
messageEn: `Fiscal years ${years} do not exist at the provider.`,
details: { field: 'years', reason: 'unknown_year', unknownYears: err.unknownYears },
})
}
throw err
}
const { files: sieFiles, failedYears, omittedYears } = fetched
if (sieFiles.length === 0) {
// Name the selection: the explicit years when the picker sent
// them, else the rolling default window (interpolated so the
// text never goes stale).
const selection = requestedYears ?? [...getAllowedFiscalYears()].sort((a, b) => a - b)
const range = requestedYears
? requestedYears.join(', ')
: `${selection[0]}-${selection[selection.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 every file ONCE: validation, account collection, the
// preview and the returned `parsed` all read from these parses.
const parsedFiles = sieFiles.map((file) => ({
file,
parsed: parseSIEFile(file.rawContent),
}))
// Validate the most recent file's parse: unchanged behavior, so no
// previously accepted dataset is newly rejected. Older years get no
// gate here (they never had one); their problems still surface
// per-file at import time. Validating the merged parse instead
// would be wrong: validateSIEFile assumes single-file invariants
// (balance yearIndexes relative to ONE current year) that the
// merged view deliberately does not preserve.
const newestFile = parsedFiles[parsedFiles.length - 1]
const validation = validateSIEFile(newestFile.parsed)
if (!validation.valid) {
log.warn(
`arcim sie-data validation failed for ${provider} fiscal year ${newestFile.file.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 })
}
// Whole-dataset view across ALL fiscal years. Mid-year provider
// exports have few or zero vouchers in the newest file, so the
// preview counts and the theater model must never be built from
// that file alone ("Hittade 740 konton och 0 verifikationer" while
// 4153 vouchers were about to be imported).
const merged = mergeParsedSIEFiles(parsedFiles.map((p) => p.parsed))
// All unique accounts across all files (first file's name wins,
// same as the merge's account union).
const allAccounts = merged.accounts
.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: '',
}))
// Mapping targets are the company's OWN chart of accounts first,
// then BAS for anything it does not have yet. BAS alone cannot
// express an account the company added outside the standard, so
// such an account was impossible to map onto. See
// ./lib/mapping-targets.
const mappingTargets = await buildMappingTargets(supabase, companyId)
const mappings = suggestMappings(allAccounts, mappingTargets, existingRecords)
const mappingStats = getMappingStats(mappings)
log.info(`Account mapping: ${allAccounts.length} unique accounts across ${sieFiles.length} files, ${mappingStats.unmapped} unmapped`)
const preview = generateImportPreview(merged, 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, parsed: fileParsed } of parsedFiles) {
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) {
// Newest first: several completed rows can overlap the same
// year (manual upload + provider sync, or residue from
// partially deleted data). The unordered .limit(1) this
// replaces picked an arbitrary row, so the wizard could show
// a stale "ersätter tidigare import från <date>" (issue #1667).
// Import-time replace resolves ALL rows regardless of which
// one is displayed here.
const overlapping = await findOverlappingPeriodImports(
supabase, companyId, fyStart, fyEnd
)
priorImport = overlapping[0] ?? null
}
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({
// The merged whole-dataset parse: SIEData.parsed feeds the
// migration theater (buildTheaterModel), which must see every
// fiscal year, not just the newest file.
parsed: merged,
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,
// Source years outside the selection: the result step names them
// so nobody believes the books are complete (#2211).
omittedYears,
basAccounts: mappingTargets,
})
} catch (error) {
log.error('arcim sie-data fetch failed', error as Error)
return providerFailureResponse(error, 'PROVIDER_SIE_FETCH_FAILED')
}
},
},
// ── 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)
// Mojibake tripwire (warn, never block). This handler receives SIE
// as an ALREADY-DECODED string, so an upstream that decoded CP437
// bytes as windows-1252 has baked the corruption in before we ever
// see it (the retired Arcim Sync gateway did exactly that on
// 2026-03-17). Flag the signature so it can never again land
// silently in posted entries; the import itself proceeds untouched.
const artifactScan = scanSieForCp1252Artifacts(parsed)
if (artifactScan.flagged) {
log.warn('import-sie: CP1252 mojibake artifacts in SIE text', {
artifactCount: artifactScan.artifactCount,
samples: artifactScan.samples,
})
}
// 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. Mapping
// persistence ALSO happens inside executeSIEImport, with the
// correct companyId + userId and non-fatal warning handling: the
// direct saveMappings call that used to sit here passed user.id in
// the companyId slot, which throws on RLS/FK now that the helper
// surfaces upsert failures, 500ing every provider-migration import
// before a single voucher was written. Do not re-add it.
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',
})
// Surface the tripwire on the result the workspace UI already
// renders (its "Remaining warnings" card shows result.warnings).
if (artifactScan.flagged) {
result.warnings.push(formatSieArtifactWarning(artifactScan))
}
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)
return providerFailureResponse(error, 'SIE_IMPORT_UNEXPECTED')
}
},
},
// ── 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,
// New option: an omitted field must behave exactly as it did before
// this option existed, so it defaults OFF. An older client that omits
// it neither imports assets nor trips the SIE guard below; the wizard
// always sends it explicitly.
importAssets = false,
reconcileVouchers = true,
} = await request.json() as {
consentId: string
importCompanyInfo?: boolean
importCustomers?: boolean
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
importAssets?: boolean
reconcileVouchers?: boolean
}
if (!consentId) {
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
}
try {
// Company-scoped read: the status below is returned to the caller, so
// an unscoped lookup would let any authenticated user probe another
// tenant's consent id for existence and connection state. A foreign
// consent throws ConsentNotFoundError, same as a nonexistent one.
const consent = await getConsent(consentId, companyId)
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 ──
// Provider APIs expose ONLY entity data (customers, suppliers,
// invoices): never the general ledger. The GL (kontoplan, ingående
// balanser, verifikationer) arrives via SIE, either uploaded by the
// user or, for Fortnox, Briox, Björn Lundén and WINT, pulled over the
// API by the wizard's /import-sie phase. Importing entities without
// that ledger 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 rule is "a completed SIE import must EXIST for the company",
// not "must be part of this run": an entities-only re-run after an
// earlier full migration passes. No provider is exempt. Fortnox used
// to be, on the assumption that the wizard always runs SIE-over-API
// first, but the wizard lets the user uncheck "Bokföringsdata (SIE)"
// while keeping entities checked (#2000), and a direct API call or a
// stale client can skip it regardless.
//
// Company info (name, org number, VAT number) writes no accounts,
// balances or subledger rows, so a run that imports only that is
// not gated.
// The asset register counts as entity data for this gate: its rows
// carry BAS account triples and depreciation plans that only mean
// something against an imported chart of accounts.
const importsEntities = importCustomers ||
importSuppliers ||
importSalesInvoices ||
importSupplierInvoices ||
importAssets
const { count: completedSieImports } = importsEntities
? await supabase
.from('sie_imports')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'completed')
: { count: null }
if (importsEntities && (!completedSieImports || completedSieImports < 1)) {
// Providers that serve SIE over the API have no file to upload:
// point the user at the wizard checkbox instead of the static
// "ladda upp en SIE-fil" text. Same code and status either way so
// the wizard's error path renders it unchanged.
const sieViaApi = ARCIM_PROVIDERS.some(
(p) => p.id === consent.provider && p.sieViaApi,
)
return errorResponseFromCode('PROVIDER_SIE_IMPORT_REQUIRED', moduleLog, {
details: { provider: consent.provider },
...(sieViaApi
? {
messageSv:
'Bokföringsdata (SIE) måste importeras först. Kryssa i "Bokföringsdata (SIE)" i guiden så att kontoplan, ingående balanser och verifikationer hämtas innan kunder, leverantörer, fakturor och anläggningstillgångar importeras.',
messageEn:
'A completed SIE import is required first. Tick "Bokföringsdata (SIE)" in the wizard so the chart of accounts, opening balances and verifications are fetched before customers, suppliers, invoices and fixed assets are imported.',
}
: {}),
})
}
log.info(`Starting migration for user ${user.id} from ${consent.provider}`)
const migrationOptions = {
consentId,
companyId,
userId: user.id,
supabase,
importCompanyInfo,
importCustomers,
importSuppliers,
importSalesInvoices,
importSupplierInvoices,
importAssets,
reconcileVouchers,
}
// Streaming mode (the migration wizard opts in via Accept): one
// NDJSON line per orchestrator progress event, then a terminal
// `done` or `error` line. Errors after the stream opens cannot
// change the HTTP status, so the terminal `error` line carries the
// same structured envelope the JSON path answers with. Callers
// without the Accept header keep the original JSON contract.
if ((request.headers.get('accept') ?? '').includes('application/x-ndjson')) {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: Record<string, unknown>) => {
try {
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
} catch {
// Reader cancelled (tab closed, navigation). The migration
// keeps running server-side; we just stop narrating.
}
}
try {
const results = await executeMigration({
...migrationOptions,
onProgress: (p) => send({
kind: 'progress',
status: p.status,
currentStep: p.currentStep,
progress: p.progress,
}),
})
log.info('Migration completed:', results)
// Mark consent as fully accepted now that data has been imported
await acceptConsent(consentId)
send({ kind: 'done', success: true, results })
} catch (error) {
log.error('arcim migration failed', error as Error)
const envelope = await migrateFailureResponse(error, consentId).json()
send({ kind: 'error', ...envelope })
} finally {
try {
controller.close()
} catch {
// Already closed because the reader cancelled; close()
// would otherwise reject start() as an unhandled rejection.
}
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
},
})
}
const results = await executeMigration(migrationOptions)
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)
return migrateFailureResponse(error, consentId)
}
},
},
// ── 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.
//
// Pass { consentId } to ALSO re-link registration vouchers (the verifikat
// that BOOKED each invoice). The imported rows do not store the provider's
// voucher ref, so that pass re-fetches both registers from the provider
// through the given consent; without a consentId it is skipped and the
// response carries no `registrationLinks`. The consent is validated
// (company-scoped) BEFORE the payment reconcile writes anything, so a
// wrong id is a clean 404; a provider failure during the relink itself is
// reported beside the payment result, which was already persisted, as
// `registrationLinksError` rather than by discarding that result.
{
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
let consentId: string | null = null
try {
const body = (await request.json()) as { dryRun?: boolean; consentId?: unknown }
dryRun = body?.dryRun === true
consentId = typeof body?.consentId === 'string' && body.consentId ? body.consentId : null
} catch {
// empty body is fine: default to a real run
}
if (consentId) {
// A foreign consent throws the same ConsentNotFoundError as a
// nonexistent one (no cross-tenant existence oracle).
try {
await getConsent(consentId, companyId)
} catch (error) {
log.error('arcim reconcile: consent lookup failed', error as Error)
return migrateFailureResponse(error, consentId)
}
}
let result: Awaited<ReturnType<typeof reconcileSupplierInvoiceVouchers>>
try {
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,
})
} catch (error) {
log.error('arcim reconcile failed', error as Error)
return errorResponseFromCode('PROVIDER_MIGRATE_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
if (!consentId) {
return NextResponse.json({ success: true, dryRun, result })
}
try {
const registrationLinks = await relinkRegistrationVouchers({
supabase,
companyId,
consentId,
dryRun,
})
log.info('arcim registration relink completed', {
companyId,
dryRun,
providerInvoices: registrationLinks.providerInvoices,
matched: registrationLinks.matched,
linked: registrationLinks.linked,
refNotFetched: registrationLinks.refNotFetched,
ambiguous: registrationLinks.ambiguous,
amountMismatch: registrationLinks.amountMismatch,
})
return NextResponse.json({ success: true, dryRun, result, registrationLinks })
} catch (error) {
// resolveConsent throws plain `{ status, message }` objects for a
// consent that vanished or lost its tokens between the check above
// and here; classifyProviderError handles the provider-side ones.
log.error('arcim registration relink failed', error as Error)
const status = typeof error === 'object' && error !== null && 'status' in error
? (error as { status?: unknown }).status
: undefined
const code = error instanceof ConsentNotFoundError || status === 404
? 'PROVIDER_CONSENT_NOT_FOUND'
: classifyProviderError(error) ?? 'PROVIDER_MIGRATE_FAILED'
return NextResponse.json({
success: true,
dryRun,
result,
registrationLinks: null,
registrationLinksError: { code },
})
}
},
},
// ── Import provider underlag (receipts) and link to verifikat ──
// Best-effort, re-runnable. Kept off the migration's critical path because
// the Bokio and Fortnox APIs are rate-limited and a full receipt sweep can
// issue hundreds of download calls. Resolves each receipt's verifikat via
// the SIE-preserved provider voucher number and archives it idempotently.
// Fortnox consents need archive and connectfile scopes. Pass
// { dryRun: true } to preview the match plan without downloading or writing.
{
method: 'POST',
path: '/import-documents',
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 consentId: string | undefined
let dryRun = false
let cursor: string | null = null
try {
const body = (await request.json()) as {
consentId?: string
dryRun?: boolean
cursor?: string
}
consentId = body?.consentId
dryRun = body?.dryRun === true
// Resume point from a previous partial call (the last handled
// provider attachment id, see import-documents.ts); anything else
// restarts from the top, which is always safe: already-archived
// receipts are skipped by hash.
cursor =
typeof body?.cursor === 'string' && body.cursor.length > 0 && body.cursor.length <= 256
? body.cursor
: null
} catch {
// empty/invalid body: consentId check below rejects it
}
if (!consentId) {
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
}
try {
const result = await importProviderDocuments({
supabase,
companyId,
userId: user.id,
consentId,
dryRun,
cursor,
})
log.info('arcim import-documents completed', {
companyId,
dryRun,
cursor,
total: result.total,
scanned: result.scanned,
linked: result.linked,
skipped: result.skipped,
unmatched: result.unmatched,
failed: result.failed,
partial: result.partial,
nextCursor: result.nextCursor,
})
return NextResponse.json({ success: true, dryRun, result })
} catch (error) {
// "400 Bad Request" alone told us nothing when a live Fortnox
// discovery failed (2026-08-20): keep status, Fortnox's own
// message and a body excerpt in the log, and hand the message
// to the UI so the user sees what the source system said.
const providerStatus = error instanceof FortnoxApiError ? error.statusCode : undefined
const providerMessage = fortnoxErrorMessage(error)
log.error('arcim import-documents failed', error as Error, {
providerStatus,
providerMessage,
providerBody:
error instanceof FortnoxApiError ? error.body?.slice(0, 500) : undefined,
})
if (error instanceof FortnoxDocumentScopesRequiredError) {
// Reconnecting only helps once the connect request actually asks
// for Arkiv and Koppla fil. While it does not, say so plainly
// instead of sending the user around a loop that cannot succeed.
return errorResponseFromCode(
FORTNOX_DOCUMENT_SCOPES_APPROVED
? 'PROVIDER_DOCUMENT_SCOPES_REQUIRED'
: 'PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE',
moduleLog,
{ status: 403 },
)
}
return errorResponseFromCode('PROVIDER_IMPORT_DOCUMENTS_FAILED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
...(providerStatus ? { providerStatus } : {}),
...(providerMessage ? { providerMessage } : {}),
},
})
}
},
},
// ── 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: [],
}