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
+1
View File
@@ -1031,3 +1031,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-17] Arcim's "610 bilagor i Hela historiken men 100 i räkenskapsåret" in the full-archive dialog is NOT a pagination bug: verified against prod, exactly 100 documents are linked to posted vouchers in the single (extended) fiscal year and 510 are unlinked inbox/receipt docs, which scope=all includes by design (same split as cloud backup's year-ZIPs vs Grunddata.zip). Kept the semantics, fixed two things instead: estimateArchiveSize's period branch ran one unpaginated read with one flat IN() over every entry id (undercounts past the PostgREST row cap, URL blowup past ~a few hundred ids) -> now CHILD_FK_CHUNK-chunked and fetchAllRows-paginated like writeDocuments already was; and the dialog now states per scope which document set is counted, so the gap reads as intent, not as a bug.
[2026-08-17] Supplier standardkonto empty-string fix lives in the API schemas, split by verb: '' normalizes to undefined on create (key dropped, column NULL) but to null on update, because update routes pass validated fields straight into .update() where undefined means "leave unchanged"; without the null mapping a cleared standardkonto/e-post would silently never clear. Client keeps sending '' as-is (the old email-strip hack removed), since stripping client-side would break exactly that clear path. The field itself became an AccountCombobox filtered to cost classes 4-7 (matches the agent-path expenseAccountField rule); other 4-digit numbers stay typeable, and the API still enforces format only. Standardkonto stays optional: it only prefills supplier-invoice lines, and the ledger-context suggestion covers the empty case, so requiring it (what the bug accidentally did) is wrong for the target user.
[2026-08-17] Replay-masking skeptic round (PR #1639): explicit data-ph tags now resolve BEFORE the th chrome fallback in replayMaskText (a single closest over tags-plus-th let a th nested in a masked container win on DOM proximity, CodeRabbit); seven missed text-leak sites got call-site masks (delete-invoice number, credit-page number, IB voucher ref, TIC orgnr since TIC serves it unnormalized so the separator scrub cannot be relied on, articles search term, dimension segment labels, activate-account buttons); the attribute channel (placeholders prefilled with effective values, title tooltips) is handled with rrweb's blockClass: user-data placeholders carry ph-no-capture, which removes the element from the recording while app UX keeps the founder-approved prefill-override pattern intact. Chose ph-no-capture over stripping the placeholders because the prefilled effective value IS the UX.
[2026-08-17] Skattekontoutdrag file import (Sebastian's request) writes into skattekonto_transactions, not into transactions as a pseudo-bank with 1630 unlocked in BankFileConfirmStep: rows inherit the skattekonto_rules 1630 booking engine, matching, drift and both UIs for free, while the literal ask would bypass the rules and double against the SKV inbox for connected companies. Dedup pairs file hash-keys with API id-keys by CONTENT in both directions (import-time skip/promote against existing rows, sync-time takeover that rewrites an imported row's key in place so journal links survive connecting the API later). Import is free for everyone per the requireSkvCapability doctrine (manual paths never blocked); only sync/saldo stay capability-gated. The parse route hard-rejects files that fail detectSkattekontoFile and statements whose opening+sum!=closing, and warns on orgnr mismatch against company_settings: wrong-company imports are a known support-incident class.
+208 -3
View File
@@ -54,6 +54,8 @@ import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import type { BankFileParseResult, BankFileFormatId, BankFileDuplicateInfo, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { SkattekontoFileParseResult } from '@/lib/import/skattekonto-file/types'
import type { SkattekontoFileImportResult } from '@/components/import/SkattekontoFileResultStep'
import type { IngestResult } from '@/lib/transactions/ingest'
import type {
ImportWizardStep,
@@ -110,6 +112,9 @@ const CustomersEditStep = dynamic(() => import('@/components/import/CustomersEdi
const SuppliersEditStep = dynamic(() => import('@/components/import/SuppliersEditStep'), { loading: ImportStepLoading })
const ArticlesEditStep = dynamic(() => import('@/components/import/ArticlesEditStep'), { loading: ImportStepLoading })
const RegisterResultStep = dynamic(() => import('@/components/import/RegisterResultStep'), { loading: ImportStepLoading })
const SkattekontoFileUploadStep = dynamic(() => import('@/components/import/SkattekontoFileUploadStep'), { loading: ImportStepLoading })
const SkattekontoFilePreviewStep = dynamic(() => import('@/components/import/SkattekontoFilePreviewStep'), { loading: ImportStepLoading })
const SkattekontoFileResultStep = dynamic(() => import('@/components/import/SkattekontoFileResultStep'), { loading: ImportStepLoading })
const SIEUploadStep = dynamic(() => import('@/components/import/SIEUploadStep'), { loading: ImportStepLoading })
const SIEPreviewStep = dynamic(() => import('@/components/import/SIEPreviewStep'), { loading: ImportStepLoading })
const AccountMappingStep = dynamic(() => import('@/components/import/AccountMappingStep'), { loading: ImportStepLoading })
@@ -144,6 +149,9 @@ function BankFileImportWizard() {
const [bankIsLoading, setBankIsLoading] = useState(false)
const [bankError, setBankError] = useState<string | null>(null)
const [bankErrorTitle, setBankErrorTitle] = useState<string | null>(null)
// The uploaded file was recognized as a skattekontoutdrag: the upload step
// renders a pointer to the dedicated importer instead of a parse error.
const [skattekontoDetected, setSkattekontoDetected] = useState(false)
// Parse results
const [parseResult, setParseResult] = useState<BankFileParseResult | null>(null)
@@ -216,6 +224,7 @@ function BankFileImportWizard() {
const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => {
setBankError(null)
setBankErrorTitle(null)
setSkattekontoDetected(false)
setBankIsLoading(true)
try {
@@ -247,6 +256,8 @@ function BankFileImportWizard() {
`Den här filen är redan importerad${when}. Transaktionerna finns redan under Transaktioner. ` +
'Exportera en ny fil från banken om du vill lägga till fler transaktioner.'
)
} else if (err.code === 'BANK_FILE_SKATTEKONTO_DETECTED') {
setSkattekontoDetected(true)
} else {
setBankError(getErrorMessage(err) || 'Kunde inte läsa filen')
}
@@ -374,6 +385,7 @@ function BankFileImportWizard() {
setIngestResult(null)
setBankError(null)
setBankErrorTitle(null)
setSkattekontoDetected(false)
setRawFileContent('')
setDuplicateInfo(null)
}
@@ -433,6 +445,7 @@ function BankFileImportWizard() {
errorTitle={bankErrorTitle}
detectedFormat={detectedFormat}
detectedFormatName={detectedFormatName}
skattekontoDetected={skattekontoDetected}
/>
)}
@@ -485,6 +498,192 @@ function BankFileImportWizard() {
)
}
// ============================================================
// Skattekonto File Import Wizard
// ============================================================
type SkattekontoFileStep = 'upload' | 'preview' | 'result'
const SKATTEKONTO_STEPS: SkattekontoFileStep[] = ['upload', 'preview', 'result']
const SKATTEKONTO_STEP_LABELS: Record<SkattekontoFileStep, string> = {
upload: 'Ladda upp',
preview: 'Förhandsgranskning',
result: 'Resultat',
}
function SkattekontoImportWizard() {
const { toast } = useToast()
const t = useTranslations('import')
const [step, setStep] = useState<SkattekontoFileStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [errorTitle, setErrorTitle] = useState<string | null>(null)
const [parseResult, setParseResult] = useState<SkattekontoFileParseResult | null>(null)
const [fileHash, setFileHash] = useState('')
const [filename, setFilename] = useState('')
const [duplicateIndexes, setDuplicateIndexes] = useState<number[]>([])
const [promotionIndexes, setPromotionIndexes] = useState<number[]>([])
const [orgNumberMismatch, setOrgNumberMismatch] = useState(false)
const [importResult, setImportResult] = useState<SkattekontoFileImportResult | null>(null)
const currentStepIndex = SKATTEKONTO_STEPS.indexOf(step)
const progress = ((currentStepIndex + 1) / SKATTEKONTO_STEPS.length) * 100
const handleFileSelect = useCallback(
async (file: File) => {
setError(null)
setErrorTitle(null)
setIsLoading(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/import/skattekonto-file/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
const err = data?.error
if (err?.code === 'SKATTEKONTO_FILE_DUPLICATE') {
setErrorTitle(t('skattekonto_duplicate_file_title'))
}
setError(getErrorMessage(data, { statusCode: res.status }))
return
}
setParseResult(data.data.parse_result)
setFileHash(data.data.file_hash)
setFilename(data.data.filename)
setDuplicateIndexes(data.data.duplicate_row_indexes ?? [])
setPromotionIndexes(data.data.promotion_row_indexes ?? [])
setOrgNumberMismatch(Boolean(data.data.org_number_mismatch))
setStep('preview')
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : t('skattekonto_error_title'))
} finally {
setIsLoading(false)
}
},
[t],
)
const handleExecute = useCallback(async () => {
if (!parseResult) return
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/import/skattekonto-file/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: parseResult.rows.map((row) => ({
transaktionsdatum: row.transaktionsdatum,
transaktionstext: row.transaktionstext,
belopp: row.belopp,
})),
filename,
file_hash: fileHash,
variant: parseResult.variant,
closing_saldo: parseResult.closing_saldo,
}),
})
const data = await res.json()
if (!res.ok) {
setError(getErrorMessage(data, { statusCode: res.status }))
return
}
setImportResult(data.data)
setStep('result')
toast({
title: t('skattekonto_result_title'),
description: t('skattekonto_result_summary', {
imported: data.data.imported,
duplicates: data.data.duplicates,
}),
})
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : t('skattekonto_error_title'))
} finally {
setIsLoading(false)
}
}, [parseResult, filename, fileHash, toast, t])
const handleNewImport = () => {
setStep('upload')
setParseResult(null)
setFileHash('')
setFilename('')
setDuplicateIndexes([])
setPromotionIndexes([])
setOrgNumberMismatch(false)
setImportResult(null)
setError(null)
setErrorTitle(null)
}
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{SKATTEKONTO_STEPS.length}:{' '}
{SKATTEKONTO_STEP_LABELS[step]}
</span>
{SKATTEKONTO_STEPS.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}
>
{SKATTEKONTO_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{step === 'upload' && (
<SkattekontoFileUploadStep
onFileSelect={handleFileSelect}
isLoading={isLoading}
error={error}
errorTitle={errorTitle}
/>
)}
{step === 'preview' && parseResult && (
<SkattekontoFilePreviewStep
parseResult={parseResult}
duplicateIndexes={duplicateIndexes}
promotionIndexes={promotionIndexes}
orgNumberMismatch={orgNumberMismatch}
isLoading={isLoading}
onExecute={handleExecute}
onBack={handleNewImport}
/>
)}
{step === 'preview' && error && (
<p className="text-sm text-destructive">{error}</p>
)}
{step === 'result' && importResult && (
<SkattekontoFileResultStep result={importResult} onNewImport={handleNewImport} />
)}
</div>
)
}
// ============================================================
// SIE Import Wizard (unchanged, extracted into component)
// ============================================================
@@ -2109,7 +2308,7 @@ const ShopifyPanel = getSettingsPanel('shopify')
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'sie' | 'underlag' | 'csv_data' | 'migration'
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'skattekonto' | 'sie' | 'underlag' | 'csv_data' | 'migration'
export default function ImportPage() {
const { isSandbox, role } = useCompany()
@@ -2143,8 +2342,8 @@ export default function ImportPage() {
// third-party credentials, so their deep links are ignored in the sandbox.
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
const allowedModes = isSandbox
? ['bank', 'sie', 'underlag', 'csv_data']
: ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'sie', 'underlag', 'csv_data', 'migration']
? ['bank', 'skattekonto', 'sie', 'underlag', 'csv_data']
: ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'skattekonto', 'sie', 'underlag', 'csv_data', 'migration']
if (!isSandbox && searchParams.get('migration')) {
setMode('migration')
} else {
@@ -2302,6 +2501,11 @@ export default function ImportPage() {
sub={t('bankfile_description')}
onClick={() => setMode('bank')}
/>
<ImportRow
title={t('skattekonto_title')}
sub={t('skattekonto_description')}
onClick={() => setMode('skattekonto')}
/>
<ImportRow
title={t('csv_data_title')}
sub={t('csv_data_description')}
@@ -2505,6 +2709,7 @@ export default function ImportPage() {
)
)}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'skattekonto' && <SkattekontoImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'underlag' && <UnderlagImportWizard />}
{mode === 'csv_data' && <CSVDataImportWizard />}
+46 -9
View File
@@ -123,6 +123,13 @@ export default function SkattekontoPage() {
if (saldoRes.status === 401) {
setNotConnected(true)
// A skattekontoutdrag file import populates the table without any
// SKV connection: keep rendering those rows. The StartCard only
// shows when the table is empty too.
if (txRes.ok) {
const txJson = (await txRes.json()) as TransaktionerEnvelope
setTx(txJson.data)
}
return
}
// A non-401 response proves a connection now exists: clear a stale
@@ -370,7 +377,10 @@ export default function SkattekontoPage() {
</HelpPopover>
)
if (notConnected) {
const hasLocalRows =
tx !== null && tx.booked.length + tx.overdue.length + tx.upcoming.length > 0
if (notConnected && !hasLocalRows) {
return (
<div className="space-y-8">
<PageHeader title="Skattekonto" help={helpNode} />
@@ -381,6 +391,7 @@ export default function SkattekontoPage() {
title={tStart('skattekonto_title')}
body={tStart('skattekonto_body')}
primary={{ label: tStart('skattekonto_primary'), href: '/settings/tax' }}
secondary={{ label: t('import_statement_action'), href: '/import?mode=skattekonto' }}
/>
</div>
</div>
@@ -413,22 +424,47 @@ export default function SkattekontoPage() {
title="Skattekonto"
help={helpNode}
action={
// The span carries the tooltip: `title` is suppressed on disabled elements.
<span title={!hasSkvCapability ? 'Synk mot Skatteverket kräver ett abonnemang' : undefined}>
notConnected ? (
<Button
variant="ghost"
onClick={syncNow}
disabled={syncing || !hasSkvCapability}
asChild
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
<Link href="/import?mode=skattekonto">{t('import_statement_action')}</Link>
</Button>
</span>
) : (
// The span carries the tooltip: `title` is suppressed on disabled elements.
<span title={!hasSkvCapability ? 'Synk mot Skatteverket kräver ett abonnemang' : undefined}>
<Button
variant="ghost"
onClick={syncNow}
disabled={syncing || !hasSkvCapability}
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
</Button>
</span>
)
}
/>
{/* Saldo as compact stat tiles (house metric-card idiom, KPIHeroCards) */}
{/* File-imported rows without a connection: no saldo to show, but the
booking/matching flows below work on the local table. One ochre
sentence with the connect action, per the attn convention. */}
{notConnected && (
<AttnLine
action={{ label: tStart('skattekonto_primary'), href: '/settings/tax' }}
>
{t('imported_not_connected_attn')}
</AttnLine>
)}
{/* Saldo as compact stat tiles (house metric-card idiom, KPIHeroCards).
Hidden entirely for unconnected companies rendering imported rows:
there is no saldo to fetch and the "Synkronisera nu" hint would
point at a button that cannot work. */}
{!notConnected && (
<section className="space-y-4">
{loading && !data ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@@ -545,6 +581,7 @@ export default function SkattekontoPage() {
</>
)}
</section>
)}
{/* One dry table with band rows (concept): Kommande, Förfallna, Genomförda */}
<SkattekontoTable
@@ -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' },
})
}
},
)
+20
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -45,6 +46,8 @@ interface BankFileUploadStepProps {
errorTitle?: string | null
detectedFormat?: string | null
detectedFormatName?: string | null
/** The uploaded file was recognized as a Skatteverket skattekontoutdrag. */
skattekontoDetected?: boolean
}
export default function BankFileUploadStep({
@@ -54,6 +57,7 @@ export default function BankFileUploadStep({
errorTitle,
detectedFormat,
detectedFormatName,
skattekontoDetected,
}: BankFileUploadStepProps) {
const t = useTranslations('import')
const [isDragging, setIsDragging] = useState(false)
@@ -204,6 +208,22 @@ export default function BankFileUploadStep({
)}
</div>
{/* Skattekonto redirect: not an error, a pointer to the right flow */}
{skattekontoDetected && (
<div className="p-4 bg-muted/30 border border-border rounded-lg flex gap-3">
<AlertCircle className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">{t('skattekonto_detected_title')}</p>
<p className="mt-1 text-muted-foreground">
{t('skattekonto_detected_body')}{' '}
<Link href="/import?mode=skattekonto" className="underline underline-offset-2">
{t('skattekonto_detected_link')}
</Link>
</p>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
@@ -0,0 +1,208 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, ArrowRight, AlertTriangle, Calendar, FileText, Scale } from 'lucide-react'
import { formatCurrency, cn } from '@/lib/utils'
import type { SkattekontoFileParseResult } from '@/lib/import/skattekonto-file/types'
interface SkattekontoFilePreviewStepProps {
parseResult: SkattekontoFileParseResult
duplicateIndexes: number[]
promotionIndexes: number[]
orgNumberMismatch: boolean
isLoading: boolean
onExecute: () => void
onBack: () => void
}
const PREVIEW_ROW_LIMIT = 100
export default function SkattekontoFilePreviewStep({
parseResult,
duplicateIndexes,
promotionIndexes,
orgNumberMismatch,
isLoading,
onExecute,
onBack,
}: SkattekontoFilePreviewStepProps) {
const t = useTranslations('import')
const [mismatchConfirmed, setMismatchConfirmed] = useState(false)
const { rows, stats, issues, date_from, date_to, closing_saldo } = parseResult
const duplicateSet = new Set(duplicateIndexes)
const promotionSet = new Set(promotionIndexes)
const newCount = rows.length - duplicateIndexes.length
const warnings = issues.filter((i) => i.severity !== 'error')
const importBlocked = orgNumberMismatch && !mismatchConfirmed
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">{t('skattekonto_preview_rows')}</span>
</div>
<p className="text-2xl font-display tabular-nums">{stats.parsed_rows}</p>
{stats.skipped_rows > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{t('skattekonto_preview_skipped', { count: stats.skipped_rows })}
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">{t('skattekonto_preview_period')}</span>
</div>
<p className="text-sm font-medium tabular-nums">
{date_from || '-'} – {date_to || '-'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Scale className="h-4 w-4" />
<span className="text-sm">{t('skattekonto_preview_closing_saldo')}</span>
</div>
<p className="text-lg font-display tabular-nums">
{closing_saldo !== null ? formatCurrency(closing_saldo) : '-'}
</p>
</CardContent>
</Card>
</div>
{orgNumberMismatch && (
<Card className="border-destructive/40">
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
{t('skattekonto_org_mismatch_title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t('skattekonto_org_mismatch_body', {
orgNumber: parseResult.org_number ?? '?',
companyName: parseResult.company_name ?? '?',
})}
</p>
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded-sm border-border"
checked={mismatchConfirmed}
onChange={(e) => setMismatchConfirmed(e.target.checked)}
/>
<span>{t('skattekonto_org_mismatch_confirm')}</span>
</label>
</CardContent>
</Card>
)}
{duplicateIndexes.length > 0 && (
<p className="text-sm text-muted-foreground">
{t('skattekonto_duplicates_note', { count: duplicateIndexes.length })}
</p>
)}
{warnings.length > 0 && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-warning">
<AlertTriangle className="h-4 w-4" />
{t('skattekonto_issues_title', { count: warnings.length })}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-1">
{warnings.slice(0, 5).map((issue, i) => (
<p key={i}>
{issue.row > 0 ? `${t('skattekonto_issue_row', { row: issue.row })}: ` : ''}
{issue.message}
</p>
))}
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('skattekonto_col_date')}</TableHead>
<TableHead>{t('skattekonto_col_text')}</TableHead>
<TableHead className="text-right">{t('skattekonto_col_amount')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, PREVIEW_ROW_LIMIT).map((row, index) => {
const isDuplicate = duplicateSet.has(index)
return (
<TableRow key={index} className={cn(isDuplicate && 'opacity-60')}>
<TableCell className="tabular-nums">{row.transaktionsdatum}</TableCell>
<TableCell>{row.transaktionstext}</TableCell>
<TableCell
className={cn(
'text-right tabular-nums',
row.belopp < 0 ? 'text-destructive' : undefined,
)}
>
{formatCurrency(row.belopp)}
</TableCell>
<TableCell className="text-right">
{isDuplicate ? (
<Badge variant="secondary">{t('skattekonto_chip_duplicate')}</Badge>
) : promotionSet.has(index) ? (
<Badge variant="outline">{t('skattekonto_chip_promotion')}</Badge>
) : null}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{rows.length > PREVIEW_ROW_LIMIT && (
<p className="px-4 py-3 text-xs text-muted-foreground">
{t('skattekonto_preview_truncated', {
shown: PREVIEW_ROW_LIMIT,
total: rows.length,
})}
</p>
)}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('skattekonto_back')}
</Button>
<Button onClick={onExecute} disabled={isLoading || importBlocked || newCount === 0}>
{t('skattekonto_import_button', { count: newCount })}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle } from 'lucide-react'
export interface SkattekontoFileImportResult {
imported: number
duplicates: number
promoted: number
errors: number
}
interface SkattekontoFileResultStepProps {
result: SkattekontoFileImportResult
onNewImport: () => void
}
export default function SkattekontoFileResultStep({
result,
onNewImport,
}: SkattekontoFileResultStepProps) {
const t = useTranslations('import')
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center gap-4 py-6 text-center">
<CheckCircle className="h-12 w-12 text-success" />
<div>
<p className="font-display text-xl">{t('skattekonto_result_title')}</p>
<p className="mt-2 text-sm text-muted-foreground">
{t('skattekonto_result_summary', {
imported: result.imported,
duplicates: result.duplicates,
})}
{result.promoted > 0 &&
` ${t('skattekonto_result_promoted', { count: result.promoted })}`}
</p>
{result.errors > 0 && (
<p className="mt-1 text-sm text-destructive">
{t('skattekonto_result_errors', { count: result.errors })}
</p>
)}
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<Button asChild>
<Link href="/skattekonto">{t('skattekonto_result_open_skattekonto')}</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/transactions">{t('skattekonto_result_book_rows')}</Link>
</Button>
<Button variant="outline" onClick={onNewImport}>
{t('skattekonto_result_new_import')}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,143 @@
'use client'
import { useState, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Upload, FileText, AlertCircle, HelpCircle } from 'lucide-react'
interface SkattekontoFileUploadStepProps {
onFileSelect: (file: File) => void
isLoading: boolean
error: string | null
errorTitle?: string | null
}
const ACCEPTED_EXTENSIONS = ['.csv', '.txt', '.skv']
export default function SkattekontoFileUploadStep({
onFileSelect,
isLoading,
error,
errorTitle,
}: SkattekontoFileUploadStepProps) {
const t = useTranslations('import')
const [isDragging, setIsDragging] = useState(false)
const acceptFile = useCallback(
(file: File | undefined) => {
if (!file) return
const name = file.name.toLowerCase()
if (ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))) {
onFileSelect(file)
}
},
[onFileSelect],
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
acceptFile(e.dataTransfer.files[0])
},
[acceptFile],
)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
{t('skattekonto_upload_title')}
</CardTitle>
<CardDescription>{t('skattekonto_upload_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
role="button"
tabIndex={isLoading ? -1 : 0}
aria-label={t('skattekonto_tap_select')}
className={`
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
${error ? 'border-destructive bg-destructive/5' : ''}
${isLoading ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setIsDragging(false)
}}
onDrop={handleDrop}
onClick={() => document.getElementById('skattekonto-file-input')?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
document.getElementById('skattekonto-file-input')?.click()
}
}}
>
<input
id="skattekonto-file-input"
type="file"
accept={ACCEPTED_EXTENSIONS.join(',')}
className="hidden"
onChange={(e) => acceptFile(e.target.files?.[0])}
disabled={isLoading}
/>
{isLoading ? (
<div className="space-y-4">
<FileText className="mx-auto h-12 w-12 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">{t('skattekonto_analyzing')}</p>
<Progress value={33} className="w-48 mx-auto" />
</div>
) : (
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<p className="font-medium hidden sm:block">{t('skattekonto_drop_here')}</p>
<p className="font-medium sm:hidden">{t('skattekonto_tap_select')}</p>
<p className="text-sm text-muted-foreground">{t('skattekonto_file_types')}</p>
</div>
</div>
)}
</div>
{error && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">
{errorTitle || t('skattekonto_error_title')}
</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<HelpCircle className="h-4 w-4" />
{t('skattekonto_howto_title')}
</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-3">
<div>
<p className="font-medium">Skatteverket</p>
<p className="text-muted-foreground">{t('skattekonto_howto_steps')}</p>
</div>
<p className="text-muted-foreground">{t('skattekonto_howto_note')}</p>
</CardContent>
</Card>
</div>
)
}
@@ -18,6 +18,8 @@ function makeRow(
belopp_kronofogden: 0,
status: 'upcoming',
journal_entry_id: null,
source: 'api',
file_import_id: null,
imported_at: '2026-05-15T10:00:00Z',
updated_at: '2026-05-15T10:00:00Z',
...overrides,
@@ -0,0 +1,169 @@
import { randomUUID } from 'crypto'
import { describe, expect, it } from 'vitest'
import { seedCompany, insertAuthUser } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* RLS + constraint smoke for skattekonto_file_imports and the provenance
* columns migration 20260817120000 added to skattekonto_transactions.
*/
async function insertFileImport(params: {
companyId: string
userId: string
fileHash?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.skattekonto_file_imports
(id, company_id, user_id, filename, file_hash, file_variant)
VALUES ($1, $2, $3, 'Kontoutdrag.csv', $4, 'csv')`,
[id, params.companyId, params.userId, params.fileHash ?? randomUUID().replace(/-/g, '')],
)
return id
}
describe('skattekonto_file_imports.pg: RLS and constraints', () => {
it('a user only sees import records for their own company', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertFileImport({ companyId: a.companyId, userId: a.userId })
await insertFileImport({ companyId: b.companyId, userId: b.userId })
const rows = await withUserContext(a.userId, async (client) => {
const res = await client.query<{ company_id: string }>(
`SELECT company_id FROM public.skattekonto_file_imports`,
)
return res.rows
})
expect(rows).toHaveLength(1)
expect(rows[0]!.company_id).toBe(a.companyId)
})
it('blocks inserting an import record into another tenant', async () => {
const a = await seedCompany()
const b = await seedCompany()
await expect(
withUserContext(a.userId, async (client) => {
return client.query(
`INSERT INTO public.skattekonto_file_imports
(company_id, user_id, filename, file_hash, file_variant)
VALUES ($1, $2, 'x.csv', 'hash-x', 'csv')`,
[b.companyId, a.userId],
)
}),
).rejects.toThrow(/row-level security/i)
})
it('binds user_id to the authenticated user on insert', async () => {
const a = await seedCompany()
const b = await seedCompany()
// Attributing the import to a colleague (or anyone else) is rejected...
await expect(
withUserContext(a.userId, async (client) => {
return client.query(
`INSERT INTO public.skattekonto_file_imports
(company_id, user_id, filename, file_hash, file_variant)
VALUES ($1, $2, 'x.csv', 'hash-imp', 'csv')`,
[a.companyId, b.userId],
)
}),
).rejects.toThrow(/row-level security/i)
// ...while inserting as oneself succeeds.
await expect(
withUserContext(a.userId, async (client) => {
return client.query(
`INSERT INTO public.skattekonto_file_imports
(company_id, user_id, filename, file_hash, file_variant)
VALUES ($1, $2, 'x.csv', 'hash-self', 'csv')`,
[a.companyId, a.userId],
)
}),
).resolves.toBeDefined()
})
it('keeps the import record with user_id NULL when the importer is deleted', async () => {
const a = await seedCompany()
// A separate importer (not the company creator) so the company itself
// survives the user deletion; only the provenance link should clear.
const importer = await insertAuthUser()
const importId = await insertFileImport({ companyId: a.companyId, userId: importer })
await getPool().query(`DELETE FROM auth.users WHERE id = $1`, [importer])
const res = await getPool().query<{ user_id: string | null }>(
`SELECT user_id FROM public.skattekonto_file_imports WHERE id = $1`,
[importId],
)
expect(res.rows).toHaveLength(1)
expect(res.rows[0]!.user_id).toBeNull()
})
it('enforces unique (company_id, file_hash) but allows the same hash across tenants', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertFileImport({ companyId: a.companyId, userId: a.userId, fileHash: 'same-hash' })
await expect(
insertFileImport({ companyId: a.companyId, userId: a.userId, fileHash: 'same-hash' }),
).rejects.toThrow(/duplicate key|unique/i)
await expect(
insertFileImport({ companyId: b.companyId, userId: b.userId, fileHash: 'same-hash' }),
).resolves.toBeDefined()
})
it('rejects unknown file variants and statuses', async () => {
const a = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.skattekonto_file_imports
(company_id, user_id, filename, file_hash, file_variant)
VALUES ($1, $2, 'x.xlsx', 'hash-v', 'xlsx')`,
[a.companyId, a.userId],
),
).rejects.toThrow(/check constraint/i)
})
})
describe('skattekonto_transactions.pg: provenance columns', () => {
it('defaults source to api and rejects unknown sources', async () => {
const a = await seedCompany()
const res = await getPool().query<{ source: string }>(
`INSERT INTO public.skattekonto_transactions
(company_id, dedup_key, transaktionsdatum, transaktionstext, belopp_skatteverket, status)
VALUES ($1, 'id:777', '2026-04-15', 'Test', -100, 'booked')
RETURNING source`,
[a.companyId],
)
expect(res.rows[0]!.source).toBe('api')
await expect(
getPool().query(
`INSERT INTO public.skattekonto_transactions
(company_id, dedup_key, transaktionsdatum, transaktionstext, belopp_skatteverket, status, source)
VALUES ($1, 'id:778', '2026-04-15', 'Test', -100, 'booked', 'smoke_signals')`,
[a.companyId],
),
).rejects.toThrow(/check constraint/i)
})
it('nulls file_import_id when the import record is deleted, keeping the row', async () => {
const a = await seedCompany()
const importId = await insertFileImport({ companyId: a.companyId, userId: a.userId })
const txId = randomUUID()
await getPool().query(
`INSERT INTO public.skattekonto_transactions
(id, company_id, dedup_key, transaktionsdatum, transaktionstext,
belopp_skatteverket, status, source, file_import_id)
VALUES ($1, $2, 'h:abc', '2026-06-06', 'Kostnadsränta', -10, 'booked', 'file_import', $3)`,
[txId, a.companyId, importId],
)
await getPool().query(`DELETE FROM public.skattekonto_file_imports WHERE id = $1`, [importId])
const res = await getPool().query<{ file_import_id: string | null; source: string }>(
`SELECT file_import_id, source FROM public.skattekonto_transactions WHERE id = $1`,
[txId],
)
expect(res.rows).toHaveLength(1)
expect(res.rows[0]!.file_import_id).toBeNull()
expect(res.rows[0]!.source).toBe('file_import')
})
})
@@ -0,0 +1,194 @@
/**
* Tests for the sync-time takeover of file-imported rows.
*
* A skattekontoutdrag file import writes booked rows with `h:` dedup keys
* (statements carry no transaktionsidentitet); the API identifies the same
* transactions with `id:` keys. When a company connects the API after a file
* import, syncSkattekonto must adopt the existing rows in place (keeping row
* id and journal_entry_id) instead of inserting duplicates.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { computeDedupKey } from '@/lib/skatteverket/skattekonto-dedup'
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const getSaldoMock = vi.fn()
const getTransaktionerMock = vi.fn()
vi.mock('../lib/skattekonto-client', () => ({
getSaldo: (...args: unknown[]) => getSaldoMock(...args),
getTransaktioner: (...args: unknown[]) => getTransaktionerMock(...args),
}))
vi.mock('../lib/agi-tax-settlement', () => ({
settleAgiTaxPayments: vi.fn().mockResolvedValue(undefined),
}))
import { syncSkattekonto } from '../lib/skattekonto-sync'
import type { ExtensionContext } from '@/lib/extensions/types'
function makeCtx(): ExtensionContext {
return {
supabase,
companyId: 'company-1',
userId: 'user-1',
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
},
emit: vi.fn().mockResolvedValue(undefined),
} as unknown as ExtensionContext
}
const AGI_BOOKED = {
transaktionsidentitet: 9001,
transaktionsdatum: '2026-07-13',
ranteberakningsdatum: '2026-07-13',
transaktionstext: 'Arbetsgivaravgift juni 2026',
beloppSkatteverket: -15710,
beloppKronofogden: 0,
}
const FILE_ROW_HASH_KEY = computeDedupKey({
transaktionsidentitet: null,
transaktionsdatum: '2026-07-13',
beloppSkatteverket: -15710,
transaktionstext: 'Arbetsgivaravgift juni 2026',
})
function makeSaldo() {
return {
nastaAvstamningsdatum: '2026-09-05',
senastUppdaterad: '2026-08-17',
informationstext: [],
saldoSkatteverket: 1000,
saldoKronofogden: 0,
rantaSkatteverket: 0,
rantaKronofogden: 0,
ocrNummer: '1234567897',
}
}
describe('syncSkattekonto: takeover of file-imported rows', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
getSaldoMock.mockResolvedValue(makeSaldo())
})
it('adopts a matching hash-keyed row in place instead of duplicating it', async () => {
getTransaktionerMock.mockResolvedValue({
tidigareTransaktioner: [AGI_BOOKED],
kommandeTransaktioner: [],
})
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
enqueue({ data: [] }) // existing dedup_key lookup: id:9001 not present
enqueue({
data: [
{
id: 'file-row-1',
dedup_key: FILE_ROW_HASH_KEY,
status: 'booked',
transaktionsdatum: '2026-07-13',
transaktionstext: 'Arbetsgivaravgift juni 2026',
belopp_skatteverket: -15710,
},
],
}) // takeover candidate scan
enqueue({ data: null }) // takeover update
enqueue({ data: null }) // upsert
await syncSkattekonto(makeCtx())
const updates = findCalls('skattekonto_transactions', 'update')
expect(updates).toHaveLength(1)
expect(updates[0][0]).toEqual({
dedup_key: 'id:9001',
transaktionsidentitet: 9001,
source: 'api',
})
// The upsert then resolves onto the adopted key: no fresh insert path.
const upserts = findCalls('skattekonto_transactions', 'upsert')
expect(upserts).toHaveLength(1)
const rows = upserts[0][0] as Array<Record<string, unknown>>
expect(rows).toHaveLength(1)
expect(rows[0].dedup_key).toBe('id:9001')
})
it('prefers the booked candidate even when it is last in a 3-candidate queue', async () => {
getTransaktionerMock.mockResolvedValue({
tidigareTransaktioner: [AGI_BOOKED],
kommandeTransaktioner: [],
})
const candidate = {
dedup_key: FILE_ROW_HASH_KEY,
transaktionsdatum: '2026-07-13',
transaktionstext: 'Arbetsgivaravgift juni 2026',
belopp_skatteverket: -15710,
}
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
enqueue({ data: [] }) // existing dedup_key lookup
enqueue({
data: [
{ id: 'stale-upcoming-1', status: 'upcoming', ...candidate },
{ id: 'stale-upcoming-2', status: 'upcoming', ...candidate },
{ id: 'file-row-booked', status: 'booked', ...candidate },
],
}) // takeover candidate scan
enqueue({ data: null }) // takeover update
enqueue({ data: null }) // upsert
await syncSkattekonto(makeCtx())
const updates = findCalls('skattekonto_transactions', 'update')
expect(updates).toHaveLength(1)
// The eq('id', ...) filter must target the booked file row, not a stale
// upcoming candidate that happened to sort first.
const eqCalls = findCalls('skattekonto_transactions', 'eq')
expect(eqCalls).toContainEqual(['id', 'file-row-booked'])
})
it('does not scan for takeover candidates when the id keys already exist', async () => {
getTransaktionerMock.mockResolvedValue({
tidigareTransaktioner: [AGI_BOOKED],
kommandeTransaktioner: [],
})
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
enqueue({ data: [{ dedup_key: 'id:9001', status: 'booked' }] }) // key already known
enqueue({ data: null }) // upsert
await syncSkattekonto(makeCtx())
expect(findCalls('skattekonto_transactions', 'update')).toHaveLength(0)
expect(findCalls('skattekonto_transactions', 'like')).toHaveLength(0)
expect(findCalls('skattekonto_transactions', 'upsert')).toHaveLength(1)
})
it('never flips a booked row back to upcoming on hash-key collision', async () => {
const kommande = {
transaktionsidentitet: null,
transaktionsdatum: '2026-07-13',
forfallodatum: '2026-07-14',
ranteberakningsdatum: null,
transaktionstext: 'Arbetsgivaravgift juni 2026',
beloppSkatteverket: -15710,
beloppKronofogden: 0,
}
getTransaktionerMock.mockResolvedValue({
tidigareTransaktioner: [],
kommandeTransaktioner: [kommande],
})
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
enqueue({ data: [{ dedup_key: FILE_ROW_HASH_KEY, status: 'booked' }] }) // same key, already booked
await syncSkattekonto(makeCtx())
// The colliding upcoming row is dropped: nothing left to upsert.
expect(findCalls('skattekonto_transactions', 'upsert')).toHaveLength(0)
})
})
@@ -1,9 +1,10 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExtensionContext } from '@/lib/extensions/types'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { computeDedupKey, contentSignature } from '@/lib/skatteverket/skattekonto-dedup'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { settleAgiTaxPayments } from './agi-tax-settlement'
import { getSaldo, getTransaktioner } from './skattekonto-client'
import { SkatteverketAuthError, type SkvAuth } from './api-client'
@@ -32,29 +33,11 @@ export interface SkattekontoSyncResult {
syncedAt: string
}
/**
* Compute the dedup key for a transaction.
*
* - When `transaktionsidentitet` is present (always on tidigare, sometimes
* on kommande), use it directly. It's stable across syncs.
* - Otherwise compute a sha256 hex over (date|amount|text): stable enough
* for kommande, which graduate to tidigare with the same content.
*
* The point of this function is reproducibility: the same logical
* transaction must always produce the same dedup_key.
*/
export function computeDedupKey(tx: {
transaktionsidentitet?: number | null
transaktionsdatum: string
beloppSkatteverket: number
transaktionstext: string
}): string {
if (tx.transaktionsidentitet != null) {
return `id:${tx.transaktionsidentitet}`
}
const material = `${tx.transaktionsdatum}|${tx.beloppSkatteverket}|${tx.transaktionstext}`
return `h:${crypto.createHash('sha256').update(material).digest('hex')}`
}
// Dedup key computation moved to core (lib/skatteverket/skattekonto-dedup):
// the skattekontoutdrag file importer is a second producer of this table and
// must compute byte-identical keys. Re-exported so existing extension-side
// imports keep working.
export { computeDedupKey }
/**
* Resolve the org/personnummer to send to Skatteverket as `omfragad`.
@@ -81,10 +64,15 @@ async function resolveOmfragad(
/**
* Build the row to insert/upsert into skattekonto_transactions.
*/
function bookedToRow(
companyId: string,
tx: SkatteverketBookedTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
// file_import_id is excluded from the upsert payload on purpose: when the
// sync takes over a file-imported row (see below) the provenance link back
// to the uploaded file should survive the conflict-update.
type SyncRow = Omit<
StoredSkattekontoTransaction,
'id' | 'imported_at' | 'updated_at' | 'journal_entry_id' | 'file_import_id'
>
function bookedToRow(companyId: string, tx: SkatteverketBookedTransaction): SyncRow {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet,
@@ -96,13 +84,11 @@ function bookedToRow(
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'booked',
source: 'api',
}
}
function upcomingToRow(
companyId: string,
tx: SkatteverketUpcomingTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
function upcomingToRow(companyId: string, tx: SkatteverketUpcomingTransaction): SyncRow {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet ?? null,
@@ -114,6 +100,7 @@ function upcomingToRow(
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'upcoming',
source: 'api',
}
}
@@ -202,10 +189,117 @@ export async function syncSkattekonto(
})
}
if (allRows.length > 0) {
// Take over hash-keyed rows for transactions the API now identifies.
//
// The skattekontoutdrag file importer writes booked rows with `h:` keys
// (statement exports carry no transaktionsidentitet); the API keys the
// same logical transactions `id:`. Without this step, connecting the API
// after a file import would duplicate every imported row. Pair each
// incoming id-keyed booked row that is NOT yet in the table against an
// unconsumed existing `h:` row with equal content and rewrite that row's
// identity in place: the row (and any journal_entry_id link on it)
// survives, and the upsert below then resolves onto the new key.
const newIdBooked = bookedRows.filter(
r => r.transaktionsidentitet != null && !existingMap.has(r.dedup_key),
)
if (newIdBooked.length > 0) {
const dates = newIdBooked.map(r => r.transaktionsdatum).sort()
// Paged: PostgREST silently caps unpaged reads at 1000 rows, and a file
// import covering several years can leave more hash rows than that in
// the window; unadopted candidates would duplicate on the upsert below.
const hashRows = await fetchAllRows<{
id: string
dedup_key: string
status: string
transaktionsdatum: string
transaktionstext: string
belopp_skatteverket: number
}>(({ from, to }) =>
ctx.supabase
.from('skattekonto_transactions')
.select('id, dedup_key, status, transaktionsdatum, transaktionstext, belopp_skatteverket')
.eq('company_id', ctx.companyId)
.like('dedup_key', 'h:%')
.gte('transaktionsdatum', dates[0])
.lte('transaktionsdatum', dates[dates.length - 1])
.order('id', { ascending: true })
.range(from, to),
)
if (hashRows.length > 0) {
// Multiset pairing by content signature; booked (imported) rows are
// preferred over stale upcoming rows with identical content.
const bySig = new Map<string, { id: string; status: string }[]>()
for (const row of hashRows) {
const sig = contentSignature(
row.transaktionsdatum,
row.belopp_skatteverket,
row.transaktionstext,
)
const queue = bySig.get(sig)
if (queue) queue.push({ id: row.id, status: row.status })
else bySig.set(sig, [{ id: row.id, status: row.status }])
}
// A proper two-argument comparator: the earlier `sort(a => ...)` form
// is an inconsistent relation and could leave an upcoming row at the
// head of a 3+ candidate queue, adopting the wrong row.
for (const queue of bySig.values()) {
queue.sort((a, b) =>
a.status === b.status ? 0 : a.status === 'booked' ? -1 : 1,
)
}
let takenOver = 0
for (const incoming of newIdBooked) {
const sig = contentSignature(
incoming.transaktionsdatum,
incoming.belopp_skatteverket,
incoming.transaktionstext,
)
const queue = bySig.get(sig)
const match = queue?.shift()
if (!match) continue
const { error } = await ctx.supabase
.from('skattekonto_transactions')
.update({
dedup_key: incoming.dedup_key,
transaktionsidentitet: incoming.transaktionsidentitet,
source: 'api',
})
.eq('id', match.id)
.eq('company_id', ctx.companyId)
if (error) {
// Non-fatal: the upsert below will then insert a fresh row, which
// is the pre-takeover behavior (a duplicate, but never data loss).
log.warn('hash-row takeover failed', {
companyId: ctx.companyId,
rowId: match.id,
message: error.message,
})
continue
}
existingMap.set(incoming.dedup_key, { status: 'booked' })
takenOver++
}
if (takenOver > 0) {
log.info('took over file-imported rows', { companyId: ctx.companyId, takenOver })
}
}
}
// A booked row must never be flipped back to upcoming. The API's booked
// rows always carry transaktionsidentitet, but a file-imported booked row
// hash-keys on the same material an id-less kommande row would, so an
// upcoming row colliding with an existing booked row is dropped rather
// than upserted.
const safeRows = allRows.filter(
r => r.status !== 'upcoming' || existingMap.get(r.dedup_key)?.status !== 'booked',
)
if (safeRows.length > 0) {
const { error } = await ctx.supabase
.from('skattekonto_transactions')
.upsert(allRows, {
.upsert(safeRows, {
onConflict: 'company_id,dedup_key',
// Don't return rows: we already know what we wrote.
ignoreDuplicates: false,
+25
View File
@@ -3516,3 +3516,28 @@ export const BankFileCheckDuplicatesSchema = z.object({
.max(20000),
format: z.enum(BANK_FILE_FORMAT_IDS),
})
/**
* POST /api/import/skattekonto-file/execute
*
* Rows are client-confirmed but the route recomputes dedup keys and
* re-partitions against the table server-side: the payload can only choose
* WHICH parsed rows to import, never what they dedup as. closing_saldo comes
* from the statement's "Utgående saldo" marker (not derivable from rows).
*/
export const SkattekontoFileExecuteSchema = z.object({
rows: z
.array(
z.object({
transaktionsdatum: isoDate,
transaktionstext: z.string().min(1).max(500),
belopp: z.number().finite(),
})
)
.min(1)
.max(20000),
filename: z.string().min(1).max(255),
file_hash: z.string().regex(/^[0-9a-f]{64}$/),
variant: z.enum(['csv', 'skv']),
closing_saldo: z.number().finite().nullable().optional(),
})
+60
View File
@@ -1686,6 +1686,65 @@ const BANK_FILE: Record<string, StructuredErrorEntry> = {
message_sv: 'Bankfilsimporten misslyckades.',
message_en: 'Bank file import failed.',
},
BANK_FILE_SKATTEKONTO_DETECTED: {
httpStatus: 400,
message_sv:
'Filen ser ut som ett skattekontoutdrag från Skatteverket. Använd Skattekonto-importen i stället.',
message_en:
'This file looks like a Skatteverket tax account statement. Use the skattekonto import instead.',
},
}
const SKATTEKONTO_FILE: Record<string, StructuredErrorEntry> = {
SKATTEKONTO_FILE_NO_FILE: {
httpStatus: 400,
message_sv: 'Ingen fil bifogad i förfrågan.',
message_en: 'No file attached to the request.',
},
SKATTEKONTO_FILE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Filen är för stor. Maxstorlek är 10 MB.',
message_en: 'File exceeds the 10 MB size limit.',
},
SKATTEKONTO_FILE_DUPLICATE: {
httpStatus: 409,
message_sv: 'Det här kontoutdraget har redan importerats.',
message_en: 'This tax account statement has already been imported.',
},
SKATTEKONTO_FILE_PARSE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tolka skattekontoutdraget.',
message_en: 'Failed to parse the tax account statement.',
},
SKATTEKONTO_FILE_NOT_RECOGNIZED: {
httpStatus: 400,
message_sv:
'Filen känns inte igen som ett skattekontoutdrag. Ladda ner kontohändelserna från Skatteverkets e-tjänst Skattekonto och försök igen.',
message_en:
'The file was not recognized as a tax account statement. Download the account events from Skatteverket and try again.',
},
SKATTEKONTO_FILE_SUM_MISMATCH: {
httpStatus: 400,
message_sv:
'Utdraget summerar inte: ingående saldo plus händelser stämmer inte med utgående saldo. Filen kan vara ofullständig.',
message_en:
'The statement does not sum: opening balance plus events does not equal the closing balance. The file may be incomplete.',
},
SKATTEKONTO_FILE_NO_ROWS: {
httpStatus: 400,
message_sv: 'Kontoutdraget innehåller inga händelser att importera.',
message_en: 'No account events to import.',
},
SKATTEKONTO_FILE_IMPORT_RECORD_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte skapa importpost.',
message_en: 'Failed to create the import record.',
},
SKATTEKONTO_FILE_EXECUTE_FAILED: {
httpStatus: 500,
message_sv: 'Importen av skattekontoutdraget misslyckades.',
message_en: 'Tax account statement import failed.',
},
}
const OPENING_BALANCE_IMPORT: Record<string, StructuredErrorEntry> = {
@@ -3584,6 +3643,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...TAX_DECL,
...SIE_IMPORT,
...BANK_FILE,
...SKATTEKONTO_FILE,
...OPENING_BALANCE_IMPORT,
...REGISTER_IMPORT,
...PROVIDER_MIGRATION,
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { detectSkattekontoFile, parseSkattekontoFile } from '../parser'
beforeEach(() => {
vi.clearAllMocks()
})
// Structure-exact mirror of a real 2026-08 export from Skatteverket's
// skattekonto e-service (name, orgnr and amounts sanitized; the real file is
// never committed). Every cell double-quoted, semicolon-delimited, CRLF,
// opening/closing saldo as marker rows, space thousands separator, and the
// invariant opening + sum(rows) = closing holds.
const MODERN_CSV = [
'"Testbolaget AB";"556677-8899";""',
'"";"";""',
'"";"Ingående saldo 2026-05-03";"-500"',
'"2026-06-06";"Kostnadsränta";"-10"',
'"2026-07-04";"Kostnadsränta";"-5"',
'"2026-07-11";"Inbetalning bokförd 260710";"24 000"',
'"2026-07-13";"Arbetsgivaravgift juni 2026";"-15 000"',
'"2026-07-13";"Avdragen skatt juni 2026";"-9 000"',
'"2026-07-23";"Inbetalning bokförd 260722";"600"',
'"2026-07-28";"Inbetalning bokförd 260727";"35 000"',
'"2026-08-01";"Kostnadsränta";"-5"',
'"2026-08-01";"Intäktsränta";"7"',
'"";"Utgående saldo 2026-08-01";"35 087"',
'',
].join('\r\n')
const MODERN_FILENAME = 'Kontoutdrag 556677-8899 2026-05-03--2026-08-01.csv'
// Legacy .skv shape: unquoted, headerless, trailing running-saldo column.
const LEGACY_SKV = [
'2024-01-12;Debiterad preliminärskatt;-8000;-8000',
'2024-01-14;Inbetalning bokförd 240113;8000;0',
'2024-02-12;Debiterad preliminärskatt;-8000;-8000',
'2024-02-13;Intäktsränta;12;-7988',
].join('\n')
// Bank fixtures (mirrored from the bank parser tests): must never detect.
const SEB_BANK_CSV = [
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;12346;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
].join('\n')
const NORDEA_BANK_CSV = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
'2024-01-14,ICA MAXI LINDHAGEN,,"-432,50","12 444,67"',
].join('\n')
// A minimal semicolon bank export whose descriptions mention tax concepts:
// one vocabulary hit must not be enough to steal the file.
const GENERIC_BANK_WITH_TAX_PAYMENT = [
'2024-01-12;Betalning moms Skatteverket;-12000',
'2024-01-15;SPOTIFY AB;-99',
'2024-01-20;Kortköp ICA;-432',
'2024-01-25;Swish Anna;-200',
].join('\n')
describe('detectSkattekontoFile', () => {
it('detects the modern export by content', () => {
expect(detectSkattekontoFile(MODERN_CSV, 'nedladdad-fil.csv')).toBe(true)
})
it('detects by Skatteverket export filename', () => {
expect(detectSkattekontoFile('', MODERN_FILENAME)).toBe(true)
})
it('detects .skv files by extension', () => {
expect(detectSkattekontoFile(LEGACY_SKV, 'skattekonto.skv')).toBe(true)
})
it('detects legacy content via vocabulary plus row shape', () => {
expect(detectSkattekontoFile(LEGACY_SKV, 'export.csv')).toBe(true)
})
it('does not detect SEB bank exports', () => {
expect(detectSkattekontoFile(SEB_BANK_CSV, 'kontoutdrag.csv')).toBe(false)
})
it('does not detect Nordea bank exports', () => {
expect(detectSkattekontoFile(NORDEA_BANK_CSV, 'export.csv')).toBe(false)
})
it('does not detect a bank file that merely pays Skatteverket', () => {
expect(detectSkattekontoFile(GENERIC_BANK_WITH_TAX_PAYMENT, 'bank.csv')).toBe(false)
})
it('does not detect empty content', () => {
expect(detectSkattekontoFile('', 'fil.csv')).toBe(false)
})
})
describe('parseSkattekontoFile: modern export', () => {
const result = parseSkattekontoFile(MODERN_CSV, MODERN_FILENAME)
it('parses all transaction rows', () => {
expect(result.rows).toHaveLength(9)
expect(result.stats).toEqual({ total_rows: 9, parsed_rows: 9, skipped_rows: 0 })
expect(result.variant).toBe('csv')
})
it('extracts company identity from the header row', () => {
expect(result.company_name).toBe('Testbolaget AB')
expect(result.org_number).toBe('556677-8899')
})
it('consumes saldo markers as metadata, never as rows', () => {
expect(result.opening_saldo).toBe(-500)
expect(result.closing_saldo).toBe(35087)
expect(result.rows.some((r) => /saldo/i.test(r.transaktionstext))).toBe(false)
})
it('parses space-separated thousands and signs per SKV convention', () => {
const inbetalning = result.rows.find(
(r) => r.transaktionstext === 'Inbetalning bokförd 260710',
)
expect(inbetalning?.belopp).toBe(24000)
const agi = result.rows.find(
(r) => r.transaktionstext === 'Arbetsgivaravgift juni 2026',
)
expect(agi?.belopp).toBe(-15000)
})
it('validates the sum invariant', () => {
expect(result.sum_valid).toBe(true)
expect(result.issues.filter((i) => i.severity === 'error')).toHaveLength(0)
})
it('extracts the date range', () => {
expect(result.date_from).toBe('2026-06-06')
expect(result.date_to).toBe('2026-08-01')
})
})
describe('parseSkattekontoFile: legacy .skv', () => {
const result = parseSkattekontoFile(LEGACY_SKV, 'skattekonto.skv')
it('parses unquoted rows and ignores the trailing saldo column', () => {
expect(result.variant).toBe('skv')
expect(result.rows).toHaveLength(4)
expect(result.rows[0]).toMatchObject({
transaktionsdatum: '2024-01-12',
transaktionstext: 'Debiterad preliminärskatt',
belopp: -8000,
})
})
it('has no header identity and no sum check without markers', () => {
expect(result.org_number).toBeNull()
expect(result.company_name).toBeNull()
expect(result.sum_valid).toBeNull()
})
})
describe('parseSkattekontoFile: robustness', () => {
it('fails a statement whose closing saldo marker is missing', () => {
const cutOff = MODERN_CSV.replace('"";"Utgående saldo 2026-08-01";"35 087"\r\n', '')
const result = parseSkattekontoFile(cutOff, MODERN_FILENAME)
expect(result.sum_valid).toBe(false)
expect(result.issues.some((i) => i.severity === 'error')).toBe(true)
})
it('fails a statement whose saldo amount is unreadable', () => {
const garbled = MODERN_CSV.replace('"35 087"', '"trasigt"')
const result = parseSkattekontoFile(garbled, MODERN_FILENAME)
expect(result.sum_valid).toBe(false)
expect(result.issues.some((i) => i.severity === 'error')).toBe(true)
})
it('flags a sum mismatch as an error', () => {
const truncated = MODERN_CSV.replace(
'"2026-07-28";"Inbetalning bokförd 260727";"35 000"\r\n',
'',
)
const result = parseSkattekontoFile(truncated, MODERN_FILENAME)
expect(result.sum_valid).toBe(false)
expect(result.issues.some((i) => i.severity === 'error')).toBe(true)
})
it('skips malformed rows with warnings', () => {
const withBad = [
'"2026-06-06";"Kostnadsränta";"-10"',
'"inte-ett-datum";"Trasig rad";"-5"',
'"2026-06-07";"";"-5"',
'"2026-06-08";"Kostnadsränta";"abc"',
].join('\n')
const result = parseSkattekontoFile(withBad, 'export.csv')
expect(result.rows).toHaveLength(1)
expect(result.stats.skipped_rows).toBe(3)
expect(result.issues.filter((i) => i.severity === 'warning')).toHaveLength(3)
})
it('notes identical rows with an info issue and keeps both', () => {
const duplicated = [
'"2026-06-06";"Kostnadsränta";"-10"',
'"2026-06-06";"Kostnadsränta";"-10"',
].join('\n')
const result = parseSkattekontoFile(duplicated, 'export.csv')
expect(result.rows).toHaveLength(2)
expect(result.issues.some((i) => i.severity === 'info')).toBe(true)
})
it('strips a BOM before the header row', () => {
const withBom = '\uFEFF' + MODERN_CSV
const result = parseSkattekontoFile(withBom, MODERN_FILENAME)
expect(result.org_number).toBe('556677-8899')
expect(result.rows).toHaveLength(9)
})
it('handles empty content', () => {
const result = parseSkattekontoFile('', 'export.csv')
expect(result.rows).toHaveLength(0)
expect(result.sum_valid).toBeNull()
})
it('tolerates a dated marker row without importing it', () => {
const legacyMarkers = [
'2024-01-01;Ingående saldo;0;0',
'2024-01-12;Debiterad preliminärskatt;-8000;-8000',
'2024-01-31;Utgående saldo;-8000;-8000',
].join('\n')
const result = parseSkattekontoFile(legacyMarkers, 'skattekonto.skv')
expect(result.rows).toHaveLength(1)
expect(result.opening_saldo).toBe(0)
expect(result.closing_saldo).toBe(-8000)
expect(result.sum_valid).toBe(true)
})
})
@@ -0,0 +1,149 @@
/**
* Server-side execution of a skattekontoutdrag file import.
*
* Writes to skattekonto_transactions directly (RLS-scoped): imported rows
* then flow through the exact same booking/matching pipeline as API-synced
* rows. The API sync's takeover step (skattekonto-sync.ts) reconciles these
* hash-keyed rows if the company later connects Skatteverket.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
assignFileDedupKeys,
partitionFileRows,
type ExistingSkattekontoRow,
type SkattekontoFileRowIdentity,
} from '@/lib/skatteverket/skattekonto-dedup'
/**
* Existing rows in the statement's date span, for partitioning. Paged: a
* statement can cover years and PostgREST caps unpaged reads at 1000 rows.
*/
export async function fetchExistingSkattekontoRows(
supabase: SupabaseClient,
companyId: string,
dateFrom: string,
dateTo: string,
): Promise<ExistingSkattekontoRow[]> {
return fetchAllRows<ExistingSkattekontoRow>(({ from, to }) =>
supabase
.from('skattekonto_transactions')
.select('id, dedup_key, status, transaktionsdatum, transaktionstext, belopp_skatteverket')
.eq('company_id', companyId)
.gte('transaktionsdatum', dateFrom)
.lte('transaktionsdatum', dateTo)
.order('id', { ascending: true })
.range(from, to),
)
}
export interface SkattekontoFileImportOutcome {
imported: number
duplicates: number
promoted: number
/** Rows that failed to write for reasons other than being duplicates. */
errors: number
first_error: string | null
}
const INSERT_BATCH_SIZE = 200
/** Postgres unique_violation */
const UNIQUE_VIOLATION = '23505'
/**
* Partition the confirmed rows against the table and write them:
* inserts for new rows, in-place status promotions for upcoming rows the
* statement proves settled, skips for rows already booked.
*
* Batched inserts fall back to per-row on unique violations so one residual
* conflict (e.g. a concurrent sync) degrades to a counted duplicate instead
* of failing the whole import.
*/
export async function executeSkattekontoFileImport(
supabase: SupabaseClient,
companyId: string,
fileImportId: string,
rows: SkattekontoFileRowIdentity[],
): Promise<SkattekontoFileImportOutcome> {
const dates = rows.map((r) => r.transaktionsdatum).sort()
const existing = await fetchExistingSkattekontoRows(
supabase,
companyId,
dates[0],
dates[dates.length - 1],
)
const partition = partitionFileRows(assignFileDedupKeys(rows), existing)
const outcome: SkattekontoFileImportOutcome = {
imported: 0,
duplicates: partition.duplicates.length,
promoted: 0,
errors: 0,
first_error: null,
}
const toRow = (entry: { row: SkattekontoFileRowIdentity; dedupKey: string }) => ({
company_id: companyId,
transaktionsidentitet: null,
dedup_key: entry.dedupKey,
transaktionsdatum: entry.row.transaktionsdatum,
forfallodatum: null,
ranteberakningsdatum: null,
transaktionstext: entry.row.transaktionstext,
belopp_skatteverket: entry.row.belopp,
belopp_kronofogden: null,
status: 'booked' as const,
source: 'file_import' as const,
file_import_id: fileImportId,
})
for (let i = 0; i < partition.toInsert.length; i += INSERT_BATCH_SIZE) {
const batch = partition.toInsert.slice(i, i + INSERT_BATCH_SIZE)
const { error } = await supabase
.from('skattekonto_transactions')
.insert(batch.map(toRow))
if (!error) {
outcome.imported += batch.length
continue
}
if (error.code !== UNIQUE_VIOLATION) {
outcome.errors += batch.length
outcome.first_error = outcome.first_error ?? error.message
continue
}
for (const entry of batch) {
const { error: rowError } = await supabase
.from('skattekonto_transactions')
.insert(toRow(entry))
if (!rowError) {
outcome.imported++
} else if (rowError.code === UNIQUE_VIOLATION) {
outcome.duplicates++
} else {
outcome.errors++
outcome.first_error = outcome.first_error ?? rowError.message
}
}
}
for (const promotion of partition.promotions) {
// Return the updated rows: when a concurrent sync already flipped the
// row, zero rows match and the promotion must not be counted.
const { data: updated, error } = await supabase
.from('skattekonto_transactions')
.update({ status: 'booked' })
.eq('id', promotion.existingId)
.eq('company_id', companyId)
.eq('status', 'upcoming')
.select('id')
if (error) {
outcome.errors++
outcome.first_error = outcome.first_error ?? error.message
} else if (updated && updated.length > 0) {
outcome.promoted++
}
}
return outcome
}
+272
View File
@@ -0,0 +1,272 @@
/**
* Skattekontoutdrag parser (Skatteverket tax account statement export).
*
* Primary layout: the CSV download from Skatteverket's skattekonto e-service
* (verified against a real 2026-08 export). Semicolon-delimited, every cell
* double-quoted, UTF-8 with BOM, CRLF:
*
* "Company AB";"NNNNNN-NNNN";""
* "";"";""
* "";"Ingående saldo YYYY-MM-DD";"-626"
* "YYYY-MM-DD";"Kostnadsränta";"-11"
* "YYYY-MM-DD";"Inbetalning bokförd 260710";"24 678"
* "";"Utgående saldo YYYY-MM-DD";"35 842"
*
* Three columns, no per-row running balance: the opening/closing saldo live
* in marker rows with an empty date cell. Amounts are whole kronor with a
* space thousands separator, negative = debit on the tax account (matches
* belopp_skatteverket in skattekonto_transactions).
*
* Secondary tolerance: legacy `.skv` text exports from the retired
* e-service. Same date;text;amount row shape but possibly unquoted, without
* the name/orgnr header, and sometimes with a trailing running-saldo column,
* which is ignored.
*/
import { roundOre } from '@/lib/money'
import { prepareContent } from '../shared/encoding'
import { normalizeDate } from '../bank-file/date-utils'
import { parseCSVLine } from '../bank-file/formats/nordea'
import type {
ParsedSkattekontoFileRow,
SkattekontoFileParseIssue,
SkattekontoFileParseResult,
} from './types'
const ORG_NUMBER_RE = /^\d{6}-\d{4}$/
const OPENING_MARKER_RE = /^ing(å|a)ende saldo/i
const CLOSING_MARKER_RE = /^utg(å|a)ende saldo/i
/** Skatteverket's export filename: "Kontoutdrag 559538-6219 2026-05-03--2026-08-01.csv" */
const EXPORT_FILENAME_RE = /kontoutdrag \d{6}-\d{4} \d{4}-\d{2}-\d{2}--\d{4}-\d{2}-\d{2}/i
/**
* Vocabulary characteristic of skattekonto statements. Terms that also occur
* in bank statement descriptions (a payment TO Skatteverket can say "moms" or
* "arbetsgivaravgift") are deliberately excluded from being sufficient alone:
* legacy detection requires at least two DISTINCT hits plus the row shape.
*/
const SKV_VOCABULARY = [
'ingående saldo',
'utgående saldo',
'debiterad preliminärskatt',
'avdragen skatt',
'intäktsränta',
'kostnadsränta',
'inbetalning bokförd',
]
/**
* Parse a skattekonto amount: whole kronor or comma decimals, space/nbsp
* thousands separators, optional trailing "kr". Returns null on non-amounts.
*/
function parseAmount(value: string): number | null {
const cleaned = value
// \s covers regular space, nbsp (U+00A0) and narrow nbsp (U+202F).
.replace(/\s/g, '')
.replace(/kr$/i, '')
.replace(',', '.')
if (cleaned === '' || cleaned === '-') return null
if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return null
return roundOre(parseFloat(cleaned))
}
function splitRow(line: string): string[] {
return parseCSVLine(line, ';').map((cell) => cell.trim())
}
function isEmptyRow(cells: string[]): boolean {
return cells.every((cell) => cell === '')
}
/**
* Detect whether a file is a skattekontoutdrag. Deliberately strict: this
* runs inside the bank-file parse route as a redirect hint, so it must never
* claim a bank CSV.
*/
export function detectSkattekontoFile(content: string, filename: string): boolean {
if (/\.skv$/i.test(filename) || EXPORT_FILENAME_RE.test(filename)) return true
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
if (lines.length === 0) return false
// Modern export: orgnr in the header row plus a saldo marker row.
const firstCells = splitRow(lines[0])
const hasOrgHeader = firstCells.length >= 2 && ORG_NUMBER_RE.test(firstCells[1])
const hasMarker = lines.some((line) => {
const cells = splitRow(line)
const text = cells[1] ?? ''
return OPENING_MARKER_RE.test(text) || CLOSING_MARKER_RE.test(text)
})
if (hasOrgHeader && hasMarker) return true
// Legacy headerless files: at least two DISTINCT skattekonto terms plus a
// dominant date;text;amount row shape. A bank CSV mentioning "moms" in one
// description fails the two-term requirement; a bank export's header row
// and extra columns fail the shape requirement.
const lower = prepared.toLowerCase()
const distinctTerms = SKV_VOCABULARY.filter((term) => lower.includes(term)).length
if (!hasMarker && distinctTerms < 2) return false
let shapeMatches = 0
for (const line of lines) {
const cells = splitRow(line)
if (cells.length < 3) continue
if (normalizeDate(cells[0]) && cells[1] !== '' && parseAmount(cells[2]) !== null) {
shapeMatches++
}
}
return shapeMatches >= 3 && shapeMatches / lines.length >= 0.6
}
/**
* Parse a skattekontoutdrag into transaction rows plus the statement's
* opening/closing saldo. Marker and header rows are consumed as metadata,
* never emitted as transactions.
*/
export function parseSkattekontoFile(
content: string,
filename: string,
): SkattekontoFileParseResult {
const variant: 'csv' | 'skv' = /\.skv$/i.test(filename) ? 'skv' : 'csv'
const prepared = prepareContent(content)
const lines = prepared.split('\n')
const rows: ParsedSkattekontoFileRow[] = []
const issues: SkattekontoFileParseIssue[] = []
let companyName: string | null = null
let orgNumber: string | null = null
let openingSaldo: number | null = null
let closingSaldo: number | null = null
let sawSaldoMarker = false
let totalRows = 0
let skippedRows = 0
const seenContent = new Map<string, number>()
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.trim() === '') continue
const cells = splitRow(line)
if (isEmptyRow(cells)) continue
// Header row: "Company AB";"NNNNNN-NNNN";""
if (orgNumber === null && cells.length >= 2 && ORG_NUMBER_RE.test(cells[1])) {
companyName = cells[0] || null
orgNumber = cells[1]
continue
}
// Marker rows: "Ingående/Utgående saldo YYYY-MM-DD" in the text cell.
// The modern export leaves the date cell empty; tolerate legacy variants
// that date the marker row. Never import a marker as a transaction.
const markerText = cells[1] ?? ''
if (OPENING_MARKER_RE.test(markerText) || CLOSING_MARKER_RE.test(markerText)) {
sawSaldoMarker = true
const amount = parseAmount(cells[2] ?? '')
if (amount === null) {
issues.push({
row: i + 1,
message: `Kunde inte läsa saldobeloppet: ${cells[2] ?? ''}`,
severity: 'warning',
})
} else if (OPENING_MARKER_RE.test(markerText)) {
openingSaldo = amount
} else {
closingSaldo = amount
}
continue
}
totalRows++
const date = normalizeDate(cells[0] ?? '')
if (!date) {
issues.push({
row: i + 1,
message: `Ogiltigt datum: ${cells[0] ?? ''}`,
severity: 'warning',
})
skippedRows++
continue
}
const text = cells[1] ?? ''
if (text === '') {
issues.push({ row: i + 1, message: 'Transaktionstext saknas', severity: 'warning' })
skippedRows++
continue
}
const belopp = parseAmount(cells[2] ?? '')
if (belopp === null) {
issues.push({
row: i + 1,
message: `Ogiltigt belopp: ${cells[2] ?? ''}`,
severity: 'warning',
})
skippedRows++
continue
}
const signature = `${date}|${belopp}|${text}`
const occurrence = (seenContent.get(signature) ?? 0) + 1
seenContent.set(signature, occurrence)
if (occurrence === 2) {
issues.push({
row: i + 1,
message: `Identiska rader i filen (${text} ${date}): båda importeras`,
severity: 'info',
})
}
rows.push({ transaktionsdatum: date, transaktionstext: text, belopp, raw_line: line })
}
// Integrity: the statement must sum. A mismatch means a truncated or
// hand-edited file: surfaced as an error so the route refuses the import.
// A file that HAS saldo markers but not both valid balances is equally
// suspect (cut off before "Utgående saldo", or a garbled amount): fail it
// rather than silently skipping the check. Only marker-less legacy files
// legitimately have no balances to check (sum_valid stays null).
let sumValid: boolean | null = null
if (openingSaldo !== null && closingSaldo !== null) {
const sum = rows.reduce((acc, row) => roundOre(acc + row.belopp), openingSaldo)
sumValid = Math.abs(sum - closingSaldo) < 0.005
if (!sumValid) {
issues.push({
row: 0,
message: `Ingående saldo plus transaktioner (${sum}) stämmer inte med utgående saldo (${closingSaldo})`,
severity: 'error',
})
}
} else if (sawSaldoMarker) {
sumValid = false
issues.push({
row: 0,
message:
'Utdraget saknar ett läsbart ingående eller utgående saldo. Filen kan vara ofullständig; ladda ner den på nytt från Skatteverket.',
severity: 'error',
})
}
const dates = rows.map((row) => row.transaktionsdatum).sort()
return {
variant,
company_name: companyName,
org_number: orgNumber,
rows,
date_from: dates[0] ?? null,
date_to: dates[dates.length - 1] ?? null,
opening_saldo: openingSaldo,
closing_saldo: closingSaldo,
sum_valid: sumValid,
issues,
stats: {
total_rows: totalRows,
parsed_rows: rows.length,
skipped_rows: skippedRows,
},
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Types for the skattekontoutdrag file importer.
*
* Deliberately separate from the bank-file importer: skattekonto rows are
* not bank transactions. They land in skattekonto_transactions (not
* transactions) and are booked through the skattekonto rules engine against
* 1630, so the parse result mirrors that table's vocabulary.
*/
export interface ParsedSkattekontoFileRow {
/** YYYY-MM-DD */
transaktionsdatum: string
transaktionstext: string
/** SKV sign convention: positive = credit on the tax account. */
belopp: number
raw_line: string
}
export interface SkattekontoFileParseIssue {
/** 1-based line number in the file */
row: number
message: string
severity: 'info' | 'warning' | 'error'
}
export interface SkattekontoFileParseResult {
variant: 'csv' | 'skv'
/** From the header row of the modern export; null on legacy files. */
company_name: string | null
/** Formatted NNNNNN-NNNN from the header row; null on legacy files. */
org_number: string | null
rows: ParsedSkattekontoFileRow[]
date_from: string | null
date_to: string | null
/** "Ingående saldo" marker row amount; null when the file has none. */
opening_saldo: number | null
/** "Utgående saldo" marker row amount; null when the file has none. */
closing_saldo: number | null
/**
* opening_saldo + sum(rows) === closing_saldo, checked when both markers
* exist. False means the file is truncated or hand-edited and must not be
* imported silently. Null when the file carries no saldo markers.
*/
sum_valid: boolean | null
issues: SkattekontoFileParseIssue[]
stats: {
total_rows: number
parsed_rows: number
skipped_rows: number
}
}
+2
View File
@@ -1051,6 +1051,8 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
processing_history: 'internal processing log; behandlingshistorik exports from audit_log',
provider_consents: 'consent tokens, not portable',
salary_payslip_deliveries: 'delivery log',
skattekonto_file_imports:
'import log for the skattekonto mirror below; the statement is re-downloadable from Skatteverket',
skattekonto_transactions: 'mirror of Skatteverket skattekonto, re-fetchable at source',
skatteverket_api_audit_log: 'integration audit log',
skatteverket_company_connections: 'integration connection state',
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
computeDedupKey,
contentSignature,
assignFileDedupKeys,
partitionFileRows,
type ExistingSkattekontoRow,
} from '../skattekonto-dedup'
beforeEach(() => {
vi.clearAllMocks()
})
describe('computeDedupKey', () => {
it('uses transaktionsidentitet when present', () => {
expect(
computeDedupKey({
transaktionsidentitet: 123456789,
transaktionsdatum: '2026-07-13',
beloppSkatteverket: -15710,
transaktionstext: 'Arbetsgivaravgift juni 2026',
}),
).toBe('id:123456789')
})
// GOLDEN KEY: this literal pins the hash-material contract
// `${datum}|${belopp}|${text}`. Every hash-keyed row in prod was written
// with it (originally from skattekonto-sync.ts, moved here). If this test
// fails, the change would duplicate every company's rows on next sync:
// fix the code, never the expected value.
it('produces the exact historical hash for id-less rows', () => {
expect(
computeDedupKey({
transaktionsidentitet: null,
transaktionsdatum: '2026-07-13',
beloppSkatteverket: -15710,
transaktionstext: 'Arbetsgivaravgift juni 2026',
}),
).toBe('h:7914fe867adca79f75ef43f276d45dfc22ac622a676a3a6e9ac163104ea3465b')
})
it('formats decimal amounts with JS number semantics', () => {
expect(
computeDedupKey({
transaktionsdatum: '2026-07-13',
beloppSkatteverket: -15710.5,
transaktionstext: 'Arbetsgivaravgift juni 2026',
}),
).toBe('h:5bf604ecb527622aab5ee1d764f832554613532844bc63394fe871b26f6ceb61')
})
it('is the hash of the content signature', () => {
const sig = contentSignature('2026-07-13', -15710, 'Arbetsgivaravgift juni 2026')
expect(sig).toBe('2026-07-13|-15710|Arbetsgivaravgift juni 2026')
})
})
describe('assignFileDedupKeys', () => {
const row = {
transaktionsdatum: '2026-07-13',
transaktionstext: 'Arbetsgivaravgift juni 2026',
belopp: -15710,
}
it('assigns the plain content hash to unique rows', () => {
const [entry] = assignFileDedupKeys([row])
expect(entry.dedupKey).toBe(
'h:7914fe867adca79f75ef43f276d45dfc22ac622a676a3a6e9ac163104ea3465b',
)
expect(entry.index).toBe(0)
})
it('suffixes later occurrences of identical rows deterministically', () => {
const [first, second] = assignFileDedupKeys([row, { ...row }])
expect(first.dedupKey).toBe(
'h:7914fe867adca79f75ef43f276d45dfc22ac622a676a3a6e9ac163104ea3465b',
)
expect(second.dedupKey).toBe(
'h:e464c2f7f8d9c4534e7c0dd140036eb9282a35c061e3346649723bcf9096f1d3',
)
// Re-running over the same content yields the same keys.
expect(assignFileDedupKeys([row, { ...row }]).map((e) => e.dedupKey)).toEqual([
first.dedupKey,
second.dedupKey,
])
})
})
describe('partitionFileRows', () => {
const fileRow = {
transaktionsdatum: '2026-07-13',
transaktionstext: 'Arbetsgivaravgift juni 2026',
belopp: -15710,
}
const existingBooked: ExistingSkattekontoRow = {
id: 'row-booked',
dedup_key: 'id:123456789',
status: 'booked',
transaktionsdatum: '2026-07-13',
transaktionstext: 'Arbetsgivaravgift juni 2026',
belopp_skatteverket: -15710,
}
const existingUpcoming: ExistingSkattekontoRow = {
...existingBooked,
id: 'row-upcoming',
dedup_key: 'h:7914fe867adca79f75ef43f276d45dfc22ac622a676a3a6e9ac163104ea3465b',
status: 'upcoming',
}
it('skips file rows whose content matches an existing booked row regardless of key form', () => {
const result = partitionFileRows(assignFileDedupKeys([fileRow]), [existingBooked])
expect(result.duplicates).toHaveLength(1)
expect(result.duplicates[0].existingId).toBe('row-booked')
expect(result.toInsert).toHaveLength(0)
expect(result.promotions).toHaveLength(0)
})
it('promotes an existing upcoming row instead of inserting', () => {
const result = partitionFileRows(assignFileDedupKeys([fileRow]), [existingUpcoming])
expect(result.promotions).toHaveLength(1)
expect(result.promotions[0].existingId).toBe('row-upcoming')
expect(result.toInsert).toHaveLength(0)
})
it('prefers a booked match over an upcoming match', () => {
const result = partitionFileRows(assignFileDedupKeys([fileRow]), [
existingUpcoming,
existingBooked,
])
expect(result.duplicates.map((d) => d.existingId)).toEqual(['row-booked'])
expect(result.promotions).toHaveLength(0)
})
it('consumes each existing row at most once (multiset semantics)', () => {
const result = partitionFileRows(assignFileDedupKeys([fileRow, { ...fileRow }]), [
existingBooked,
])
expect(result.duplicates).toHaveLength(1)
expect(result.toInsert).toHaveLength(1)
})
it('inserts rows with no content match', () => {
const other = { ...fileRow, belopp: -999 }
const result = partitionFileRows(assignFileDedupKeys([other]), [
existingBooked,
existingUpcoming,
])
expect(result.toInsert).toHaveLength(1)
expect(result.duplicates).toHaveLength(0)
expect(result.promotions).toHaveLength(0)
})
})
+167
View File
@@ -0,0 +1,167 @@
import crypto from 'crypto'
/**
* Dedup identity for skattekonto_transactions rows.
*
* Lives in core (not the skatteverket extension) because two producers write
* the table: the extension's API sync and the core skattekontoutdrag file
* importer. Both must compute byte-identical keys or every row would double
* on the next sync. Same pattern as SKATTEKONTO_ACCOUNT in
* manual-verifikat-prefill.ts: core owns the constant, the extension imports
* it back.
*
* Key forms:
* - `id:<transaktionsidentitet>` when Skatteverket's stable id is present
* (always on tidigare from the API, sometimes on kommande).
* - `h:sha256(transaktionsdatum|beloppSkatteverket|transaktionstext)` when
* it is not: kommande rows, and every file-imported row (statement exports
* carry no transaktionsidentitet).
*
* The amount is interpolated with JS number formatting. Both producers hold
* the amount as a number, so `500` stringifies as "500" on both sides; a
* parser must never feed a pre-formatted string in here.
*/
/**
* Compute the dedup key for a transaction.
*
* The point of this function is reproducibility: the same logical
* transaction must always produce the same dedup_key. The material format
* is a stored contract (every existing hash row in prod was written with
* it): never change it.
*/
export function computeDedupKey(tx: {
transaktionsidentitet?: number | null
transaktionsdatum: string
beloppSkatteverket: number
transaktionstext: string
}): string {
if (tx.transaktionsidentitet != null) {
return `id:${tx.transaktionsidentitet}`
}
return `h:${hashMaterial(contentSignature(tx.transaktionsdatum, tx.beloppSkatteverket, tx.transaktionstext))}`
}
/**
* The content identity of a row independent of which producer wrote it and
* which key form it got. Exactly the hash material of the `h:` key form, so
* `computeDedupKey` for an id-less row is `h:sha256(contentSignature(...))`.
* Used to pair file rows against `id:`-keyed API rows (import-time duplicate
* detection) and API rows against `h:`-keyed imported rows (sync-time
* takeover).
*/
export function contentSignature(
transaktionsdatum: string,
beloppSkatteverket: number,
transaktionstext: string,
): string {
return `${transaktionsdatum}|${beloppSkatteverket}|${transaktionstext}`
}
function hashMaterial(material: string): string {
return crypto.createHash('sha256').update(material).digest('hex')
}
/** The subset of a parsed statement row that dedup identity is built from. */
export interface SkattekontoFileRowIdentity {
transaktionsdatum: string
transaktionstext: string
/** SKV sign convention: positive = credit on the tax account. */
belopp: number
}
/**
* Assign dedup keys to parsed file rows.
*
* Identical rows within one file are legal (two equal payments on the same
* day). The first occurrence gets the plain content hash; later occurrences
* suffix the material with `|2`, `|3`, ... by occurrence order. This is
* deterministic per file content, so re-importing the same or an overlapping
* file resolves onto the same keys instead of inserting twice.
*/
export function assignFileDedupKeys(
rows: SkattekontoFileRowIdentity[],
): { row: SkattekontoFileRowIdentity; index: number; dedupKey: string }[] {
const seen = new Map<string, number>()
return rows.map((row, index) => {
const sig = contentSignature(row.transaktionsdatum, row.belopp, row.transaktionstext)
const occurrence = (seen.get(sig) ?? 0) + 1
seen.set(sig, occurrence)
const material = occurrence === 1 ? sig : `${sig}|${occurrence}`
return { row, index, dedupKey: `h:${hashMaterial(material)}` }
})
}
/** Existing table rows needed to partition an incoming file. */
export interface ExistingSkattekontoRow {
id: string
dedup_key: string
status: 'booked' | 'upcoming'
transaktionsdatum: string
transaktionstext: string
belopp_skatteverket: number
}
export interface SkattekontoFilePartition {
/** New rows to insert as booked file_import rows. */
toInsert: { row: SkattekontoFileRowIdentity; index: number; dedupKey: string }[]
/**
* Existing upcoming rows the statement proves have settled: flip their
* status to booked in place (keeps id, journal_entry_id, forfallodatum).
*/
promotions: { row: SkattekontoFileRowIdentity; index: number; existingId: string }[]
/** File rows already present as booked rows (either key form): skip. */
duplicates: { row: SkattekontoFileRowIdentity; index: number; existingId: string }[]
}
/**
* Partition parsed file rows against the rows already in the table.
*
* Matching is by content signature, not by dedup key, because the API writes
* `id:` keys for the same logical transactions a file hash-keys. Multiset
* semantics: each existing row is consumed at most once, so two identical
* file rows against one existing row yield one duplicate and one insert.
* Booked matches win over upcoming matches (a booked row IS this
* transaction; an upcoming row merely predicts it).
*/
export function partitionFileRows(
keyed: { row: SkattekontoFileRowIdentity; index: number; dedupKey: string }[],
existing: ExistingSkattekontoRow[],
): SkattekontoFilePartition {
const bookedBySig = new Map<string, ExistingSkattekontoRow[]>()
const upcomingBySig = new Map<string, ExistingSkattekontoRow[]>()
for (const row of existing) {
const sig = contentSignature(
row.transaktionsdatum,
row.belopp_skatteverket,
row.transaktionstext,
)
const bucket = row.status === 'booked' ? bookedBySig : upcomingBySig
const queue = bucket.get(sig)
if (queue) queue.push(row)
else bucket.set(sig, [row])
}
const result: SkattekontoFilePartition = { toInsert: [], promotions: [], duplicates: [] }
for (const entry of keyed) {
const sig = contentSignature(
entry.row.transaktionsdatum,
entry.row.belopp,
entry.row.transaktionstext,
)
const booked = bookedBySig.get(sig)
if (booked && booked.length > 0) {
const match = booked.shift() as ExistingSkattekontoRow
result.duplicates.push({ row: entry.row, index: entry.index, existingId: match.id })
continue
}
const upcoming = upcomingBySig.get(sig)
if (upcoming && upcoming.length > 0) {
const match = upcoming.shift() as ExistingSkattekontoRow
result.promotions.push({ row: entry.row, index: entry.index, existingId: match.id })
continue
}
result.toInsert.push(entry)
}
return result
}
+43
View File
@@ -6997,6 +6997,47 @@
"migration_description": "Nothing changes in your existing system.",
"bankfile_title": "Bank file",
"bankfile_description": "CSV, OFX or your bank's own formats: SEB, Swedbank, Nordea",
"skattekonto_title": "Tax account statement",
"skattekonto_description": "Account events from Skatteverket's skattekonto, booked against 1630",
"skattekonto_upload_title": "Upload tax account statement",
"skattekonto_upload_description": "Download the account events from Skatteverket's Skattekonto e-service and upload the file here.",
"skattekonto_analyzing": "Analyzing file…",
"skattekonto_drop_here": "Drag and drop the tax account statement here",
"skattekonto_tap_select": "Tap to choose a file",
"skattekonto_file_types": "CSV, TXT or SKV (max 10 MB)",
"skattekonto_error_title": "Could not read the file",
"skattekonto_duplicate_file_title": "File already imported",
"skattekonto_howto_title": "How to download the statement",
"skattekonto_howto_steps": "Log in at skatteverket.se → Mina sidor → Skattekonto → Kontohändelser → Ladda ner",
"skattekonto_howto_note": "Older .skv files from the previous e-service can also be uploaded.",
"skattekonto_preview_rows": "Events",
"skattekonto_preview_period": "Period",
"skattekonto_preview_closing_saldo": "Closing balance",
"skattekonto_preview_skipped": "{count, plural, one {1 row was skipped} other {# rows were skipped}}",
"skattekonto_preview_truncated": "Showing {shown} of {total} events.",
"skattekonto_org_mismatch_title": "The statement belongs to another company",
"skattekonto_org_mismatch_body": "The file is a statement for {companyName} ({orgNumber}), which does not match the active company's organisation number. Make sure you have the right company selected before importing.",
"skattekonto_org_mismatch_confirm": "I am sure this statement belongs to the active company",
"skattekonto_duplicates_note": "{count, plural, one {1 event already exists on the tax account and will be skipped.} other {# events already exist on the tax account and will be skipped.}}",
"skattekonto_issues_title": "{count, plural, one {1 row could not be fully read} other {# rows could not be fully read}}",
"skattekonto_issue_row": "Row {row}",
"skattekonto_col_date": "Date",
"skattekonto_col_text": "Event",
"skattekonto_col_amount": "Amount",
"skattekonto_chip_duplicate": "Already exists",
"skattekonto_chip_promotion": "Will update",
"skattekonto_back": "Back",
"skattekonto_import_button": "{count, plural, one {Import 1 event} other {Import # events}}",
"skattekonto_result_title": "Import complete",
"skattekonto_result_summary": "{imported, plural, one {1 event was imported} other {# events were imported}}, {duplicates, plural, one {1 already existed} other {# already existed}}.",
"skattekonto_result_promoted": "{count, plural, one {1 upcoming event was marked as settled.} other {# upcoming events were marked as settled.}}",
"skattekonto_result_errors": "{count, plural, one {1 row could not be imported.} other {# rows could not be imported.}}",
"skattekonto_result_open_skattekonto": "Open the tax account",
"skattekonto_result_book_rows": "Book events",
"skattekonto_result_new_import": "Import another file",
"skattekonto_detected_title": "This looks like a tax account statement",
"skattekonto_detected_body": "Tax account events are booked against 1630, not against a bank account.",
"skattekonto_detected_link": "Open the skattekonto import",
"csv_data_title": "CSV or Excel",
"csv_data_description": "Opening balances, customers, suppliers and articles",
"csv_chip_opening_balances": "Opening balances",
@@ -7249,6 +7290,8 @@
"skattekonto": {
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
"copy": "copy",
"import_statement_action": "Import statement",
"imported_not_connected_attn": "Showing imported account events. Connect Skatteverket for automatic sync and balance.",
"attn_reconnect_action": "Reconnect",
"attn_shortfall": "The charge on {date} is {charge}. The balance is {missing} short.",
"attn_show_payment": "Show payment details",
+43
View File
@@ -6997,6 +6997,47 @@
"migration_description": "Inget ändras i ditt befintliga system.",
"bankfile_title": "Bankfil",
"bankfile_description": "CSV, OFX eller bankens egna format: SEB, Swedbank, Nordea",
"skattekonto_title": "Skattekontoutdrag",
"skattekonto_description": "Kontohändelser från Skatteverkets skattekonto, bokförs mot 1630",
"skattekonto_upload_title": "Ladda upp skattekontoutdrag",
"skattekonto_upload_description": "Ladda ner kontohändelserna från Skatteverkets e-tjänst Skattekonto och ladda upp filen här.",
"skattekonto_analyzing": "Analyserar fil…",
"skattekonto_drop_here": "Dra och släpp skattekontoutdraget här",
"skattekonto_tap_select": "Tryck för att välja fil",
"skattekonto_file_types": "CSV, TXT eller SKV (max 10 MB)",
"skattekonto_error_title": "Kunde inte läsa filen",
"skattekonto_duplicate_file_title": "Filen är redan importerad",
"skattekonto_howto_title": "Så hämtar du kontoutdraget",
"skattekonto_howto_steps": "Logga in på skatteverket.se → Mina sidor → Skattekonto → Kontohändelser → Ladda ner",
"skattekonto_howto_note": "Även äldre .skv-filer från den tidigare e-tjänsten kan laddas upp.",
"skattekonto_preview_rows": "Händelser",
"skattekonto_preview_period": "Period",
"skattekonto_preview_closing_saldo": "Utgående saldo",
"skattekonto_preview_skipped": "{count, plural, one {En rad hoppades över} other {# rader hoppades över}}",
"skattekonto_preview_truncated": "Visar {shown} av {total} händelser.",
"skattekonto_org_mismatch_title": "Utdraget gäller ett annat företag",
"skattekonto_org_mismatch_body": "Filen är ett kontoutdrag för {companyName} ({orgNumber}), vilket inte matchar det aktiva företagets organisationsnummer. Kontrollera att du valt rätt företag innan du importerar.",
"skattekonto_org_mismatch_confirm": "Jag är säker på att utdraget hör till det aktiva företaget",
"skattekonto_duplicates_note": "{count, plural, one {En händelse finns redan på skattekontot och hoppas över.} other {# händelser finns redan på skattekontot och hoppas över.}}",
"skattekonto_issues_title": "{count, plural, one {En rad kunde inte läsas fullt ut} other {# rader kunde inte läsas fullt ut}}",
"skattekonto_issue_row": "Rad {row}",
"skattekonto_col_date": "Datum",
"skattekonto_col_text": "Händelse",
"skattekonto_col_amount": "Belopp",
"skattekonto_chip_duplicate": "Finns redan",
"skattekonto_chip_promotion": "Uppdateras",
"skattekonto_back": "Tillbaka",
"skattekonto_import_button": "{count, plural, one {Importera en händelse} other {Importera # händelser}}",
"skattekonto_result_title": "Import genomförd",
"skattekonto_result_summary": "{imported, plural, one {En händelse importerades} other {# händelser importerades}}, {duplicates, plural, one {en fanns redan} other {# fanns redan}}.",
"skattekonto_result_promoted": "{count, plural, one {En kommande händelse markerades som genomförd.} other {# kommande händelser markerades som genomförda.}}",
"skattekonto_result_errors": "{count, plural, one {En rad kunde inte importeras.} other {# rader kunde inte importeras.}}",
"skattekonto_result_open_skattekonto": "Öppna skattekontot",
"skattekonto_result_book_rows": "Bokför händelser",
"skattekonto_result_new_import": "Importera en till fil",
"skattekonto_detected_title": "Det här ser ut som ett skattekontoutdrag",
"skattekonto_detected_body": "Skattekontots händelser bokförs mot 1630, inte mot ett bankkonto.",
"skattekonto_detected_link": "Öppna skattekonto-importen",
"csv_data_title": "CSV eller Excel",
"csv_data_description": "Ingående balanser, kunder, leverantörer och artiklar",
"csv_chip_opening_balances": "Ingående balanser",
@@ -7249,6 +7290,8 @@
"skattekonto": {
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
"copy": "kopiera",
"import_statement_action": "Importera kontoutdrag",
"imported_not_connected_attn": "Visar importerade kontohändelser. Anslut Skatteverket för automatisk synk och saldo.",
"attn_reconnect_action": "Anslut igen",
"attn_shortfall": "Dragningen {date} är {charge}. Saldot saknar {missing}.",
"attn_show_payment": "Visa betalningsuppgifter",
@@ -0,0 +1,92 @@
-- Skattekontoutdrag file import support
--
-- Users can download their kontohändelser from Skatteverket's skattekonto
-- e-service (CSV; legacy exports were semicolon .skv text files) and import
-- them manually. Imported rows land in skattekonto_transactions and inherit
-- the existing booking rules, matching and UI. This serves companies without
-- the paid API connection and history beyond the API's ~555-day lookback.
--
-- Two parts:
-- 1. skattekonto_file_imports: one row per uploaded file, keyed on
-- (company_id, file_hash) for whole-file duplicate rejection
-- (company-scoped from day one; see 20260707130000 for why the
-- user-scoped variant on bank_file_imports had to be fixed later).
-- 2. Provenance on skattekonto_transactions: source distinguishes
-- API-synced rows from file-imported ones (the sync takeover logic
-- needs it), file_import_id links back to the uploaded file.
CREATE TABLE public.skattekonto_file_imports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID NOT NULL REFERENCES public.companies ON DELETE CASCADE,
-- Importer provenance. Nullable + SET NULL so the import record (and the
-- file-hash dedup it provides) survives the importing user's deletion;
-- the INSERT policy below binds it to auth.uid() so a member cannot
-- attribute an import to a colleague.
user_id UUID REFERENCES auth.users ON DELETE SET NULL,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
file_variant TEXT NOT NULL CHECK (file_variant IN ('csv', 'skv')),
row_count INTEGER NOT NULL DEFAULT 0,
imported_count INTEGER NOT NULL DEFAULT 0,
duplicate_count INTEGER NOT NULL DEFAULT 0,
promoted_count INTEGER NOT NULL DEFAULT 0,
date_from DATE,
date_to DATE,
-- "Utgående saldo" from the statement's final marker row. Not yet used
-- for the balance snapshot (that write stays API-owned for now) but
-- stored so an import-history or avstämning view can surface it.
closing_saldo NUMERIC(14, 2),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (company_id, file_hash)
);
CREATE INDEX skattekonto_file_imports_company_created_idx
ON public.skattekonto_file_imports (company_id, created_at DESC);
ALTER TABLE public.skattekonto_file_imports ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see skattekonto file imports for their companies"
ON public.skattekonto_file_imports FOR SELECT
USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "Users insert skattekonto file imports for their companies"
ON public.skattekonto_file_imports FOR INSERT
WITH CHECK (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
);
CREATE POLICY "Users update skattekonto file imports for their companies"
ON public.skattekonto_file_imports FOR UPDATE
USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "Users delete skattekonto file imports for their companies"
ON public.skattekonto_file_imports FOR DELETE
USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER update_skattekonto_file_imports_updated_at
BEFORE UPDATE ON public.skattekonto_file_imports
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Provenance columns. Existing rows were all written by the API sync, so
-- the 'api' default backfills them correctly.
ALTER TABLE public.skattekonto_transactions
ADD COLUMN source TEXT NOT NULL DEFAULT 'api'
CHECK (source IN ('api', 'file_import')),
ADD COLUMN file_import_id UUID
REFERENCES public.skattekonto_file_imports ON DELETE SET NULL;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,14 @@
-- Align skattekonto_rules with the 2012 -> 2013 EF account decision.
--
-- 20260810120000 established that account 2012 is not standard BAS (bas.se
-- BAS 2026 v2 has no 2012; owner taxes paid by an enskild firma are an eget
-- uttag on 2013 "Övriga egna uttag") and migrated booking_template_library
-- accordingly. The skattekonto_rules seed from 20260519100000 was missed:
-- its "Preliminär skatt" rule still books EF F-skatt against 2012. That rule
-- fires for every EF skattekonto booking (API-synced and file-imported), so
-- bring it onto 2013 too. Covers the NULL-company system row plus any
-- per-company clones.
UPDATE public.skattekonto_rules
SET counter_account_ef = '2013'
WHERE counter_account_ef = '2012';
+5 -1
View File
@@ -97,8 +97,12 @@ const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
* at runtime (frozen rows get safe fields only; unfrozen rows get optional
* parent/legacy links). Writing the shapes as inline literals would need one
* variant per key combination; the row shapes are covered by ingest.test.ts.
*
* 2026-08-17 +1: lib/import/skattekonto-file/import-service.ts inserts parsed
* statement rows via a mapped batch (same shape as every other file importer);
* the row shape is covered by the execute route tests and the pg-real suite.
*/
const UNRESOLVED_CEILING = 378
const UNRESOLVED_CEILING = 379
/**
* Floor on statically resolved column references. Guards the guard: if a change
+24
View File
@@ -24,10 +24,34 @@ export interface StoredSkattekontoTransaction {
belopp_kronofogden: number | null
status: 'booked' | 'upcoming'
journal_entry_id: string | null
source: 'api' | 'file_import'
file_import_id: string | null
imported_at: string
updated_at: string
}
/** Row shape for the `skattekonto_file_imports` tracking table (DB → app). */
export interface SkattekontoFileImportRecord {
id: string
company_id: string
/** Importing user; null after that user's account is deleted. */
user_id: string | null
filename: string
file_hash: string
file_variant: 'csv' | 'skv'
row_count: number
imported_count: number
duplicate_count: number
promoted_count: number
date_from: string | null
date_to: string | null
closing_saldo: number | null
status: 'pending' | 'processing' | 'completed' | 'failed'
error_message: string | null
created_at: string
updated_at: string
}
/**
* Single best candidate verifikat for an unmatched SKV row. Attached by
* the `/skattekonto/transaktioner` endpoint when exactly one strong match