* 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>
209 lines
7.6 KiB
TypeScript
209 lines
7.6 KiB
TypeScript
'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>
|
||
)
|
||
}
|