* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2) Reversed entries (storno) must appear alongside their original posted entries in reports for a complete audit trail. Previously, filtering by status='posted' excluded them, causing discrepancies when corrections had been made. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: semi-manual invoice payment booking with editable journal lines When marking an invoice as paid, users now see a dialog where they can: - Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.) - Review and edit the proposed journal entry lines before committing - The happy path remains fast — lines are pre-filled correctly Implementation: - Pure proposePaymentLines() function for line computation (accrual + cash) - PaymentBookingDialog with AccountCombobox, balance validation, date picker - API accepts optional custom lines, falls back to auto-generation without them - 18 tests (8 unit + 10 API) all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — validation fallback, balance check, error handling - P1: Return 400 on invalid body instead of silently falling back to auto-generated lines (split JSON parse from schema validation) - P1: Add server-side balance check for custom lines before committing (debit must equal credit, totalDebit > 0) - P2: Wrap PaymentBookingDialog init() in try/catch with toast on failure and auto-close instead of silent empty state - Add 2 new tests: unbalanced lines → 400, invalid schema → 400 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: OAuth callback redirect for local dev and timeout resilience - Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth callbacks work on localhost (not just production) - Encode consentId/provider in OAuth state (base64url JSON) so the callback doesn't depend on session storage - Add skipAuth flag to extension API routes for OAuth callbacks (external provider redirects have no user session cookie) - Wrap AbortError in descriptive timeout messages in arcim-client - Make preview endpoint resilient to partial failures (company info and SIE fetch are individually non-blocking) - Simplify login page (remove unused magic link auth mode) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: create journal entry before marking invoice as paid Move journal entry creation before the invoice status update so that if accounting fails, the invoice is not permanently marked paid without a corresponding entry. Previously the error was silently swallowed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update mark-paid tests for journal-first ordering Reorder mock queue to match new flow (settings before update), update failure test to expect 500 instead of silent success, add try-catch with proper error response in route handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration: - Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT - Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type - Include ruta 30-32 in ruta 49 formula and totalOutputVat summary - Display reverse charge section in reports UI and composition chart - Add comprehensive test coverage for all reverse charge scenarios Improve mobile UX across the app: - Convert nav drawer to bottom sheet with drag handle and safe area padding - Add mobile card layout for PaymentBookingDialog journal lines - Replace settings tab pills with dropdown selector on mobile - Make wizard step indicators responsive (collapsed on mobile) - Ensure all dialog footers stack buttons full-width on mobile - Add 44px minimum touch targets throughout - Make onboarding buttons full-width on mobile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — indentation, query efficiency, tab dedup - Fix misleading try-block indentation in mark-paid route - Filter reversed entries at DB level (.eq('status', 'posted')) instead of fetching then discarding in memory - Extract shared settingsTabs array so mobile Select and desktop TabsList stay in sync automatically Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add resilience fallbacks, Arcim retry logic, and client tests Add FallbackPrompt component and integrate it across banking and migration error states so users always have a manual import escape hatch. Add retry with exponential backoff to Arcim API client for transient failures (429, 502, 503, 504) and timeouts. Expand import page deep-linking with ?mode= parameter. Add persistent error banner on settings page for bank connection failures. Include 18 new tests for the Arcim client covering retry, backoff, pagination, timeout, env validation, and singleton resource unwrapping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — setActiveTab, test cleanup, redundant clearTimeout - Add missing setActiveTab('banking') when handling bank_error query param so the error banner is actually visible (P1) - Guard env-var cleanup with try/finally in arcim-client tests to prevent state leakage on assertion failure (P2) - Only mock retry-range setTimeout delays in backoff test, letting AbortController timers pass through real setTimeout (P2) - Remove redundant clearTimeout in catch block — finally handles it (P2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add BFL-compliant counterparty names to journal descriptions and cancelled entry status Journal descriptions now include customer/supplier names for traceability (e.g. "Kundfaktura 1001, Foretag AB"). Failed draft entries are marked as 'cancelled' instead of deleted, respecting immutability constraints. Includes DB migration for the new journal_entries status value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — Swedish typos, missing source type, trigger and reversal cleanup - Fix Swedish spelling: leverantor → leverantör in all supplier description prefixes - Add supplier_credit_note to supplierSourceTypes in VAT declaration so credit notes correctly reduce reverse-charge bases (ruta 20–24) - Mark orphaned concurrent reversals as cancelled instead of attempting deletion that the immutability trigger blocks - Allow posted → cancelled transition in trigger for orphaned reversal cleanup - Restrict cancelled entry line trigger to DELETE-only (block INSERT/UPDATE) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use main's Step3TaxRegistration (onboarding restructured in PR #54) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: retrigger Greptile review Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add TIC company lookup extension, extension nav items, and legacy toggle fallback Introduces the TIC (Bolagsuppgifter) extension for automatic company data lookup via org number during onboarding. Adds dynamic extension nav items in the sidebar, legacy general extension fallback for toggle checks, and company lookup type definitions in core. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — restore push-notifications, filter nav by toggles, fix timeout error name - Restore push-notifications to LEGACY_GENERAL_EXTENSIONS (was silently dropped when extracting the shared constant) - Remove tic and arcim-migration from legacy defaults (new extensions should not default to enabled for all users) - Filter getExtensionNavItems() against user's enabled extensions so disabled extensions don't appear in the sidebar - Fix AbortSignal.timeout() error name check — Node.js throws TimeoutError, not AbortError Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add all bundled extensions to legacy defaults (email, arcim-migration, tic) Bundled extensions configured in extensions.config.json should default to enabled. Adds email, arcim-migration, and tic alongside the existing legacy defaults so they are accessible without explicit toggle rows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
626 lines
23 KiB
TypeScript
626 lines
23 KiB
TypeScript
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||
import { NextResponse } from 'next/server'
|
||
import {
|
||
createConsent,
|
||
getConsent,
|
||
generateOtc,
|
||
getAuthUrl,
|
||
exchangeAuthToken,
|
||
submitProviderToken,
|
||
deleteConsent,
|
||
fetchCompanyInfo,
|
||
fetchSIEExport,
|
||
} from './lib/arcim-client'
|
||
import { mapCompanyInfo } from './lib/entity-mapper'
|
||
import { executeMigration } from './lib/migration-orchestrator'
|
||
import type { ArcimProvider } from './types'
|
||
import { ARCIM_PROVIDERS } from './types'
|
||
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
|
||
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
|
||
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
|
||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||
|
||
/** Fiscal years we support importing — older data is not needed */
|
||
const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026])
|
||
|
||
/**
|
||
* Arcim Migration extension
|
||
*
|
||
* Migrates bookkeeping data from external Swedish accounting systems
|
||
* (Fortnox, Visma, Bokio, Björn Lundén, Briox) into gnubok via
|
||
* the Arcim Sync unified API gateway.
|
||
*
|
||
* Bookkeeping data (accounts, balances, vouchers) is imported via SIE
|
||
* files fetched from the gateway. Entity data (customers, suppliers,
|
||
* invoices) is imported via the REST API.
|
||
*
|
||
* Required environment variables:
|
||
* - ARCIM_SYNC_GATEWAY_URL
|
||
* - ARCIM_SYNC_API_KEY
|
||
*/
|
||
export const arcimMigrationExtension: Extension = {
|
||
id: 'arcim-migration',
|
||
name: 'Systemmigration (Arcim Sync)',
|
||
version: '1.0.0',
|
||
|
||
apiRoutes: [
|
||
// ── List available providers ───────────────────────────────────
|
||
{
|
||
method: 'GET',
|
||
path: '/providers',
|
||
handler: async () => {
|
||
return NextResponse.json({ providers: ARCIM_PROVIDERS })
|
||
},
|
||
},
|
||
|
||
// ── Start consent flow (create consent + OTC) ─────────────────
|
||
{
|
||
method: 'POST',
|
||
path: '/connect',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
const log = ctx?.log ?? console
|
||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const { provider, companyName, orgNumber } = await request.json() as {
|
||
provider: ArcimProvider
|
||
companyName?: string
|
||
orgNumber?: string
|
||
}
|
||
|
||
if (!provider) {
|
||
return NextResponse.json({ error: 'provider is required' }, { status: 400 })
|
||
}
|
||
|
||
const providerInfo = ARCIM_PROVIDERS.find(p => p.id === provider)
|
||
if (!providerInfo) {
|
||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })
|
||
}
|
||
|
||
try {
|
||
// Create consent in Arcim Sync
|
||
const consent = await createConsent(
|
||
provider,
|
||
`gnubok-migration-${user.id}`,
|
||
orgNumber,
|
||
companyName
|
||
)
|
||
|
||
// Store consent ID in extension settings for this user
|
||
if (ctx?.settings) {
|
||
await ctx.settings.set('consent_id', consent.id)
|
||
await ctx.settings.set('provider', provider)
|
||
}
|
||
|
||
if (providerInfo.authType === 'oauth') {
|
||
// Generate OTC for OAuth flow
|
||
const otc = await generateOtc(consent.id)
|
||
|
||
// Build the OAuth callback URL using the current app URL (localhost in dev, production in prod)
|
||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
|
||
const callbackUrl = `${appUrl}/api/extensions/ext/arcim-migration/callback`
|
||
|
||
// Encode consentId + provider in state so the callback doesn't depend on session storage
|
||
const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider })
|
||
const stateEncoded = Buffer.from(statePayload).toString('base64url')
|
||
|
||
// Pass callbackUrl as redirectUri so Fortnox redirects here (works for localhost and production)
|
||
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
|
||
|
||
return NextResponse.json({
|
||
consentId: consent.id,
|
||
authType: 'oauth',
|
||
authUrl: url,
|
||
otcCode: otc.code,
|
||
})
|
||
} else {
|
||
// Token-based providers: consent is ready for direct use
|
||
return NextResponse.json({
|
||
consentId: consent.id,
|
||
authType: 'token',
|
||
})
|
||
}
|
||
} catch (error) {
|
||
log.error('Failed to create consent:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Failed to connect' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Submit API token for token-based providers (Bokio, etc.) ──
|
||
{
|
||
method: 'POST',
|
||
path: '/submit-token',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
const log = ctx?.log ?? console
|
||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const { consentId, provider, apiToken, companyId } = await request.json() as {
|
||
consentId: string
|
||
provider: ArcimProvider
|
||
apiToken: string
|
||
companyId?: string
|
||
}
|
||
|
||
if (!consentId || !provider) {
|
||
return NextResponse.json(
|
||
{ error: 'consentId and provider are required' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// BL uses server-side client credentials — only needs companyId
|
||
// Bokio and Briox need an API token
|
||
if (provider !== 'bjornlunden' && !apiToken) {
|
||
return NextResponse.json(
|
||
{ error: 'apiToken is required for this provider' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// Bokio and BL require companyId
|
||
if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) {
|
||
return NextResponse.json(
|
||
{ error: 'companyId is required for this provider' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
try {
|
||
await submitProviderToken(consentId, provider, apiToken || 'client_credentials', companyId)
|
||
return NextResponse.json({ success: true, consentId })
|
||
} catch (error) {
|
||
log.error('Submit token error:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Failed to submit token' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── OAuth callback ────────────────────────────────────────────
|
||
// This handler is called by the OAuth provider redirect. It does NOT
|
||
// require user auth — the request comes from the provider, not the user's
|
||
// browser session. Authentication is validated via the OTC code + consent.
|
||
// The 'skipAuth' flag is checked by the extension dispatch route.
|
||
{
|
||
method: 'GET',
|
||
path: '/callback',
|
||
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')
|
||
|
||
if (!code || !stateRaw) {
|
||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 })
|
||
}
|
||
|
||
try {
|
||
// Decode state — supports both new format (base64url JSON with consentId/provider)
|
||
// and legacy format (plain OTC code string)
|
||
let consentId: string | null = null
|
||
let provider: ArcimProvider | null = null
|
||
let otcCode: string = stateRaw
|
||
|
||
try {
|
||
const decoded = JSON.parse(Buffer.from(stateRaw, 'base64url').toString())
|
||
if (decoded.consentId && decoded.provider && decoded.otc) {
|
||
consentId = decoded.consentId
|
||
provider = decoded.provider as ArcimProvider
|
||
otcCode = decoded.otc
|
||
}
|
||
} catch {
|
||
// Legacy: state is just the OTC code — fall back to ctx.settings
|
||
}
|
||
|
||
// Fall back to session-based settings if state didn't contain the data
|
||
if (!consentId || !provider) {
|
||
consentId = ctx?.settings
|
||
? await ctx.settings.get<string>('consent_id')
|
||
: null
|
||
provider = ctx?.settings
|
||
? await ctx.settings.get<ArcimProvider>('provider')
|
||
: null
|
||
}
|
||
|
||
if (!consentId || !provider) {
|
||
return NextResponse.json({ error: 'No active migration session' }, { status: 400 })
|
||
}
|
||
|
||
// The redirectUri used for the token exchange must match the one used in the auth URL
|
||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
|
||
const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback`
|
||
|
||
await exchangeAuthToken(consentId, provider, otcCode, code, redirectUri)
|
||
|
||
// Redirect to import page with success
|
||
return NextResponse.redirect(`${appUrl}/import?migration=connected&consentId=${consentId}`)
|
||
} catch (error) {
|
||
log.error('OAuth callback error:', error)
|
||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
|
||
return NextResponse.redirect(`${appUrl}/import?migration=error`)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Preview: fetch company info + SIE stats before migration ──
|
||
{
|
||
method: 'GET',
|
||
path: '/preview',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
const log = ctx?.log ?? console
|
||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const url = new URL(request.url)
|
||
const consentId = url.searchParams.get('consentId')
|
||
|
||
if (!consentId) {
|
||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||
}
|
||
|
||
try {
|
||
// Verify consent is accepted
|
||
const consent = await getConsent(consentId)
|
||
if (consent.status !== 1) {
|
||
return NextResponse.json(
|
||
{ error: 'Consent is not accepted. Complete OAuth first.' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// Fetch company info for preview (non-blocking — continue if it fails)
|
||
let mapped = null
|
||
try {
|
||
const companyInfo = await fetchCompanyInfo(consentId)
|
||
mapped = companyInfo ? mapCompanyInfo(companyInfo) : null
|
||
} catch (err) {
|
||
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
|
||
}
|
||
|
||
// Try to fetch SIE stats (non-blocking — continue if it fails)
|
||
let sieAvailable = false
|
||
let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
|
||
|
||
try {
|
||
log.info(`Fetching SIE export for consent ${consentId}...`)
|
||
const sieResult = await fetchSIEExport(consentId, 4)
|
||
log.info(`SIE export response: ${sieResult.files.length} files returned`)
|
||
|
||
// Only keep fiscal years we need (2024–2026)
|
||
const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
|
||
if (filteredFiles.length < sieResult.files.length) {
|
||
const skippedYears = sieResult.files
|
||
.filter(f => !ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
|
||
.map(f => f.fiscalYear)
|
||
log.info(`Filtered out fiscal years: ${skippedYears.join(', ')} (only importing ${[...ALLOWED_FISCAL_YEARS].join(', ')})`)
|
||
}
|
||
|
||
if (filteredFiles.length > 0) {
|
||
sieAvailable = true
|
||
const totalAccounts = Math.max(...filteredFiles.map(f => f.accountCount))
|
||
const totalTransactions = filteredFiles.reduce((sum, f) => sum + f.transactionCount, 0)
|
||
const fiscalYears = filteredFiles.map(f => f.fiscalYear).sort()
|
||
sieStats = { accountCount: totalAccounts, transactionCount: totalTransactions, fiscalYears }
|
||
log.info(`SIE stats: ${totalAccounts} accounts, ${totalTransactions} transactions, years: ${fiscalYears.join(', ')}`)
|
||
} else {
|
||
log.info('No SIE files within allowed fiscal years')
|
||
}
|
||
} catch (err) {
|
||
log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
|
||
}
|
||
|
||
return NextResponse.json({
|
||
consent: {
|
||
id: consent.id,
|
||
provider: consent.provider,
|
||
status: consent.status,
|
||
companyName: consent.companyName,
|
||
},
|
||
companyInfo: mapped,
|
||
sieAvailable,
|
||
sieStats,
|
||
})
|
||
} catch (error) {
|
||
log.error('Preview error:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Preview failed' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Fetch + parse SIE data for mapping step ───────────────────
|
||
{
|
||
method: 'GET',
|
||
path: '/sie-data',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
const log = ctx?.log ?? console
|
||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const url = new URL(request.url)
|
||
const consentId = url.searchParams.get('consentId')
|
||
|
||
if (!consentId) {
|
||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||
}
|
||
|
||
try {
|
||
// Fetch SIE from gateway and filter to allowed fiscal years (2024–2026)
|
||
const sieResult = await fetchSIEExport(consentId, 4)
|
||
const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
|
||
if (filteredFiles.length === 0) {
|
||
return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 })
|
||
}
|
||
|
||
// Parse most recent file for preview/validation
|
||
const sieFile = filteredFiles[filteredFiles.length - 1]
|
||
const parsed = parseSIEFile(sieFile.rawContent)
|
||
const validation = validateSIEFile(parsed)
|
||
|
||
// Collect ALL unique accounts across ALL fiscal year files
|
||
// so mappings cover every account that will be imported
|
||
const allAccountsMap = new Map<string, { number: string; name: string }>()
|
||
for (const file of filteredFiles) {
|
||
const fileParsed = parseSIEFile(file.rawContent)
|
||
for (const acc of fileParsed.accounts) {
|
||
if (!allAccountsMap.has(acc.number)) {
|
||
allAccountsMap.set(acc.number, { number: acc.number, name: acc.name })
|
||
}
|
||
}
|
||
}
|
||
// Filter out source-system internal accounts (e.g. Fortnox 0099)
|
||
// that have no BAS equivalent — same as core SIE import
|
||
const allAccounts = [...allAccountsMap.values()]
|
||
.filter(a => !isSystemAccount(a.number))
|
||
.map(a => ({ number: a.number, name: a.name }))
|
||
|
||
// Load existing user mappings
|
||
const existingMappings = await loadMappings(supabase, user.id)
|
||
const existingRecords = [...existingMappings.values()].map(m => ({
|
||
id: '',
|
||
user_id: user.id,
|
||
source_account: m.sourceAccount,
|
||
source_name: m.sourceName,
|
||
target_account: m.targetAccount,
|
||
confidence: m.confidence,
|
||
match_type: m.matchType,
|
||
created_at: '',
|
||
updated_at: '',
|
||
}))
|
||
|
||
// Suggest mappings using accounts from ALL fiscal years
|
||
const basAccounts = BAS_REFERENCE.map(b => ({
|
||
account_number: b.account_number,
|
||
account_name: b.account_name,
|
||
}))
|
||
const mappings = suggestMappings(allAccounts, basAccounts, existingRecords)
|
||
const mappingStats = getMappingStats(mappings)
|
||
|
||
log.info(`Account mapping: ${allAccounts.length} unique accounts across ${filteredFiles.length} files, ${mappingStats.unmapped} unmapped`)
|
||
|
||
// Generate preview
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
// Collect all raw SIE content (filtered fiscal years only)
|
||
const allRawContent = filteredFiles.map(f => f.rawContent)
|
||
|
||
return NextResponse.json({
|
||
parsed,
|
||
mappings,
|
||
mappingStats,
|
||
preview,
|
||
validation,
|
||
rawContent: allRawContent,
|
||
basAccounts: BAS_REFERENCE,
|
||
})
|
||
} catch (error) {
|
||
log.error('SIE data fetch error:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Failed to fetch SIE data' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Import SIE data (accounts, balances, vouchers) ────────────
|
||
{
|
||
method: 'POST',
|
||
path: '/import-sie',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
const log = ctx?.log ?? console
|
||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const { rawContent, mappings, options } = await request.json() as {
|
||
rawContent: string
|
||
mappings: import('@/lib/import/types').AccountMapping[]
|
||
options: {
|
||
createFiscalPeriod: boolean
|
||
importOpeningBalances: boolean
|
||
importTransactions: boolean
|
||
voucherSeries?: string
|
||
}
|
||
}
|
||
|
||
if (!rawContent || !mappings) {
|
||
return NextResponse.json({ error: 'rawContent and mappings are required' }, { status: 400 })
|
||
}
|
||
|
||
try {
|
||
// Parse the SIE content
|
||
const parsed = parseSIEFile(rawContent)
|
||
|
||
// Save the user's mappings for future use
|
||
await saveMappings(supabase, user.id, mappings)
|
||
|
||
// Execute the import via core engine
|
||
const result = await executeSIEImport(supabase, user.id, parsed, mappings, {
|
||
filename: `migration-sie-${Date.now()}.se`,
|
||
fileContent: rawContent,
|
||
createFiscalPeriod: options.createFiscalPeriod,
|
||
importOpeningBalances: options.importOpeningBalances,
|
||
importTransactions: options.importTransactions,
|
||
voucherSeries: options.voucherSeries,
|
||
})
|
||
|
||
log.info('SIE import completed:', {
|
||
success: result.success,
|
||
journalEntriesCreated: result.journalEntriesCreated,
|
||
errors: result.errors.length,
|
||
errorDetails: result.errors.slice(0, 10),
|
||
})
|
||
|
||
return NextResponse.json(result)
|
||
} catch (error) {
|
||
log.error('SIE import failed:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'SIE import failed' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Execute entity migration (customers, suppliers, invoices) ──
|
||
{
|
||
method: 'POST',
|
||
path: '/migrate',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
const log = ctx?.log ?? console
|
||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const {
|
||
consentId,
|
||
importCompanyInfo = true,
|
||
importCustomers = true,
|
||
importSuppliers = true,
|
||
importSalesInvoices = true,
|
||
importSupplierInvoices = true,
|
||
} = await request.json() as {
|
||
consentId: string
|
||
importCompanyInfo?: boolean
|
||
importCustomers?: boolean
|
||
importSuppliers?: boolean
|
||
importSalesInvoices?: boolean
|
||
importSupplierInvoices?: boolean
|
||
}
|
||
|
||
if (!consentId) {
|
||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||
}
|
||
|
||
try {
|
||
// Verify consent
|
||
const consent = await getConsent(consentId)
|
||
if (consent.status !== 1) {
|
||
return NextResponse.json(
|
||
{ error: 'Consent is not accepted' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
log.info(`Starting migration for user ${user.id} from ${consent.provider}`)
|
||
|
||
const results = await executeMigration({
|
||
consentId,
|
||
userId: user.id,
|
||
supabase,
|
||
importCompanyInfo,
|
||
importCustomers,
|
||
importSuppliers,
|
||
importSalesInvoices,
|
||
importSupplierInvoices,
|
||
})
|
||
|
||
log.info('Migration completed:', results)
|
||
|
||
return NextResponse.json({ success: true, results })
|
||
} catch (error) {
|
||
log.error('Migration failed:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Migration failed' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Disconnect / revoke consent ───────────────────────────────
|
||
{
|
||
method: 'DELETE',
|
||
path: '/disconnect',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
const log = ctx?.log ?? console
|
||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const { consentId } = await request.json() as { consentId: string }
|
||
|
||
if (!consentId) {
|
||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||
}
|
||
|
||
try {
|
||
await deleteConsent(consentId)
|
||
|
||
// Clear stored consent from settings
|
||
if (ctx?.settings) {
|
||
await ctx.settings.set('consent_id', null)
|
||
await ctx.settings.set('provider', null)
|
||
}
|
||
|
||
return NextResponse.json({ success: true })
|
||
} catch (error) {
|
||
log.error('Disconnect error:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Disconnect failed' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
],
|
||
|
||
eventHandlers: [],
|
||
}
|