fix(vat): complete account treatment enforcement (#1593)

* fix(vat): complete account treatment enforcement

* docs(api): refresh account endpoint skill

* fix(mcp): preserve ruta 05 compatibility

* test(vat): seed migration constraint fixtures

* docs(vat): clarify treatment precedence

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-15 23:45:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent edfdbe2d2a
commit 86f0b70fdd
43 changed files with 1017 additions and 359 deletions
@@ -22,7 +22,7 @@ export function AccountVatTreatmentSelect({
const t = useTranslations('chart_of_accounts')
const isRelevant = accountClass === 3 ||
(accountClass !== null && accountClass >= 4 && accountClass <= 6)
const treatments = accountClass === null ? [] : vatTreatmentsForAccountClass(accountClass)
const treatments = vatTreatmentsForAccountClass(accountClass)
return (
<div className="space-y-2">
+15 -2
View File
@@ -22,6 +22,7 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import {
defaultRateForVatTreatment,
isVatTreatmentAllowedForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
@@ -72,6 +73,8 @@ export function AddAccountDialog({
const num = (initialAccountNumber ?? '').replace(/\D/g, '').slice(0, 4)
setAccountNumber(num)
setAccountName(initialAccountName ?? '')
setDefaultVatRate('none')
setDefaultVatTreatment('none')
setError('')
setInactiveConflict(false)
if (num.length === 4) {
@@ -209,6 +212,16 @@ export function AddAccountDialog({
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4)
setAccountNumber(v)
const nextClass = v.length > 0 ? Number(v[0]) : null
if (
defaultVatTreatment !== 'none' &&
(nextClass === null || !isVatTreatmentAllowedForAccountClass(
defaultVatTreatment,
nextClass,
))
) {
setDefaultVatTreatment('none')
}
// The conflict is about a specific number; editing it makes
// the reactivate offer stale.
setInactiveConflict(false)
@@ -274,9 +287,9 @@ export function AddAccountDialog({
accountClass={derived ? Number(accountNumber.charAt(0)) : null}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
if (treatment !== 'none' && defaultVatRate === 'none') {
const rate = defaultRateForVatTreatment(treatment, Number(accountNumber.charAt(0)))
setDefaultVatRate(rate === null ? 'none' : String(rate))
if (rate !== null) setDefaultVatRate(String(rate))
}
}}
/>
+2 -2
View File
@@ -327,9 +327,9 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
accountClass={account.account_class}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
if (treatment !== 'none' && defaultVatRate === 'none') {
const rate = defaultRateForVatTreatment(treatment, account.account_class)
setDefaultVatRate(rate === null ? 'none' : String(rate))
if (rate !== null) setDefaultVatRate(String(rate))
}
}}
/>
@@ -239,8 +239,14 @@ interface MigrationResults {
import AccountMappingStep from '@/components/import/AccountMappingStep'
import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigrationTheater'
import TheaterCanvas from '@/components/import/TheaterCanvas'
import {
applyVatTreatmentReview,
enrichChangedAccountMappingWithVat,
enrichAccountMappingsWithVat,
} from '@/lib/import/account-vat-treatment'
import type { TheaterModel } from '@/lib/import/theater-model'
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
import type { BASAccount } from '@/types'
// ── Types ────────────────────────────────────────────────────────
@@ -962,6 +968,7 @@ function MappingStep({
error,
errorDetails,
onMappingChange,
onVatTreatmentChange,
onContinue,
onBack,
}: {
@@ -970,6 +977,11 @@ function MappingStep({
error: string | null
errorDetails: string[] | null
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => void
onContinue: () => void
onBack: () => void
}) {
@@ -1012,6 +1024,7 @@ function MappingStep({
mappings={sieData.mappings}
basAccounts={sieData.basAccounts}
onMappingChange={onMappingChange}
onVatTreatmentChange={onVatTreatmentChange}
onContinue={onContinue}
onBack={onBack}
/>
@@ -1972,6 +1985,7 @@ export default function ArcimMigrationWorkspace({
// SIE data state (held between mapping and execution steps)
const [sieData, setSieData] = useState<SIEData | null>(null)
const companyAccountsForVatRef = useRef<BASAccount[]>([])
// Options state
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
@@ -2539,16 +2553,29 @@ export default function ArcimMigrationWorkspace({
throw apiError(data, `HTTP ${res.status}`)
}
const data = await res.json()
setSieData(data)
const data = await res.json() as SIEData
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
const accountsBody = await accountsRes.json().catch(() => ({})) as {
data?: BASAccount[]
error?: unknown
}
if (!accountsRes.ok) {
throw apiError(accountsBody, `HTTP ${accountsRes.status}`)
}
companyAccountsForVatRef.current = accountsBody.data ?? []
const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, accountsBody.data ?? [])
setSieData({ ...data, mappings: enrichedMappings })
// If all SIE files are already imported, disable SIE import by default
if (data.allImported) {
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
// Auto-skip mapping step if all accounts are mapped or all files already imported
if (data.mappingStats.unmapped === 0 || data.allImported) {
const needsVatReview = enrichedMappings.some(mapping =>
mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed
)
// Auto-skip only when there is neither account mapping nor VAT review work.
if ((data.mappingStats.unmapped === 0 && !needsVatReview) || data.allImported) {
setStep('options')
}
} catch (err) {
@@ -2571,10 +2598,14 @@ export default function ArcimMigrationWorkspace({
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
if (!sieData) return
const updatedMappings = sieData.mappings.map(m =>
m.sourceAccount === sourceAccount
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
: m
const updatedMappings = enrichChangedAccountMappingWithVat(
sieData.mappings.map(m =>
m.sourceAccount === sourceAccount
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
: m
),
sourceAccount,
companyAccountsForVatRef.current,
)
setSieData(prev => prev ? {
...prev,
@@ -2587,6 +2618,21 @@ export default function ArcimMigrationWorkspace({
} : null)
}, [sieData])
const handleVatTreatmentChange = useCallback((
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => {
setSieData(prev => prev ? {
...prev,
mappings: applyVatTreatmentReview(prev.mappings, sourceAccount, treatment, rate),
} : null)
}, [])
const handleMappingContinue = useCallback(() => {
setStep('options')
}, [])
const handleStartMigration = useCallback(async () => {
if (!consentId) return
@@ -2832,7 +2878,8 @@ export default function ArcimMigrationWorkspace({
error={error}
errorDetails={errorDetails}
onMappingChange={handleMappingChange}
onContinue={() => setStep('options')}
onVatTreatmentChange={handleVatTreatmentChange}
onContinue={handleMappingContinue}
onBack={() => setStep('preview')}
/>
)}
+49 -44
View File
@@ -42,7 +42,7 @@ interface AccountMappingStepProps {
mappings: AccountMapping[]
basAccounts: BASAccount[]
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange?: (
onVatTreatmentChange: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
@@ -238,8 +238,8 @@ 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="min-w-72">{t('vat_treatment_column')}</TableHead>
<TableHead className="w-24">Konfidens</TableHead>
</TableRow>
</TableHeader>
@@ -255,8 +255,43 @@ export default function AccountMappingStep({
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{onVatTreatmentChange &&
mapping.sourceAccount === mapping.targetAccount &&
<Select
value={mapping.targetAccount || 'none'}
onValueChange={(value) => {
const account = basAccounts.find((a) => a.account_number === value)
onMappingChange(
mapping.sourceAccount,
value === 'none' ? '' : value,
account?.account_name || ''
)
}}
>
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
<SelectValue placeholder="Välj konto..." />
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value="none">-- Välj konto --</SelectItem>
{Object.entries(accountsByClass).map(([className, accounts]) => (
<div key={className}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
{className}
</div>
{accounts.map((account) => (
<SelectItem
key={account.account_number}
value={account.account_number}
>
<span className="font-mono mr-2">{account.account_number}</span>
{account.account_name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{mapping.sourceAccount === mapping.targetAccount &&
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
<div className="flex min-w-72 gap-2">
<Select
@@ -267,8 +302,10 @@ export default function AccountMappingStep({
: value as AccountVatTreatment
const accountClass = Number(mapping.sourceAccount.charAt(0))
const rate = treatment
? defaultRateForVatTreatment(treatment, accountClass)
: null
? mapping.defaultVatRate == null || mapping.vatTreatmentSuggested
? defaultRateForVatTreatment(treatment, accountClass)
: mapping.defaultVatRate
: mapping.defaultVatRate ?? null
onVatTreatmentChange(mapping.sourceAccount, treatment, rate)
}}
>
@@ -278,7 +315,7 @@ export default function AccountMappingStep({
<SelectContent>
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
{vatTreatmentsForAccountClass(
Number(mapping.sourceAccount.charAt(0)),
Number(mapping.sourceAccount.charAt(0))
).map((treatment) => (
<SelectItem key={treatment} value={treatment}>
{t(`vat_treatment_${treatment}`)}
@@ -326,42 +363,6 @@ export default function AccountMappingStep({
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Select
value={mapping.targetAccount || 'none'}
onValueChange={(value) => {
const account = basAccounts.find((a) => a.account_number === value)
onMappingChange(
mapping.sourceAccount,
value === 'none' ? '' : value,
account?.account_name || ''
)
}}
>
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
<SelectValue placeholder="Välj konto..." />
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value="none">-- Välj konto --</SelectItem>
{Object.entries(accountsByClass).map(([className, accounts]) => (
<div key={className}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
{className}
</div>
{accounts.map((account) => (
<SelectItem
key={account.account_number}
value={account.account_number}
>
<span className="font-mono mr-2">{account.account_number}</span>
{account.account_name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{mapping.targetAccount && (
<ConfidenceBadge
@@ -422,7 +423,11 @@ export default function AccountMappingStep({
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={!canContinue}>
{canContinue ? 'Fortsätt till granskning' : `${stats.unmapped} konton saknar mappning`}
{canContinue
? 'Fortsätt till granskning'
: stats.unmapped > 0
? `${stats.unmapped} konton saknar mappning`
: `${stats.vatReview} momskoder återstår att bekräfta`}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>