feat(import): import skattekontoutdrag files into the skattekonto pipeline (#1637)

* feat(import): import skattekontoutdrag files into the skattekonto pipeline

Users can now upload the kontohändelse export from Skatteverket's
skattekonto e-service (current CSV layout, verified against a real
2026-08 export, plus legacy .skv files) instead of needing the paid API
connection. Parsed rows land in skattekonto_transactions as booked
file_import rows and inherit the existing 1630 rules engine, bulk
booking, match-to-verifikat and both UIs unchanged.

- Core parser lib/import/skattekonto-file/ with strict detection
  (orgnr header + saldo markers, or two distinct SKV vocabulary terms
  plus row shape), sum-integrity check (opening + rows must equal
  closing) and a wrong-company guard against company_settings.
- computeDedupKey moves to core (lib/skatteverket/skattekonto-dedup);
  the extension re-imports it. File rows hash-key; content-signature
  partitioning skips rows already booked (either key form) and promotes
  matching upcoming rows in place.
- syncSkattekonto gains a takeover step: an id-keyed API row adopts a
  matching hash-keyed imported row in place, so journal links survive
  connecting the API after a file import. Upcoming rows can no longer
  clobber a booked row on hash collision.
- New skattekonto_file_imports table (company-scoped file-hash dedup)
  plus source/file_import_id provenance columns on
  skattekonto_transactions.
- /import gains a Skattekontoutdrag wizard (upload/preview/result,
  deep link ?mode=skattekonto); the bank-file flow detects skattekonto
  files and redirects instead of importing them as bank rows.
- /skattekonto renders imported rows for unconnected companies (attn
  line + import CTA) instead of discarding them behind the StartCard.
- Free for everyone: the local-data booking/match routes were already
  ungated; only API sync/saldo stay capability-gated.

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

* fix(skattekonto): align the EF F-skatt rule with the 2012 -> 2013 decision

20260810120000 established that 2012 is not standard BAS and moved the
booking templates to 2013 (owner taxes in an enskild firma are an eget
uttag), but the skattekonto_rules seed still booked EF preliminarskatt
against 2012. The file importer makes this rule fire for every EF
F-skatt row, so bring it onto 2013 too.

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

* fix(import): apply review findings on the skattekonto file import

- Fix the takeover candidate comparator: the single-argument sort was an
  inconsistent relation and could adopt a stale upcoming row ahead of the
  booked file row in a 3+ candidate queue (regression test added), and
  page the candidate scan with fetchAllRows so a multi-year window is not
  silently capped at 1000 rows.
- Fail parsing when a statement HAS saldo markers but not both readable
  balances: a file cut off before "Utgående saldo" previously skipped the
  sum check entirely. sum_valid stays null only for marker-less legacy
  files.
- Count a promotion only when the UPDATE matched a row, so a concurrent
  sync cannot inflate promoted_count; log a failed finalize of the import
  record instead of discarding the error.
- Migration (unshipped, edited in place): user_id is nullable with
  ON DELETE SET NULL so import records and their file-hash dedup survive
  user deletion, and the INSERT policy binds user_id to auth.uid() so a
  member cannot attribute an import to a colleague. pg tests cover both.
- Make the upload drop zone keyboard-reachable (role, tabIndex, Enter/
  Space) and give the six count-bearing strings ICU plural forms in both
  locales.

Skipped with reasons on the PR: binding execute rows to file bytes and
re-checking orgnr in execute (same client-trust model as the shipped
bank-file execute; Zod + RLS scope writes to the caller's own company),
a 404 test (the route has no not-found path), event-bus clearing in the
route test (the route touches no events), and FK NOT VALID (new column
referencing a brand-new empty table).

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>
This commit is contained in:
Jakob Wennberg
2026-08-17 13:18:32 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent dfb34a01d9
commit 4921d1da5e
32 changed files with 3188 additions and 47 deletions
@@ -0,0 +1,91 @@
/**
* Tests for POST /api/import/bank-file/parse, focused on the skattekonto
* redirect: a skattekontoutdrag uploaded into the bank flow must be refused
* with a pointer to the dedicated importer (its rows belong on 1630, not on
* a bank account), while an explicit format override still forces a bank
* parse as the escape hatch.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
getCompanyRole: vi.fn().mockResolvedValue({ ok: true, role: 'owner', companyId: 'company-1' }),
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const SKATTEKONTO_CSV = [
'"Testbolaget AB";"556677-8899";""',
'"";"Ingående saldo 2026-05-03";"-500"',
'"2026-06-06";"Kostnadsränta";"-10"',
'"";"Utgående saldo 2026-06-06";"-510"',
].join('\r\n')
const SEB_BANK_CSV = [
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
].join('\n')
function makeFileRequest(content: string, filename: string, format?: string) {
const formData = new FormData()
formData.append('file', new File([content], filename, { type: 'text/csv' }))
if (format) formData.append('format', format)
return new Request('http://localhost:3000/api/import/bank-file/parse', {
method: 'POST',
body: formData,
})
}
describe('POST /api/import/bank-file/parse (skattekonto redirect)', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('refuses a skattekontoutdrag with a redirect error code', async () => {
enqueue({ data: null }) // no prior bank_file_imports row
const response = await POST(
makeFileRequest(SKATTEKONTO_CSV, 'Kontoutdrag 556677-8899 2026-05-03--2026-08-01.csv'),
emptyParams,
)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error.code).toBe('BANK_FILE_SKATTEKONTO_DETECTED')
})
it('honors an explicit format override as the escape hatch', async () => {
enqueue({ data: null }) // no prior bank_file_imports row
const response = await POST(
makeFileRequest(SKATTEKONTO_CSV, 'skattekonto.skv', 'generic_csv'),
emptyParams,
)
// Not the redirect: the override forces a bank parse attempt.
const body = await response.json()
expect(body?.error?.code).not.toBe('BANK_FILE_SKATTEKONTO_DETECTED')
})
it('parses a real bank file normally', async () => {
enqueue({ data: null }) // no prior bank_file_imports row
const response = await POST(makeFileRequest(SEB_BANK_CSV, 'kontoutdrag.csv'), emptyParams)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.detected_format).toBe('seb')
expect(body.data.parse_result.transactions).toHaveLength(1)
})
})
+9
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
import { detectSkattekontoFile } from '@/lib/import/skattekonto-file/parser'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -57,6 +58,14 @@ export const POST = withRouteContext(
})
}
// A skattekontoutdrag is not a bank statement: its rows belong on the
// skattekonto (1630), not on a bank account. Redirect the user to the
// dedicated importer. An explicit format override still forces a bank
// parse as the escape hatch.
if (!formatOverride && detectSkattekontoFile(content, file.name)) {
return errorResponseFromCode('BANK_FILE_SKATTEKONTO_DETECTED', opLog, { requestId })
}
const detectedFormat = formatOverride
? null
: detectFileFormat(content, file.name)
@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
getCompanyRole: vi.fn().mockResolvedValue({ ok: true, role: 'owner', companyId: 'company-1' }),
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
function makeBody(overrides: Record<string, unknown> = {}) {
return {
rows: [
{
transaktionsdatum: '2026-06-06',
transaktionstext: 'Kostnadsränta',
belopp: -10,
},
{
transaktionsdatum: '2026-07-11',
transaktionstext: 'Inbetalning bokförd 260710',
belopp: 24000,
},
],
filename: 'Kontoutdrag 556677-8899 2026-05-03--2026-08-01.csv',
file_hash: 'a'.repeat(64),
variant: 'csv',
closing_saldo: 23490,
...overrides,
}
}
function makeRequest(body: unknown) {
return createMockRequest('/api/import/skattekonto-file/execute', {
method: 'POST',
body,
})
}
describe('POST /api/import/skattekonto-file/execute', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeRequest(makeBody()), emptyParams)
expect(response.status).toBe(401)
})
it('returns 400 on invalid payload', async () => {
const response = await POST(makeRequest({ filename: 'x.csv' }), emptyParams)
expect(response.status).toBe(400)
})
it('imports new rows with file provenance', async () => {
enqueue({ data: { id: 'imp-1' } }) // skattekonto_file_imports upsert
enqueue({ data: [] }) // existing rows page
enqueue({ data: null }) // insert batch
enqueue({ data: null }) // final record update
const { status, body } = await parseJsonResponse<{
data: { import_id: string; imported: number; duplicates: number; promoted: number }
}>(await POST(makeRequest(makeBody()), emptyParams))
expect(status).toBe(200)
expect(body.data).toMatchObject({ import_id: 'imp-1', imported: 2, duplicates: 0, promoted: 0 })
const inserts = findCalls('skattekonto_transactions', 'insert')
expect(inserts).toHaveLength(1)
const payload = inserts[0][0] as Array<Record<string, unknown>>
expect(payload).toHaveLength(2)
expect(payload[0]).toMatchObject({
company_id: 'company-1',
status: 'booked',
source: 'file_import',
file_import_id: 'imp-1',
transaktionsidentitet: null,
belopp_skatteverket: -10,
})
expect(payload[0].dedup_key).toMatch(/^h:[0-9a-f]{64}$/)
})
it('re-partitions server-side: rows already booked are skipped, not inserted', async () => {
enqueue({ data: { id: 'imp-1' } }) // upsert record
enqueue({
data: [
{
id: 'existing-1',
dedup_key: 'id:42',
status: 'booked',
transaktionsdatum: '2026-06-06',
transaktionstext: 'Kostnadsränta',
belopp_skatteverket: -10,
},
],
}) // existing rows
enqueue({ data: null }) // insert batch (the remaining row)
enqueue({ data: null }) // final record update
const { status, body } = await parseJsonResponse<{
data: { imported: number; duplicates: number }
}>(await POST(makeRequest(makeBody()), emptyParams))
expect(status).toBe(200)
expect(body.data).toMatchObject({ imported: 1, duplicates: 1 })
const inserts = findCalls('skattekonto_transactions', 'insert')
expect((inserts[0][0] as unknown[]).length).toBe(1)
})
it('promotes an upcoming row via UPDATE instead of inserting', async () => {
enqueue({ data: { id: 'imp-1' } }) // upsert record
enqueue({
data: [
{
id: 'upcoming-1',
dedup_key: 'h:deadbeef',
status: 'upcoming',
transaktionsdatum: '2026-06-06',
transaktionstext: 'Kostnadsränta',
belopp_skatteverket: -10,
},
],
}) // existing rows
enqueue({ data: null }) // insert batch (remaining row)
enqueue({ data: [{ id: 'upcoming-1' }] }) // promotion update returns the row
enqueue({ data: null }) // final record update
const { status, body } = await parseJsonResponse<{
data: { imported: number; promoted: number }
}>(await POST(makeRequest(makeBody()), emptyParams))
expect(status).toBe(200)
expect(body.data).toMatchObject({ imported: 1, promoted: 1 })
const updates = findCalls('skattekonto_transactions', 'update')
expect(updates).toHaveLength(1)
expect(updates[0][0]).toEqual({ status: 'booked' })
})
it('counts residual unique violations as duplicates via per-row fallback', async () => {
enqueue({ data: { id: 'imp-1' } }) // upsert record
enqueue({ data: [] }) // existing rows
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) // batch insert fails
enqueue({ data: null }) // row 1 insert ok
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) // row 2 conflict
enqueue({ data: null }) // final record update
const { status, body } = await parseJsonResponse<{
data: { imported: number; duplicates: number; errors: number }
}>(await POST(makeRequest(makeBody()), emptyParams))
expect(status).toBe(200)
expect(body.data).toMatchObject({ imported: 1, duplicates: 1, errors: 0 })
})
it('fails with 500 when the import record cannot be created', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(makeRequest(makeBody()), emptyParams),
)
expect(status).toBe(500)
expect(body.error.code).toBe('SKATTEKONTO_FILE_IMPORT_RECORD_FAILED')
})
})
@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { SkattekontoFileExecuteSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { executeSkattekontoFileImport } from '@/lib/import/skattekonto-file/import-service'
/**
* POST /api/import/skattekonto-file/execute
*
* Executes the import of confirmed skattekonto statement rows into
* skattekonto_transactions. The route recomputes dedup keys and re-partitions
* server-side (never trusts client-side duplicate indexes), records the
* import in skattekonto_file_imports, and counts residual unique-constraint
* conflicts as duplicates rather than failures.
*/
export const POST = withRouteContext(
'skattekonto_file.execute',
async (request, ctx) => {
const { supabase, companyId, user, log, requestId } = ctx
const validation = await validateBody(request, SkattekontoFileExecuteSchema)
if (!validation.success) return validation.response
const { rows, filename, file_hash, variant, closing_saldo } = validation.data
const dates = rows.map((r) => r.transaktionsdatum).sort()
const opLog = log.child({ filename, fileHash: file_hash, rowCount: rows.length })
try {
const { data: importRecord, error: importError } = await supabase
.from('skattekonto_file_imports')
.upsert(
{
company_id: companyId,
user_id: user.id,
filename,
file_hash,
file_variant: variant,
row_count: rows.length,
date_from: dates[0],
date_to: dates[dates.length - 1],
closing_saldo: closing_saldo ?? null,
status: 'processing',
},
{ onConflict: 'company_id,file_hash' },
)
.select()
.single()
if (importError || !importRecord) {
opLog.error(
'failed to create skattekonto_file_imports record',
importError ?? new Error('no record returned'),
)
return errorResponseFromCode('SKATTEKONTO_FILE_IMPORT_RECORD_FAILED', opLog, {
requestId,
details: { reason: importError ? getUserErrorMessage(importError) : 'unknown' },
})
}
const outcome = await executeSkattekontoFileImport(
supabase,
companyId,
importRecord.id,
rows,
)
if (outcome.errors > 0) {
opLog.error('skattekonto file import reported row errors', new Error(outcome.first_error ?? 'unknown'), {
errorCount: outcome.errors,
})
}
const { error: statusError } = await supabase
.from('skattekonto_file_imports')
.update({
imported_count: outcome.imported,
duplicate_count: outcome.duplicates,
promoted_count: outcome.promoted,
status: outcome.errors > 0 && outcome.imported === 0 ? 'failed' : 'completed',
error_message:
outcome.errors > 0
? `${outcome.errors} rader kunde inte importeras: ${outcome.first_error ?? ''}`
: null,
})
.eq('id', importRecord.id)
if (statusError) {
// The rows are written; only the record would misreport "processing".
opLog.error('failed to finalize skattekonto_file_imports record', statusError)
}
return NextResponse.json({
data: {
import_id: importRecord.id,
imported: outcome.imported,
duplicates: outcome.duplicates,
promoted: outcome.promoted,
errors: outcome.errors,
date_from: dates[0],
date_to: dates[dates.length - 1],
closing_saldo: closing_saldo ?? null,
},
})
} catch (err) {
opLog.error('skattekonto file execute failed', err as Error)
return errorResponseFromCode('SKATTEKONTO_FILE_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
{ requireWrite: true },
)
@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
getCompanyRole: vi.fn().mockResolvedValue({ ok: true, role: 'owner', companyId: 'company-1' }),
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const MODERN_CSV = [
'"Testbolaget AB";"556677-8899";""',
'"";"Ingående saldo 2026-05-03";"-500"',
'"2026-06-06";"Kostnadsränta";"-10"',
'"2026-07-11";"Inbetalning bokförd 260710";"24 000"',
'"";"Utgående saldo 2026-08-01";"23 490"',
].join('\r\n')
const BANK_CSV = [
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
].join('\n')
function makeFileRequest(content: string | null, filename = 'Kontoutdrag.csv') {
const formData = new FormData()
if (content !== null) {
formData.append('file', new File([content], filename, { type: 'text/csv' }))
}
return new Request('http://localhost:3000/api/import/skattekonto-file/parse', {
method: 'POST',
body: formData,
})
}
async function jsonOf(response: Response) {
return { status: response.status, body: await response.json() }
}
describe('POST /api/import/skattekonto-file/parse', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeFileRequest(MODERN_CSV), emptyParams)
expect(response.status).toBe(401)
})
it('returns 400 when no file is attached', async () => {
const { status, body } = await jsonOf(await POST(makeFileRequest(null), emptyParams))
expect(status).toBe(400)
expect(body.error.code).toBe('SKATTEKONTO_FILE_NO_FILE')
})
it('rejects an already-imported file with 409', async () => {
enqueue({
data: { id: 'imp-1', status: 'completed', imported_count: 9, created_at: '2026-08-01' },
})
const { status, body } = await jsonOf(await POST(makeFileRequest(MODERN_CSV), emptyParams))
expect(status).toBe(409)
expect(body.error.code).toBe('SKATTEKONTO_FILE_DUPLICATE')
})
it('refuses files that are not skattekonto statements', async () => {
enqueue({ data: null }) // no prior import
const { status, body } = await jsonOf(
await POST(makeFileRequest(BANK_CSV, 'kontoutdrag-bank.csv'), emptyParams),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SKATTEKONTO_FILE_NOT_RECOGNIZED')
})
it('rejects a statement that does not sum', async () => {
enqueue({ data: null }) // no prior import
const broken = MODERN_CSV.replace('"23 490"', '"99 999"')
const { status, body } = await jsonOf(
await POST(makeFileRequest(broken, 'Kontoutdrag 556677-8899 2026-05-03--2026-08-01.csv'), emptyParams),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SKATTEKONTO_FILE_SUM_MISMATCH')
})
it('parses a valid statement and partitions against existing rows', async () => {
enqueue({ data: null }) // no prior import
enqueue({ data: { org_number: '556677-8899' } }) // company_settings
enqueue({
data: [
{
id: 'existing-1',
dedup_key: 'id:42',
status: 'booked',
transaktionsdatum: '2026-06-06',
transaktionstext: 'Kostnadsränta',
belopp_skatteverket: -10,
},
],
}) // existing rows page
const { status, body } = await jsonOf(await POST(makeFileRequest(MODERN_CSV), emptyParams))
expect(status).toBe(200)
expect(body.data.parse_result.rows).toHaveLength(2)
expect(body.data.parse_result.closing_saldo).toBe(23490)
expect(body.data.org_number_mismatch).toBe(false)
// The Kostnadsränta row matches the existing booked id-keyed row by content.
expect(body.data.duplicate_row_indexes).toEqual([0])
expect(body.data.promotion_row_indexes).toEqual([])
expect(body.data.file_hash).toMatch(/^[0-9a-f]{64}$/)
})
it('flags an org number mismatch', async () => {
enqueue({ data: null }) // no prior import
enqueue({ data: { org_number: '5591112223' } }) // different company
enqueue({ data: [] }) // existing rows page
const { status, body } = await jsonOf(await POST(makeFileRequest(MODERN_CSV), emptyParams))
expect(status).toBe(200)
expect(body.data.org_number_mismatch).toBe(true)
})
})
@@ -0,0 +1,135 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import { normalizeOrgNumber } from '@/lib/import/shared/column-utils'
import { generateFileHash } from '@/lib/import/bank-file/parser'
import {
detectSkattekontoFile,
parseSkattekontoFile,
} from '@/lib/import/skattekonto-file/parser'
import { assignFileDedupKeys, partitionFileRows } from '@/lib/skatteverket/skattekonto-dedup'
import { fetchExistingSkattekontoRows } from '@/lib/import/skattekonto-file/import-service'
/**
* POST /api/import/skattekonto-file/parse
*
* Accepts a skattekontoutdrag (Skatteverket tax account statement, CSV or
* legacy .skv) via FormData and returns a parsed preview with row-level
* duplicate/promotion detection against skattekonto_transactions.
*
* Unlike the bank-file flow there is no separate check-duplicates route: the
* bank flow needs one only for its client-side generic_csv re-parse, which
* has no equivalent here, so the partition is computed inline.
*/
export const POST = withRouteContext(
'skattekonto_file.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('SKATTEKONTO_FILE_NO_FILE', log, { requestId })
}
if (file.size > 10 * 1024 * 1024) {
return errorResponseFromCode('SKATTEKONTO_FILE_TOO_LARGE', log, {
requestId,
details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) },
})
}
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
try {
const arrayBuffer = await file.arrayBuffer()
const content = decodeFileContent(arrayBuffer)
const fileHash = generateFileHash(content)
const { data: existingImport } = await supabase
.from('skattekonto_file_imports')
.select('id, status, imported_count, created_at')
.eq('company_id', companyId)
.eq('file_hash', fileHash)
.maybeSingle()
if (existingImport && existingImport.status === 'completed') {
return errorResponseFromCode('SKATTEKONTO_FILE_DUPLICATE', opLog, {
requestId,
details: {
importId: existingImport.id,
importedCount: existingImport.imported_count,
importedAt: existingImport.created_at,
},
})
}
// Strict gate on purpose: without it, a bank CSV uploaded here by
// mistake could parse "well enough" (date;text;number) and land bank
// rows on the skattekonto.
if (!detectSkattekontoFile(content, file.name)) {
return errorResponseFromCode('SKATTEKONTO_FILE_NOT_RECOGNIZED', opLog, { requestId })
}
const parseResult = parseSkattekontoFile(content, file.name)
if (parseResult.sum_valid === false) {
return errorResponseFromCode('SKATTEKONTO_FILE_SUM_MISMATCH', opLog, {
requestId,
details: {
openingSaldo: parseResult.opening_saldo,
closingSaldo: parseResult.closing_saldo,
},
})
}
if (parseResult.rows.length === 0) {
return errorResponseFromCode('SKATTEKONTO_FILE_NO_ROWS', opLog, { requestId })
}
// Wrong-company guard: the modern export names its orgnr in the header
// row. A mismatch is surfaced for the preview step to confirm, not a
// hard block (legacy files have no header at all).
let orgNumberMismatch = false
if (parseResult.org_number) {
const { data: settings } = await supabase
.from('company_settings')
.select('org_number')
.eq('company_id', companyId)
.maybeSingle()
const companyOrg = normalizeOrgNumber(settings?.org_number ?? null)
const fileOrg = normalizeOrgNumber(parseResult.org_number)
orgNumberMismatch = companyOrg !== null && fileOrg !== null && companyOrg !== fileOrg
}
const existing = await fetchExistingSkattekontoRows(
supabase,
companyId,
parseResult.date_from as string,
parseResult.date_to as string,
)
const partition = partitionFileRows(
assignFileDedupKeys(parseResult.rows),
existing,
)
return NextResponse.json({
data: {
parse_result: parseResult,
file_hash: fileHash,
filename: file.name,
org_number_mismatch: orgNumberMismatch,
duplicate_row_indexes: partition.duplicates.map((d) => d.index),
promotion_row_indexes: partition.promotions.map((p) => p.index),
},
})
} catch (err) {
opLog.error('skattekonto file parse failed', err as Error)
return errorResponseFromCode('SKATTEKONTO_FILE_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
)