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:
co-authored by
Claude Fable 5
parent
edfdbe2d2a
commit
86f0b70fdd
@@ -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">
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user