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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dfb34a01d9
commit
4921d1da5e
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user