Files
accounted/app/api/import/sie/parse/route.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

211 lines
7.2 KiB
TypeScript

import { NextResponse } from 'next/server'
import {
parseSIEFile,
validateSIEFile,
detectEncoding,
decodeBuffer,
calculateFileHash,
} from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { planChartChanges } from '@/lib/import/chart-plan'
import { scanSieForCp1252Artifacts, formatSieArtifactWarning } from '@/lib/import/sie-artifact-scan'
import {
generateImportPreview,
checkDuplicateImport,
checkDuplicatePeriodImport,
precheckFiscalPeriod,
} from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/import/sie/parse
* Parse an uploaded SIE file and return preview data.
*/
export const POST = withRouteContext(
'sie_import.parse',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return errorResponseFromCode('SIE_PARSE_NO_FILE', log, { requestId })
}
const filename = file.name.toLowerCase()
if (!filename.endsWith('.sie') && !filename.endsWith('.se')) {
return errorResponseFromCode('SIE_PARSE_INVALID_TYPE', log, {
requestId,
details: { filename: file.name },
})
}
const MAX_FILE_SIZE = 50 * 1024 * 1024
if (file.size > MAX_FILE_SIZE) {
return errorResponseFromCode('SIE_PARSE_FILE_TOO_LARGE', log, {
requestId,
details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) },
})
}
if (file.size === 0) {
return errorResponseFromCode('SIE_PARSE_EMPTY', log, { requestId })
}
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
try {
const arrayBuffer = await file.arrayBuffer()
const encoding = detectEncoding(arrayBuffer)
const content = decodeBuffer(arrayBuffer, encoding)
const duplicate = await checkDuplicateImport(supabase, companyId!, content)
if (duplicate) {
return errorResponseFromCode('SIE_DUPLICATE_FILE', opLog, {
requestId,
details: {
importId: duplicate.id,
importedAt: duplicate.imported_at,
},
})
}
const parsed = parseSIEFile(content)
// Mojibake tripwire (warn, never block): CP437 bytes decoded as
// windows-1252 somewhere upstream leave C1 specials mid-word in account
// names and voucher texts. Surface it as a parse-issue warning, the
// preview's existing warnings card, so the user can abort before import.
const artifactScan = scanSieForCp1252Artifacts(parsed)
if (artifactScan.flagged) {
const contentLines = content.split(/\r?\n/)
const firstSample = artifactScan.samples[0]
const sampleLine = firstSample
? contentLines.findIndex((l) => l.includes(firstSample))
: -1
parsed.issues.push({
severity: 'warning',
line: sampleLine >= 0 ? sampleLine + 1 : 1,
message: formatSieArtifactWarning(artifactScan),
})
opLog.warn('sie parse: CP1252 mojibake artifacts in decoded content', {
encoding,
artifactCount: artifactScan.artifactCount,
samples: artifactScan.samples,
})
}
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
const periodDuplicate = await checkDuplicatePeriodImport(
supabase,
companyId!,
parsed.stats.fiscalYearStart,
parsed.stats.fiscalYearEnd,
)
if (periodDuplicate) {
return errorResponseFromCode('SIE_DUPLICATE_PERIOD', opLog, {
requestId,
details: {
importId: periodDuplicate.id,
fiscalYearStart: periodDuplicate.fiscal_year_start,
fiscalYearEnd: periodDuplicate.fiscal_year_end,
importedAt: periodDuplicate.imported_at,
},
})
}
}
const validation = validateSIEFile(parsed)
if (!validation.valid) {
return errorResponseFromCode('SIE_PARSE_VALIDATION_FAILED', opLog, {
requestId,
details: { errors: validation.errors, warnings: validation.warnings },
})
}
const excludedSystemAccounts = parsed.accounts
.filter((a) => isSystemAccount(a.number))
.map((a) => ({ number: a.number, name: a.name }))
const bookkeepingAccounts = parsed.accounts.filter((a) => !isSystemAccount(a.number))
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('company_id', companyId)
const mappings = suggestMappings(
bookkeepingAccounts,
BAS_REFERENCE,
(storedMappings as SIEAccountMappingRecord[]) || undefined,
)
const preview = generateImportPreview(parsed, mappings)
preview.excludedSystemAccounts = excludedSystemAccounts
preview.accountCount = bookkeepingAccounts.length
// The mapping stats above score the file against the BAS reference. A
// consultant with a 41-account seeded company reads "150 mappade" as
// "your chart replaces mine", so also say what happens to THIS company's
// chart: how many of the file's accounts are new here, how many exist.
const chartRows = await fetchAllRows<{ account_number: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.order('account_number')
.range(from, to),
)
preview.chart = planChartChanges(
mappings,
new Set(chartRows.map((r) => r.account_number)),
)
// Same containment/overlap verdict the import runs (ensureFiscalPeriod),
// so a fiscal-year conflict shows here instead of after the mapping step.
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
preview.fiscalYear = await precheckFiscalPeriod(
supabase,
companyId!,
parsed.stats.fiscalYearStart,
parsed.stats.fiscalYearEnd,
)
}
const fileHash = await calculateFileHash(content)
return NextResponse.json({
success: true,
encoding,
fileHash,
parsed: {
header: parsed.header,
accounts: parsed.accounts,
stats: parsed.stats,
issues: parsed.issues,
},
mappings,
mappingStats: getMappingStats(mappings),
preview,
validation: {
valid: validation.valid,
errors: validation.errors,
warnings: validation.warnings,
},
})
} catch (err) {
opLog.error('sie parse failed', err as Error)
return errorResponseFromCode('SIE_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
)