diff --git a/DECISIONS.md b/DECISIONS.md index 179363a3..7a8ee9b7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1031,3 +1031,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 48432d01..b5cd0977 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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(null) const [bankErrorTitle, setBankErrorTitle] = useState(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(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 = { + upload: 'Ladda upp', + preview: 'Förhandsgranskning', + result: 'Resultat', +} + +function SkattekontoImportWizard() { + const { toast } = useToast() + const t = useTranslations('import') + + const [step, setStep] = useState('upload') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [errorTitle, setErrorTitle] = useState(null) + + const [parseResult, setParseResult] = useState(null) + const [fileHash, setFileHash] = useState('') + const [filename, setFilename] = useState('') + const [duplicateIndexes, setDuplicateIndexes] = useState([]) + const [promotionIndexes, setPromotionIndexes] = useState([]) + const [orgNumberMismatch, setOrgNumberMismatch] = useState(false) + const [importResult, setImportResult] = useState(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 ( +
+ + +
+
+ + Steg {currentStepIndex + 1}/{SKATTEKONTO_STEPS.length}:{' '} + {SKATTEKONTO_STEP_LABELS[step]} + + {SKATTEKONTO_STEPS.map((s, i) => ( + + {SKATTEKONTO_STEP_LABELS[s]} + + ))} +
+ +
+
+
+ + {step === 'upload' && ( + + )} + + {step === 'preview' && parseResult && ( + + )} + + {step === 'preview' && error && ( +

{error}

+ )} + + {step === 'result' && importResult && ( + + )} +
+ ) +} + // ============================================================ // 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')} /> + setMode('skattekonto')} + /> } + {mode === 'skattekonto' && } {mode === 'sie' && } {mode === 'underlag' && } {mode === 'csv_data' && } diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 4afffccc..fc632c60 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -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() { ) - if (notConnected) { + const hasLocalRows = + tx !== null && tx.booked.length + tx.overdue.length + tx.upcoming.length > 0 + + if (notConnected && !hasLocalRows) { return (
@@ -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' }} />
@@ -413,22 +424,47 @@ export default function SkattekontoPage() { title="Skattekonto" help={helpNode} action={ - // The span carries the tooltip: `title` is suppressed on disabled elements. - + notConnected ? ( - + ) : ( + // The span carries the tooltip: `title` is suppressed on disabled elements. + + + + ) } /> - {/* 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 && ( + + {t('imported_not_connected_attn')} + + )} + + {/* 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 && (
{loading && !data ? (
@@ -545,6 +581,7 @@ export default function SkattekontoPage() { )}
+ )} {/* One dry table with band rows (concept): Kommande, Förfallna, Genomförda */} ({ + 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) + }) +}) diff --git a/app/api/import/bank-file/parse/route.ts b/app/api/import/bank-file/parse/route.ts index 3363cf3c..ec65fab4 100644 --- a/app/api/import/bank-file/parse/route.ts +++ b/app/api/import/bank-file/parse/route.ts @@ -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) diff --git a/app/api/import/skattekonto-file/execute/__tests__/route.test.ts b/app/api/import/skattekonto-file/execute/__tests__/route.test.ts new file mode 100644 index 00000000..81b12790 --- /dev/null +++ b/app/api/import/skattekonto-file/execute/__tests__/route.test.ts @@ -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 = {}) { + 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> + 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') + }) +}) diff --git a/app/api/import/skattekonto-file/execute/route.ts b/app/api/import/skattekonto-file/execute/route.ts new file mode 100644 index 00000000..8a08fe6d --- /dev/null +++ b/app/api/import/skattekonto-file/execute/route.ts @@ -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 }, +) diff --git a/app/api/import/skattekonto-file/parse/__tests__/route.test.ts b/app/api/import/skattekonto-file/parse/__tests__/route.test.ts new file mode 100644 index 00000000..5a796cd8 --- /dev/null +++ b/app/api/import/skattekonto-file/parse/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/import/skattekonto-file/parse/route.ts b/app/api/import/skattekonto-file/parse/route.ts new file mode 100644 index 00000000..8d8b3fac --- /dev/null +++ b/app/api/import/skattekonto-file/parse/route.ts @@ -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' }, + }) + } + }, +) diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx index 154eb96c..4208224c 100644 --- a/components/import/BankFileUploadStep.tsx +++ b/components/import/BankFileUploadStep.tsx @@ -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({ )} + {/* Skattekonto redirect: not an error, a pointer to the right flow */} + {skattekontoDetected && ( +
+ +
+

{t('skattekonto_detected_title')}

+

+ {t('skattekonto_detected_body')}{' '} + + {t('skattekonto_detected_link')} + +

+
+
+ )} + {/* Error display */} {error && (
diff --git a/components/import/SkattekontoFilePreviewStep.tsx b/components/import/SkattekontoFilePreviewStep.tsx new file mode 100644 index 00000000..ad014af4 --- /dev/null +++ b/components/import/SkattekontoFilePreviewStep.tsx @@ -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 ( +
+
+ + +
+ + {t('skattekonto_preview_rows')} +
+

{stats.parsed_rows}

+ {stats.skipped_rows > 0 && ( +

+ {t('skattekonto_preview_skipped', { count: stats.skipped_rows })} +

+ )} +
+
+ + + +
+ + {t('skattekonto_preview_period')} +
+

+ {date_from || '-'} – {date_to || '-'} +

+
+
+ + + +
+ + {t('skattekonto_preview_closing_saldo')} +
+

+ {closing_saldo !== null ? formatCurrency(closing_saldo) : '-'} +

+
+
+
+ + {orgNumberMismatch && ( + + + + + {t('skattekonto_org_mismatch_title')} + + + +

+ {t('skattekonto_org_mismatch_body', { + orgNumber: parseResult.org_number ?? '?', + companyName: parseResult.company_name ?? '?', + })} +

+ +
+
+ )} + + {duplicateIndexes.length > 0 && ( +

+ {t('skattekonto_duplicates_note', { count: duplicateIndexes.length })} +

+ )} + + {warnings.length > 0 && ( + + + + + {t('skattekonto_issues_title', { count: warnings.length })} + + + + {warnings.slice(0, 5).map((issue, i) => ( +

+ {issue.row > 0 ? `${t('skattekonto_issue_row', { row: issue.row })}: ` : ''} + {issue.message} +

+ ))} +
+
+ )} + + + + + + + {t('skattekonto_col_date')} + {t('skattekonto_col_text')} + {t('skattekonto_col_amount')} + + + + + {rows.slice(0, PREVIEW_ROW_LIMIT).map((row, index) => { + const isDuplicate = duplicateSet.has(index) + return ( + + {row.transaktionsdatum} + {row.transaktionstext} + + {formatCurrency(row.belopp)} + + + {isDuplicate ? ( + {t('skattekonto_chip_duplicate')} + ) : promotionSet.has(index) ? ( + {t('skattekonto_chip_promotion')} + ) : null} + + + ) + })} + +
+ {rows.length > PREVIEW_ROW_LIMIT && ( +

+ {t('skattekonto_preview_truncated', { + shown: PREVIEW_ROW_LIMIT, + total: rows.length, + })} +

+ )} +
+
+ +
+ + +
+
+ ) +} diff --git a/components/import/SkattekontoFileResultStep.tsx b/components/import/SkattekontoFileResultStep.tsx new file mode 100644 index 00000000..6ce3622e --- /dev/null +++ b/components/import/SkattekontoFileResultStep.tsx @@ -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 ( +
+ + +
+ +
+

{t('skattekonto_result_title')}

+

+ {t('skattekonto_result_summary', { + imported: result.imported, + duplicates: result.duplicates, + })} + {result.promoted > 0 && + ` ${t('skattekonto_result_promoted', { count: result.promoted })}`} +

+ {result.errors > 0 && ( +

+ {t('skattekonto_result_errors', { count: result.errors })} +

+ )} +
+
+
+
+ +
+ + + +
+
+ ) +} diff --git a/components/import/SkattekontoFileUploadStep.tsx b/components/import/SkattekontoFileUploadStep.tsx new file mode 100644 index 00000000..8a883227 --- /dev/null +++ b/components/import/SkattekontoFileUploadStep.tsx @@ -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 ( +
+ + + + + {t('skattekonto_upload_title')} + + {t('skattekonto_upload_description')} + + +
{ + 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() + } + }} + > + acceptFile(e.target.files?.[0])} + disabled={isLoading} + /> + + {isLoading ? ( +
+ +

{t('skattekonto_analyzing')}

+ +
+ ) : ( +
+ +
+

{t('skattekonto_drop_here')}

+

{t('skattekonto_tap_select')}

+

{t('skattekonto_file_types')}

+
+
+ )} +
+ + {error && ( +
+ +
+

+ {errorTitle || t('skattekonto_error_title')} +

+

{error}

+
+
+ )} +
+
+ + + + + + {t('skattekonto_howto_title')} + + + +
+

Skatteverket

+

{t('skattekonto_howto_steps')}

+
+

{t('skattekonto_howto_note')}

+
+
+
+ ) +} diff --git a/extensions/general/skatteverket/__tests__/skattekonto-buckets.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-buckets.test.ts index 0db6e426..565123c7 100644 --- a/extensions/general/skatteverket/__tests__/skattekonto-buckets.test.ts +++ b/extensions/general/skatteverket/__tests__/skattekonto-buckets.test.ts @@ -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, diff --git a/extensions/general/skatteverket/__tests__/skattekonto-file-imports.pg.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-file-imports.pg.test.ts new file mode 100644 index 00000000..1bd65035 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/skattekonto-file-imports.pg.test.ts @@ -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 { + 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') + }) +}) diff --git a/extensions/general/skatteverket/__tests__/skattekonto-sync-takeover.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-sync-takeover.test.ts new file mode 100644 index 00000000..914417c3 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/skattekonto-sync-takeover.test.ts @@ -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> + 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) + }) +}) diff --git a/extensions/general/skatteverket/lib/skattekonto-sync.ts b/extensions/general/skatteverket/lib/skattekonto-sync.ts index 25b05645..c08ee302 100644 --- a/extensions/general/skatteverket/lib/skattekonto-sync.ts +++ b/extensions/general/skatteverket/lib/skattekonto-sync.ts @@ -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 { +// 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 { +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() + 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, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index cd8ba195..f99f5e3b 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), +}) diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index a6ebbcd7..adf82156 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1686,6 +1686,65 @@ const BANK_FILE: Record = { 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 = { + 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 = { @@ -3584,6 +3643,7 @@ const REGISTRY: Record = { ...TAX_DECL, ...SIE_IMPORT, ...BANK_FILE, + ...SKATTEKONTO_FILE, ...OPENING_BALANCE_IMPORT, ...REGISTER_IMPORT, ...PROVIDER_MIGRATION, diff --git a/lib/import/skattekonto-file/__tests__/parser.test.ts b/lib/import/skattekonto-file/__tests__/parser.test.ts new file mode 100644 index 00000000..271c20c3 --- /dev/null +++ b/lib/import/skattekonto-file/__tests__/parser.test.ts @@ -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) + }) +}) diff --git a/lib/import/skattekonto-file/import-service.ts b/lib/import/skattekonto-file/import-service.ts new file mode 100644 index 00000000..21a9c826 --- /dev/null +++ b/lib/import/skattekonto-file/import-service.ts @@ -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 { + return fetchAllRows(({ 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 { + 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 +} diff --git a/lib/import/skattekonto-file/parser.ts b/lib/import/skattekonto-file/parser.ts new file mode 100644 index 00000000..6d68885e --- /dev/null +++ b/lib/import/skattekonto-file/parser.ts @@ -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() + + 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, + }, + } +} diff --git a/lib/import/skattekonto-file/types.ts b/lib/import/skattekonto-file/types.ts new file mode 100644 index 00000000..8ecc41ab --- /dev/null +++ b/lib/import/skattekonto-file/types.ts @@ -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 + } +} diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 844f7fba..5c1473cc 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -1051,6 +1051,8 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = { 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', diff --git a/lib/skatteverket/__tests__/skattekonto-dedup.test.ts b/lib/skatteverket/__tests__/skattekonto-dedup.test.ts new file mode 100644 index 00000000..0d40b106 --- /dev/null +++ b/lib/skatteverket/__tests__/skattekonto-dedup.test.ts @@ -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) + }) +}) diff --git a/lib/skatteverket/skattekonto-dedup.ts b/lib/skatteverket/skattekonto-dedup.ts new file mode 100644 index 00000000..8e2d57d8 --- /dev/null +++ b/lib/skatteverket/skattekonto-dedup.ts @@ -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:` 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() + 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() + const upcomingBySig = new Map() + 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 +} diff --git a/messages/en.json b/messages/en.json index 2d449517..c96f74a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index c634e62e..28af91c0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/supabase/migrations/20260817120000_skattekonto_file_imports.sql b/supabase/migrations/20260817120000_skattekonto_file_imports.sql new file mode 100644 index 00000000..2c5745b6 --- /dev/null +++ b/supabase/migrations/20260817120000_skattekonto_file_imports.sql @@ -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'; diff --git a/supabase/migrations/20260817120100_skattekonto_rules_f_skatt_ef_2013.sql b/supabase/migrations/20260817120100_skattekonto_rules_f_skatt_ef_2013.sql new file mode 100644 index 00000000..312d3d61 --- /dev/null +++ b/supabase/migrations/20260817120100_skattekonto_rules_f_skatt_ef_2013.sql @@ -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'; diff --git a/tests/schema/no-phantom-columns.test.ts b/tests/schema/no-phantom-columns.test.ts index 3261af5b..cc9f5364 100644 --- a/tests/schema/no-phantom-columns.test.ts +++ b/tests/schema/no-phantom-columns.test.ts @@ -97,8 +97,12 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} * 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 diff --git a/types/skatteverket.ts b/types/skatteverket.ts index 7f48a3e8..7bd7adb6 100644 --- a/types/skatteverket.ts +++ b/types/skatteverket.ts @@ -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