* feat(import): stream real Arcim migration progress as NDJSON (#1485) * feat(import): stream real Arcim migration progress as NDJSON The /migrate route ran the orchestrator to completion and answered with one JSON blob, so the wizard faked its progress bar: a hardcoded 55% anchor and a static step label for a phase that can take minutes. The orchestrator has had a real onProgress channel (eight emit points with Swedish step labels and anchors) since it was written; the route just never passed it. Now a request with Accept: application/x-ndjson gets a streamed response: one line per orchestrator progress event, then a terminal done line with the results or an error line carrying the same structured envelope the JSON path returns (the 200 status is already committed once the stream opens). Callers without the header keep the original single-JSON contract, so pre-deploy tabs and the existing error-mapping tests are untouched. The wizard opts in, drives MigratingStep from the real labels and anchors (mapped onto the 55-100 slice of the wizard bar), and treats a dropped connection as unconfirmed rather than failed, since the migration keeps running server-side and a blind retry could double-import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the opt-in NDJSON streaming decision for /migrate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat(import): mount the knowledge-graph theater on the Arcim migrating step The migration wizard already holds the fully parsed SIE client-side (SIEData.parsed from /sie-data), so the same TheaterCanvas that carries the /import flow can build the company's knowledge graph while the migration runs: no server change, and the plain progress card stays as the fallback whenever no parsed SIE exists (e.g. providers without SIE). Unlike /import's fixed narration script, the wizard knows exactly what the server is doing: phase 1 posts one SIE file at a time and phase 2 streams the orchestrator's real progress events. ArcimMigrationTheater therefore narrates by printing those real step labels once each as they arrive, and keys the canvas to the same milestones: the GL skeleton (rings, buckets, accounts) builds during the journal writes, counterparty waves attach while customers and suppliers import, and reconciliation pulses. Real progress bar and elapsed counter stay visible throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): review fixes on the migration theater and stream close CodeRabbit on #1486: (1) the aria-live region wrapped the per-second elapsed counter, so a screen reader re-announced the timer every second and drowned out the real step labels; the live region now covers only the narration list and the timer row is aria-hidden (the Progress bar exposes its own ARIA value). (2) controller.close() in the stream's finally block throws if the reader already cancelled, escaping start() as an unhandled rejection; now guarded like send(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1412 lines
58 KiB
TypeScript
1412 lines
58 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 } from './lib/sie-fetcher'
|
|
import { mapCompanyInfo } from './lib/entity-mapper'
|
|
import { executeMigration } from './lib/migration-orchestrator'
|
|
import { importProviderDocuments } from './lib/import-documents'
|
|
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 } 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')
|
|
|
|
/**
|
|
* 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.'
|
|
|
|
/**
|
|
* 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): Promise<string> {
|
|
// Server-side state row: consent id, provider (via the consent), 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)
|
|
|
|
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)
|
|
return url
|
|
}
|
|
|
|
/**
|
|
* 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 },
|
|
})
|
|
}
|
|
const classified = classifyProviderError(error)
|
|
return errorResponseFromCode(classified ?? 'PROVIDER_MIGRATE_FAILED', moduleLog, {
|
|
details: {
|
|
reason: error instanceof Error ? error.message : 'unknown',
|
|
classified: classified ?? 'unclassified',
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 } = await request.json() as {
|
|
provider: ArcimProvider
|
|
companyName?: string
|
|
orgNumber?: string
|
|
reconnect?: 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)
|
|
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)
|
|
|
|
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 },
|
|
})
|
|
}
|
|
// 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 },
|
|
})
|
|
}
|
|
// 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) => {
|
|
const fallbackUrl = new URL(`${appUrl}/import`)
|
|
fallbackUrl.searchParams.set('migration', 'error')
|
|
fallbackUrl.searchParams.set('reason', reason)
|
|
|
|
const escapedReason = reason
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
|
|
// 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.
|
|
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.href = ${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: { '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 {
|
|
// 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 } = resolvedState
|
|
|
|
// 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)}`
|
|
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 {
|
|
// 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
|
|
|
|
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)
|
|
// 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".
|
|
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. 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',
|
|
})
|
|
|
|
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 {
|
|
// 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 ──
|
|
// 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 migrationOptions = {
|
|
consentId,
|
|
companyId,
|
|
userId: user.id,
|
|
supabase,
|
|
importCompanyInfo,
|
|
importCustomers,
|
|
importSuppliers,
|
|
importSalesInvoices,
|
|
importSupplierInvoices,
|
|
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.
|
|
{
|
|
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' },
|
|
})
|
|
}
|
|
},
|
|
},
|
|
|
|
// ── Import provider underlag (receipts) and link to verifikat ──
|
|
// Best-effort, re-runnable. Kept off the migration's critical path: the
|
|
// Bokio document API is rate-limited (200 req/60s) and a full receipt
|
|
// sweep issues hundreds of download calls, which would blow the 300s
|
|
// migration window. Pages /uploads, resolves each receipt's verifikat via
|
|
// the SIE-preserved Bokio voucher number, and archives it idempotently
|
|
// (skips content already stored for the company). 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
|
|
try {
|
|
const body = (await request.json()) as { consentId?: string; dryRun?: boolean }
|
|
consentId = body?.consentId
|
|
dryRun = body?.dryRun === true
|
|
} 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,
|
|
})
|
|
log.info('arcim import-documents completed', {
|
|
companyId,
|
|
dryRun,
|
|
scanned: result.scanned,
|
|
linked: result.linked,
|
|
skipped: result.skipped,
|
|
unmatched: result.unmatched,
|
|
failed: result.failed,
|
|
})
|
|
return NextResponse.json({ success: true, dryRun, result })
|
|
} catch (error) {
|
|
log.error('arcim import-documents failed', error as Error)
|
|
return errorResponseFromCode('PROVIDER_IMPORT_DOCUMENTS_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: [],
|
|
}
|