feat(vat): add per-account declaration treatments (#1588)

Closes #1457
This commit is contained in:
Mattsson
2026-08-13 17:03:35 +02:00
committed by GitHub
parent 7881f757a9
commit d02fd82191
32 changed files with 1196 additions and 253 deletions
@@ -0,0 +1,52 @@
'use client'
import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
vatTreatmentsForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
interface AccountVatTreatmentSelectProps {
value: AccountVatTreatment | 'none'
onValueChange: (value: AccountVatTreatment | 'none') => void
accountClass: number | null
}
export function AccountVatTreatmentSelect({
value,
onValueChange,
accountClass,
}: AccountVatTreatmentSelectProps) {
const t = useTranslations('chart_of_accounts')
const isRelevant = accountClass === 3 ||
(accountClass !== null && accountClass >= 4 && accountClass <= 6)
const treatments = accountClass === null ? [] : vatTreatmentsForAccountClass(accountClass)
return (
<div className="space-y-2">
<Label>{t('vat_treatment_label')}</Label>
<Select
value={value}
onValueChange={(next) => onValueChange(next as AccountVatTreatment | 'none')}
disabled={!isRelevant}
>
<SelectTrigger aria-label={t('vat_treatment_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
{treatments.map((treatment) => (
<SelectItem key={treatment} value={treatment}>
{t(`vat_treatment_${treatment}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{isRelevant ? t('vat_treatment_help') : t('vat_treatment_not_applicable')}
</p>
</div>
)
}
+21 -6
View File
@@ -19,6 +19,11 @@ import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import type { BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import {
defaultRateForVatTreatment,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
/**
* The create path hands back the full row the API inserted. The reactivate
@@ -49,6 +54,7 @@ export function AddAccountDialog({
// "Standard moms": the moms-sats a booking line defaults to when this konto is
// picked. 'none' = no default. SelectItem values are stringified decimals.
const [defaultVatRate, setDefaultVatRate] = useState('none')
const [defaultVatTreatment, setDefaultVatTreatment] = useState<AccountVatTreatment | 'none'>('none')
const [sruCode, setSruCode] = useState('')
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
const [isSaving, setIsSaving] = useState(false)
@@ -102,6 +108,7 @@ export function AddAccountDialog({
normal_balance: normalBalance,
description: description || null,
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment,
sru_code: sruCode || null,
}),
})
@@ -127,6 +134,7 @@ export function AddAccountDialog({
setAccountName('')
setDescription('')
setDefaultVatRate('none')
setDefaultVatTreatment('none')
setSruCode('')
onCreated(createdAccount)
onOpenChange(false)
@@ -162,6 +170,7 @@ export function AddAccountDialog({
setAccountName('')
setDescription('')
setDefaultVatRate('none')
setDefaultVatTreatment('none')
setSruCode('')
onCreated({ account_number: accountNumber })
onOpenChange(false)
@@ -260,6 +269,18 @@ export function AddAccountDialog({
/>
</div>
<AccountVatTreatmentSelect
value={defaultVatTreatment}
accountClass={derived ? Number(accountNumber.charAt(0)) : null}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
const rate = defaultRateForVatTreatment(treatment, Number(accountNumber.charAt(0)))
setDefaultVatRate(rate === null ? 'none' : String(rate))
}
}}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard moms <span className="text-muted-foreground">(valfritt)</span></Label>
@@ -275,12 +296,6 @@ export function AddAccountDialog({
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{accountNumber.length === 4 && accountNumber.startsWith('3') && (
<p className="text-xs text-muted-foreground">
På intäktskonton avgör satsen också om kontot räknas som
momspliktig försäljning i ruta 05 i momsdeklarationen.
</p>
)}
</div>
<div className="space-y-2">
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
+21 -6
View File
@@ -32,6 +32,11 @@ import {
type DimensionRuleType,
} from '@/components/dimensions/types'
import type { BASAccount } from '@/types'
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import {
defaultRateForVatTreatment,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
interface EditAccountDialogProps {
open: boolean
@@ -65,6 +70,9 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
const [defaultVatRate, setDefaultVatRate] = useState(
account.default_vat_rate != null ? String(account.default_vat_rate) : 'none',
)
const [defaultVatTreatment, setDefaultVatTreatment] = useState<AccountVatTreatment | 'none'>(
account.default_vat_treatment ?? 'none',
)
const [sruCode, setSruCode] = useState(account.sru_code || '')
const [isActive, setIsActive] = useState(account.is_active)
const [isSaving, setIsSaving] = useState(false)
@@ -256,6 +264,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
account_name: accountName,
description: description || null,
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment,
sru_code: sruCode || null,
is_active: isActive,
}),
@@ -313,6 +322,18 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
/>
</div>
<AccountVatTreatmentSelect
value={defaultVatTreatment}
accountClass={account.account_class}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
const rate = defaultRateForVatTreatment(treatment, account.account_class)
setDefaultVatRate(rate === null ? 'none' : String(rate))
}
}}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard moms</Label>
@@ -328,12 +349,6 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{account.account_class === 3 && (
<p className="text-xs text-muted-foreground">
På intäktskonton avgör satsen också om kontot räknas som
momspliktig försäljning i ruta 05 i momsdeklarationen.
</p>
)}
</div>
<div className="space-y-2">
<Label>SRU-kod</Label>
+105 -5
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -31,16 +32,26 @@ import {
import type { AccountMapping } from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import {
defaultRateForVatTreatment,
vatTreatmentsForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
interface AccountMappingStepProps {
mappings: AccountMapping[]
basAccounts: BASAccount[]
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange?: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => void
onContinue: () => void
onBack: () => void
}
type FilterType = 'all' | 'unmapped' | 'low_confidence' | 'manual'
type FilterType = 'all' | 'unmapped' | 'vat_review' | 'low_confidence' | 'manual'
const PAGE_SIZE = 50
@@ -48,14 +59,17 @@ export default function AccountMappingStep({
mappings,
basAccounts,
onMappingChange,
onVatTreatmentChange,
onContinue,
onBack,
}: AccountMappingStepProps) {
const t = useTranslations('chart_of_accounts')
const [searchTerm, setSearchTerm] = useState('')
// Default to showing unmapped accounts first (most actionable)
const [filter, setFilter] = useState<FilterType>(() => {
const hasUnmapped = mappings.some((m) => !m.targetAccount)
return hasUnmapped ? 'unmapped' : 'all'
const hasVatReview = mappings.some((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
return hasUnmapped ? 'unmapped' : hasVatReview ? 'vat_review' : 'all'
})
const [currentPage, setCurrentPage] = useState(1)
@@ -71,6 +85,9 @@ export default function AccountMappingStep({
case 'low_confidence':
result = result.filter((m) => m.targetAccount && m.confidence < 0.7)
break
case 'vat_review':
result = result.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
break
case 'manual':
result = result.filter((m) => m.isOverride)
break
@@ -114,10 +131,11 @@ export default function AccountMappingStep({
const unmapped = mappings.filter((m) => !m.targetAccount).length
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
const manual = mappings.filter((m) => m.isOverride).length
return { unmapped, lowConfidence, manual }
const vatReview = mappings.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed).length
return { unmapped, lowConfidence, manual, vatReview }
}, [mappings])
const canContinue = stats.unmapped === 0
const canContinue = stats.unmapped === 0 && stats.vatReview === 0
// Group BAS accounts by class for the dropdown
const accountsByClass = useMemo(() => {
@@ -145,6 +163,14 @@ export default function AccountMappingStep({
<CardContent className="space-y-4">
{/* Stats */}
<div className="flex gap-4 flex-wrap">
<Badge
variant={filter === 'vat_review' ? 'default' : stats.vatReview > 0 ? 'secondary' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('vat_review')}
>
<AlertCircle className="h-3 w-3 mr-1" />
{t('vat_review_filter', { count: stats.vatReview })}
</Badge>
<Badge
variant={filter === 'unmapped' ? 'destructive' : stats.unmapped > 0 ? 'destructive' : 'secondary'}
className="cursor-pointer"
@@ -197,6 +223,7 @@ export default function AccountMappingStep({
<SelectContent>
<SelectItem value="all">Visa alla</SelectItem>
<SelectItem value="unmapped">Ej mappade</SelectItem>
<SelectItem value="vat_review">{t('vat_review_filter', { count: stats.vatReview })}</SelectItem>
<SelectItem value="low_confidence">Osäkra</SelectItem>
<SelectItem value="manual">Manuellt satta</SelectItem>
</SelectContent>
@@ -211,6 +238,7 @@ export default function AccountMappingStep({
<TableHead className="w-36">Källkonto</TableHead>
<TableHead>Källnamn</TableHead>
<TableHead className="w-12"></TableHead>
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
<TableHead className="w-64">Målkonto</TableHead>
<TableHead className="w-24">Konfidens</TableHead>
</TableRow>
@@ -226,6 +254,78 @@ export default function AccountMappingStep({
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{onVatTreatmentChange &&
mapping.sourceAccount === mapping.targetAccount &&
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
<div className="flex min-w-72 gap-2">
<Select
value={mapping.defaultVatTreatment ?? 'none'}
onValueChange={(value) => {
const treatment = value === 'none'
? null
: value as AccountVatTreatment
const accountClass = Number(mapping.sourceAccount.charAt(0))
const rate = treatment
? defaultRateForVatTreatment(treatment, accountClass)
: null
onVatTreatmentChange(mapping.sourceAccount, treatment, rate)
}}
>
<SelectTrigger className={mapping.requiresVatTreatmentReview ? 'border-warning/60' : ''}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
{vatTreatmentsForAccountClass(
Number(mapping.sourceAccount.charAt(0)),
).map((treatment) => (
<SelectItem key={treatment} value={treatment}>
{t(`vat_treatment_${treatment}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={mapping.defaultVatRate === null || mapping.defaultVatRate === undefined
? 'none'
: String(mapping.defaultVatRate)}
onValueChange={(value) => onVatTreatmentChange(
mapping.sourceAccount,
mapping.defaultVatTreatment ?? null,
value === 'none' ? null : Number(value),
)}
>
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_rate_none')}</SelectItem>
<SelectItem value="0">0 %</SelectItem>
<SelectItem value="0.25">25 %</SelectItem>
<SelectItem value="0.12">12 %</SelectItem>
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onVatTreatmentChange(
mapping.sourceAccount,
mapping.defaultVatTreatment ?? null,
mapping.defaultVatRate ?? null,
)}
>
{t('vat_treatment_confirm')}
</Button>
)}
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Select
value={mapping.targetAccount || 'none'}
@@ -275,7 +375,7 @@ export default function AccountMappingStep({
))}
{paginatedMappings.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
Inga konton matchar filtret
</TableCell>
</TableRow>
+19
View File
@@ -35,6 +35,7 @@ interface ImportResultStepProps {
* bridge. Absent (failure, oversized file, parse miss) = plain header. */
preview?: ImportPreview | null
theaterModel?: TheaterModel | null
unresolvedVatAccountCount?: number
}
export default function ImportResultStep({
@@ -43,6 +44,7 @@ export default function ImportResultStep({
onUndo,
preview = null,
theaterModel = null,
unresolvedVatAccountCount = 0,
}: ImportResultStepProps) {
const t = useTranslations('import')
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -193,6 +195,23 @@ export default function ImportResultStep({
</Card>
)}
{result.success && unresolvedVatAccountCount > 0 && (
<Card className="border-warning/40">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="h-5 w-5 text-warning" />
{t('vat_review_title', { count: unresolvedVatAccountCount })}
</CardTitle>
<CardDescription>{t('vat_review_description')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/chart-of-accounts">{t('vat_review_action')}</Link>
</Button>
</CardContent>
</Card>
)}
{/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
{result.success && result.dimensionsImported && (
<Card>