Files
accounted/components/import/SkattekontoFilePreviewStep.tsx
T
4921d1da5e feat(import): import skattekontoutdrag files into the skattekonto pipeline (#1637)
* feat(import): import skattekontoutdrag files into the skattekonto pipeline

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:18:32 +02:00

209 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, ArrowRight, AlertTriangle, Calendar, FileText, Scale } from 'lucide-react'
import { formatCurrency, cn } from '@/lib/utils'
import type { SkattekontoFileParseResult } from '@/lib/import/skattekonto-file/types'
interface SkattekontoFilePreviewStepProps {
parseResult: SkattekontoFileParseResult
duplicateIndexes: number[]
promotionIndexes: number[]
orgNumberMismatch: boolean
isLoading: boolean
onExecute: () => void
onBack: () => void
}
const PREVIEW_ROW_LIMIT = 100
export default function SkattekontoFilePreviewStep({
parseResult,
duplicateIndexes,
promotionIndexes,
orgNumberMismatch,
isLoading,
onExecute,
onBack,
}: SkattekontoFilePreviewStepProps) {
const t = useTranslations('import')
const [mismatchConfirmed, setMismatchConfirmed] = useState(false)
const { rows, stats, issues, date_from, date_to, closing_saldo } = parseResult
const duplicateSet = new Set(duplicateIndexes)
const promotionSet = new Set(promotionIndexes)
const newCount = rows.length - duplicateIndexes.length
const warnings = issues.filter((i) => i.severity !== 'error')
const importBlocked = orgNumberMismatch && !mismatchConfirmed
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">{t('skattekonto_preview_rows')}</span>
</div>
<p className="text-2xl font-display tabular-nums">{stats.parsed_rows}</p>
{stats.skipped_rows > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{t('skattekonto_preview_skipped', { count: stats.skipped_rows })}
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">{t('skattekonto_preview_period')}</span>
</div>
<p className="text-sm font-medium tabular-nums">
{date_from || '-'} – {date_to || '-'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Scale className="h-4 w-4" />
<span className="text-sm">{t('skattekonto_preview_closing_saldo')}</span>
</div>
<p className="text-lg font-display tabular-nums">
{closing_saldo !== null ? formatCurrency(closing_saldo) : '-'}
</p>
</CardContent>
</Card>
</div>
{orgNumberMismatch && (
<Card className="border-destructive/40">
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
{t('skattekonto_org_mismatch_title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t('skattekonto_org_mismatch_body', {
orgNumber: parseResult.org_number ?? '?',
companyName: parseResult.company_name ?? '?',
})}
</p>
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded-sm border-border"
checked={mismatchConfirmed}
onChange={(e) => setMismatchConfirmed(e.target.checked)}
/>
<span>{t('skattekonto_org_mismatch_confirm')}</span>
</label>
</CardContent>
</Card>
)}
{duplicateIndexes.length > 0 && (
<p className="text-sm text-muted-foreground">
{t('skattekonto_duplicates_note', { count: duplicateIndexes.length })}
</p>
)}
{warnings.length > 0 && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-warning">
<AlertTriangle className="h-4 w-4" />
{t('skattekonto_issues_title', { count: warnings.length })}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-1">
{warnings.slice(0, 5).map((issue, i) => (
<p key={i}>
{issue.row > 0 ? `${t('skattekonto_issue_row', { row: issue.row })}: ` : ''}
{issue.message}
</p>
))}
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('skattekonto_col_date')}</TableHead>
<TableHead>{t('skattekonto_col_text')}</TableHead>
<TableHead className="text-right">{t('skattekonto_col_amount')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, PREVIEW_ROW_LIMIT).map((row, index) => {
const isDuplicate = duplicateSet.has(index)
return (
<TableRow key={index} className={cn(isDuplicate && 'opacity-60')}>
<TableCell className="tabular-nums">{row.transaktionsdatum}</TableCell>
<TableCell>{row.transaktionstext}</TableCell>
<TableCell
className={cn(
'text-right tabular-nums',
row.belopp < 0 ? 'text-destructive' : undefined,
)}
>
{formatCurrency(row.belopp)}
</TableCell>
<TableCell className="text-right">
{isDuplicate ? (
<Badge variant="secondary">{t('skattekonto_chip_duplicate')}</Badge>
) : promotionSet.has(index) ? (
<Badge variant="outline">{t('skattekonto_chip_promotion')}</Badge>
) : null}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{rows.length > PREVIEW_ROW_LIMIT && (
<p className="px-4 py-3 text-xs text-muted-foreground">
{t('skattekonto_preview_truncated', {
shown: PREVIEW_ROW_LIMIT,
total: rows.length,
})}
</p>
)}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('skattekonto_back')}
</Button>
<Button onClick={onExecute} disabled={isLoading || importBlocked || newCount === 0}>
{t('skattekonto_import_button', { count: newCount })}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}