feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)

* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse)

Disposal books depreciation to the disposal date, clears cost and
accumulated depreciation, books gain (3973) or loss (7973), applies
output VAT on third-party sales, honors the ML 5 kap. 38 §
verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning
server-side from tax years and original input VAT. The voucher, the
disposal-date depreciation schedule and the immutable register state
commit in one dedicated commit_asset_disposal RPC transaction that
delegates voucher numbering to commit_journal_entry.

Fixes #325

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

* fix(assets): harden disposal per review and pg-real findings

- commit_asset_disposal now uses the NULL-safe caller_is_company_member()
  guard (tenant-guard ratchet) and passes the allowed 'user_accept'
  commit_method instead of the unlisted 'asset_disposal' value
- disposal metadata invariants validated in the RPC (non-negative
  proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no
  proceeds) since the RPC is independently callable
- new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so
  the migration never blocks writes on the hot journal_entries table
- disposeAsset paginates fiscal periods and depreciation schedules with
  fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||)
- engine imports shared AssetDisposalType/AssetJamkningDirection/
  VatTreatment unions; post-commit reload retries once and logs before
  surfacing, so a transient read cannot masquerade as a failed disposal
- dispose page parses Swedish-formatted amounts (125 000,50) and blocks
  submission on unparseable proceeds
- assets pg tests write disposal attributes in the disposal transition
  itself and gain a regression test that the register is frozen after

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-04 09:59:52 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1c9d378df8
commit cb3ef45f14
22 changed files with 19644 additions and 1805 deletions
+232 -408
View File
@@ -1,18 +1,17 @@
'use client'
import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { use, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Loader2, Lock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PageHeader } from '@/components/ui/page-header'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
@@ -20,15 +19,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import { assessJamkning, assessJamkningEligibility } from '@/lib/bokslut/assets/jamkning'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import {
assessJamkningEligibility,
computeJamkningAmount,
} from '@/lib/bokslut/assets/jamkning'
import type { Asset, FiscalPeriod, VatTreatment } from '@/types'
import type { Asset, AssetDisposalType, FiscalPeriod, VatTreatment } from '@/types'
interface PeriodOption {
id: string
@@ -39,21 +37,23 @@ interface PeriodOption {
locked_at: string | null
}
const VAT_TREATMENT_OPTIONS: { value: VatTreatment; label: string; rate: number | null }[] = [
{ value: 'standard_25', label: 'Standard 25 %', rate: 0.25 },
{ value: 'reduced_12', label: 'Reducerad 12 %', rate: 0.12 },
{ value: 'reduced_6', label: 'Reducerad 6 %', rate: 0.06 },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet', rate: null },
{ value: 'export', label: 'Export (utanför EU)', rate: null },
{ value: 'exempt', label: 'Momsfri', rate: null },
]
const VAT_TREATMENTS = ['standard_25', 'reverse_charge', 'export', 'exempt'] as const
function round2(n: number): number {
return Math.round(n * 100) / 100
function round2(value: number): number {
return Math.round(value * 100) / 100
}
// Accept Swedish-formatted amounts ("125 000,50") as well as dot decimals.
function parseAmount(raw: string): number | null {
const normalized = raw.replace(/\s/g, '').replace(',', '.')
if (normalized === '') return null
const value = Number(normalized)
return Number.isFinite(value) ? value : null
}
export default function DisposeAssetPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const t = useTranslations('assets.disposal')
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -62,513 +62,337 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
const [periods, setPeriods] = useState<PeriodOption[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
// Form state
const [disposalDate, setDisposalDate] = useState<string>(() => new Date().toISOString().slice(0, 10))
const [proceeds, setProceeds] = useState<string>('')
const [disposalType, setDisposalType] = useState<AssetDisposalType>('sale')
const [disposalDate, setDisposalDate] = useState(
() => new Date().toISOString().slice(0, 10),
)
const [proceeds, setProceeds] = useState('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment>('standard_25')
const [vatAmount, setVatAmount] = useState<string>('')
const [vatAutoCalc, setVatAutoCalc] = useState(true)
const [periodId, setPeriodId] = useState<string>('')
const [proceedsAccount, setProceedsAccount] = useState<string>('1930')
const [periodId, setPeriodId] = useState('')
const [proceedsAccount, setProceedsAccount] = useState('1930')
const [originalInputVat, setOriginalInputVat] = useState('')
const [originalDeductionPercent, setOriginalDeductionPercent] = useState('100')
const [businessTransferConfirmed, setBusinessTransferConfirmed] = useState(false)
const [adjustmentDocumentConfirmed, setAdjustmentDocumentConfirmed] = useState(false)
// Jämkning state
const [jamkningEnabled, setJamkningEnabled] = useState(false)
const [originalInputVat, setOriginalInputVat] = useState<string>('')
// Load asset + periods
useEffect(() => {
let cancelled = false
Promise.all([
fetch(`/api/assets`).then((r) => r.json()),
fetch('/api/bookkeeping/fiscal-periods').then((r) => r.json()),
fetch(`/api/assets/${id}`).then((response) => response.json()),
fetch('/api/bookkeeping/fiscal-periods').then((response) => response.json()),
])
.then(([assetsRes, periodsRes]) => {
.then(([assetResponse, periodsResponse]) => {
if (cancelled) return
const assets: Asset[] = assetsRes.data ?? []
const found = assets.find((a) => a.id === id) ?? null
setAsset(found)
const periodList: PeriodOption[] = (periodsRes.data ?? []).map((p: FiscalPeriod) => ({
id: p.id,
name: p.name,
period_start: p.period_start,
period_end: p.period_end,
is_closed: p.is_closed,
locked_at: p.locked_at,
}))
setPeriods(periodList)
setLoading(false)
setAsset(assetResponse.data ?? null)
setPeriods(
(periodsResponse.data ?? []).map((period: FiscalPeriod) => ({
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
is_closed: period.is_closed,
locked_at: period.locked_at,
})),
)
})
.catch(() => {
if (!cancelled) {
toast({
title: 'Kunde inte ladda',
description: 'Försök igen.',
title: t('load_failed_title'),
description: t('try_again'),
variant: 'destructive',
})
setLoading(false)
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [id, toast])
}, [id, t, toast])
// Auto-select matching fiscal period when disposalDate changes.
useEffect(() => {
if (!disposalDate || periods.length === 0) return
const match = periods.find(
(p) => disposalDate >= p.period_start && disposalDate <= p.period_end,
(period) => disposalDate >= period.period_start && disposalDate <= period.period_end,
)
if (match && match.id !== periodId) setPeriodId(match.id)
}, [disposalDate, periods, periodId])
if (match) setPeriodId(match.id)
}, [disposalDate, periods])
// Derived: VAT rate from treatment
const selectedVatOpt = VAT_TREATMENT_OPTIONS.find((o) => o.value === vatTreatment)
const proceedsNum = Number(proceeds) || 0
const computedVat = useMemo(() => {
if (!selectedVatOpt || selectedVatOpt.rate === null) return 0
// Standard convention: proceeds is GROSS (incl VAT).
// vat = gross × rate / (1 + rate)
return round2((proceedsNum * selectedVatOpt.rate) / (1 + selectedVatOpt.rate))
}, [proceedsNum, selectedVatOpt])
// Auto-fill VAT amount when auto-calc is on.
useEffect(() => {
if (vatAutoCalc) {
if (selectedVatOpt && selectedVatOpt.rate !== null) {
setVatAmount(String(computedVat))
} else {
setVatAmount('0')
}
if (disposalType === 'scrap') setProceeds('0')
if (disposalType !== 'business_transfer') {
setBusinessTransferConfirmed(false)
setAdjustmentDocumentConfirmed(false)
}
}, [computedVat, selectedVatOpt, vatAutoCalc])
}, [disposalType])
const parsedProceeds = parseAmount(proceeds)
const proceedsNumber = parsedProceeds ?? 0
const proceedsInvalid =
disposalType !== 'scrap' && proceeds.trim() !== '' && parsedProceeds === null
const vatAmount =
disposalType === 'sale' && vatTreatment === 'standard_25'
? round2(proceedsNumber * (0.25 / 1.25))
: 0
const netProceeds = round2(proceedsNumber - vatAmount)
const selectedPeriod = periods.find((period) => period.id === periodId)
const periodLocked = Boolean(
selectedPeriod && (selectedPeriod.is_closed || selectedPeriod.locked_at !== null),
)
// Jämkning eligibility, derived from asset + disposal date.
const eligibility = useMemo(() => {
if (!asset) return null
return assessJamkningEligibility({
basAssetAccount: asset.bas_asset_account,
basExpenseAccount: asset.bas_expense_account,
category: asset.category,
acquisitionDate: asset.acquisition_date,
disposalDate,
basAssetAccount: asset.bas_asset_account,
category: asset.category,
})
}, [asset, disposalDate])
// Auto-enable jämkning toggle when disposal falls within the correction period.
useEffect(() => {
if (eligibility?.withinCorrectionPeriod && !jamkningEnabled) {
setJamkningEnabled(true)
}
}, [eligibility?.withinCorrectionPeriod, jamkningEnabled])
const originalInputVatNum = Number(originalInputVat) || 0
const jamkningAmount = useMemo(() => {
if (!jamkningEnabled || !eligibility) return 0
return computeJamkningAmount({
originalInputVat: originalInputVatNum,
totalCorrectionMonths: eligibility.totalCorrectionMonths,
remainingMonths: eligibility.remainingMonths,
disposalEvent: 'triggers_jamkning',
const possibleInvestmentGood = Boolean(
asset &&
eligibility?.withinAdjustmentPeriod &&
Number(asset.acquisition_cost) >= (eligibility.totalYears === 10 ? 400_000 : 200_000),
)
const jamkningAssessment = useMemo(() => {
if (!asset || originalInputVat === '' || originalDeductionPercent === '') return null
return assessJamkning({
acquisitionDate: asset.acquisition_date,
disposalDate,
category: asset.category,
basAssetAccount: asset.bas_asset_account,
originalInputVat: Number(originalInputVat) || 0,
originalDeductionPercent: Number(originalDeductionPercent) || 0,
disposalType,
vatTreatment: disposalType === 'sale' ? vatTreatment : undefined,
netProceeds,
})
}, [jamkningEnabled, eligibility, originalInputVatNum])
}, [
asset,
disposalDate,
disposalType,
netProceeds,
originalDeductionPercent,
originalInputVat,
vatTreatment,
])
const handleSubmit = useCallback(async () => {
if (!asset || !periodId) return
setSubmitting(true)
const vatNum = Number(vatAmount) || 0
const body: Record<string, unknown> = {
disposal_type: disposalType,
disposed_at: disposalDate,
disposed_proceeds: proceedsNum,
disposed_proceeds: disposalType === 'scrap' ? 0 : proceedsNumber,
fiscal_period_id: periodId,
proceeds_account: proceedsAccount,
}
if (vatNum > 0) {
body.proceeds_vat = vatNum
body.vat_treatment = vatTreatment
if (disposalType === 'sale') body.vat_treatment = vatTreatment
if (originalInputVat !== '' && originalDeductionPercent !== '') {
body.jamkning_original_input_vat = Number(originalInputVat)
body.jamkning_original_deduction_percent = Number(originalDeductionPercent)
}
if (jamkningEnabled && jamkningAmount > 0 && eligibility) {
body.jamkning_amount = jamkningAmount
body.jamkning_remaining_months = eligibility.remainingMonths
body.jamkning_total_months = eligibility.totalCorrectionMonths
body.jamkning_original_input_vat = originalInputVatNum
if (disposalType === 'business_transfer') {
body.business_transfer_confirmed = businessTransferConfirmed
body.adjustment_document_confirmed = adjustmentDocumentConfirmed
}
try {
const res = await fetch(`/api/assets/${id}/dispose`, {
const response = await fetch(`/api/assets/${id}/dispose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json()
if (!res.ok) {
const json = await response.json()
if (!response.ok) {
toast({
title: 'Avyttring misslyckades',
description: getErrorMessage(json?.error ?? json) || 'Försök igen.',
title: t('submit_failed_title'),
description: getErrorMessage(json?.error ?? json) || t('try_again'),
variant: 'destructive',
})
return
}
toast({
title: 'Tillgång avyttrad',
description: 'Verifikat skapat.',
})
toast({ title: t('success_title'), description: t('success_description') })
router.push('/assets')
} catch (err) {
} catch (error) {
toast({
title: 'Avyttring misslyckades',
description: getErrorMessage(err),
title: t('submit_failed_title'),
description: getErrorMessage(error),
variant: 'destructive',
})
} finally {
setSubmitting(false)
}
}, [
adjustmentDocumentConfirmed,
asset,
businessTransferConfirmed,
disposalDate,
eligibility,
disposalType,
id,
jamkningAmount,
jamkningEnabled,
originalInputVatNum,
originalDeductionPercent,
originalInputVat,
periodId,
proceedsAccount,
proceedsNum,
proceedsNumber,
router,
t,
toast,
vatAmount,
vatTreatment,
])
if (loading) {
return (
<div className="space-y-8">
<PageHeader title="Avyttra tillgång" />
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
<PageHeader title={t('title')} />
<Card><CardContent className="space-y-3 p-6"><Skeleton className="h-6 w-1/3" /><Skeleton className="h-4 w-2/3" /></CardContent></Card>
</div>
)
}
if (!asset) {
if (!asset || asset.disposed_at) {
return (
<div className="space-y-8">
<PageHeader title="Avyttra tillgång" />
<PageHeader title={t('title')} />
<Card>
<CardContent className="p-6">
<p>Tillgången kunde inte hittas.</p>
<div className="mt-4">
<Link href="/assets">
<Button variant="secondary">
<ArrowLeft className="mr-1 h-4 w-4" />
Tillbaka
</Button>
</Link>
</div>
<CardContent className="space-y-4 p-6">
<p>{!asset ? t('not_found') : t('already_disposed', { date: formatDate(asset.disposed_at!) })}</p>
<Link href="/assets"><Button variant="secondary"><ArrowLeft className="mr-1 h-4 w-4" />{t('back')}</Button></Link>
</CardContent>
</Card>
</div>
)
}
if (asset.disposed_at) {
return (
<div className="space-y-8">
<PageHeader title="Avyttra tillgång" />
<Card>
<CardContent className="p-6">
<p className="mb-4">
Tillgången är redan avyttrad ({formatDate(asset.disposed_at)}).
</p>
<Link href="/assets">
<Button variant="secondary">
<ArrowLeft className="mr-1 h-4 w-4" />
Tillbaka
</Button>
</Link>
</CardContent>
</Card>
</div>
)
}
const netProceeds = round2(proceedsNum - (Number(vatAmount) || 0))
const isVatLineTreatment = selectedVatOpt?.rate !== null
const selectedPeriod = periods.find((p) => p.id === periodId)
const periodLocked = selectedPeriod
? selectedPeriod.is_closed || selectedPeriod.locked_at !== null
: false
const transferNeedsDocument = jamkningAssessment?.direction === 'transferred'
const missingJamkningData = possibleInvestmentGood &&
(originalInputVat === '' || originalDeductionPercent === '')
return (
<div className="space-y-8">
<PageHeader
title="Avyttra tillgång"
action={
<Link href="/assets">
<Button variant="secondary">
<ArrowLeft className="mr-1 h-4 w-4" />
Tillbaka
</Button>
</Link>
}
title={t('title')}
action={<Link href="/assets"><Button variant="secondary"><ArrowLeft className="mr-1 h-4 w-4" />{t('back')}</Button></Link>}
/>
<Card>
<CardHeader>
<CardTitle className="text-base">{asset.name}</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Anskaffningsvärde</span>
<span className="tabular-nums">{formatCurrency(Number(asset.acquisition_cost))}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Anskaffat</span>
<span className="tabular-nums">{formatDate(asset.acquisition_date)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Konton (BAS)</span>
<span className="tabular-nums">
{asset.bas_asset_account} / {asset.bas_accumulated_account} / {asset.bas_expense_account}
</span>
</div>
<CardHeader><CardTitle className="text-base">{asset.name}</CardTitle></CardHeader>
<CardContent className="space-y-2 p-6 pt-0 text-sm">
<SummaryRow label={t('acquisition_cost')} value={formatCurrency(Number(asset.acquisition_cost))} />
<SummaryRow label={t('acquired')} value={formatDate(asset.acquisition_date)} />
<SummaryRow label={t('bas_accounts')} value={`${asset.bas_asset_account} / ${asset.bas_accumulated_account} / ${asset.bas_expense_account}`} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Avyttringsuppgifter</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="disposalDate">Avyttringsdatum</Label>
<Input
id="disposalDate"
type="date"
value={disposalDate}
onChange={(e) => setDisposalDate(e.target.value)}
className="tabular-nums"
/>
</div>
<CardHeader><CardTitle className="text-base">{t('details_title')}</CardTitle></CardHeader>
<CardContent className="grid gap-4 p-6 pt-0 md:grid-cols-2">
<Field label={t('type_label')} htmlFor="disposalType">
<Select value={disposalType} onValueChange={(value) => setDisposalType(value as AssetDisposalType)}>
<SelectTrigger id="disposalType"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sale">{t('type_sale')}</SelectItem>
<SelectItem value="scrap">{t('type_scrap')}</SelectItem>
<SelectItem value="business_transfer">{t('type_business_transfer')}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={t('date_label')} htmlFor="disposalDate">
<Input id="disposalDate" type="date" value={disposalDate} onChange={(event) => setDisposalDate(event.target.value)} className="tabular-nums" />
</Field>
<Field label={t('period_label')} htmlFor="period">
<Select value={periodId} onValueChange={setPeriodId}>
<SelectTrigger id="period"><SelectValue placeholder={t('period_placeholder')} /></SelectTrigger>
<SelectContent>{periods.map((period) => <SelectItem key={period.id} value={period.id}>{period.name}{period.is_closed || period.locked_at ? ` (${t('locked')})` : ''}</SelectItem>)}</SelectContent>
</Select>
{periodLocked && <p className="text-xs text-destructive">{t('period_locked')}</p>}
</Field>
<Field label={disposalType === 'business_transfer' ? t('consideration_label') : t('proceeds_label')} htmlFor="proceeds">
<Input id="proceeds" inputMode="decimal" value={proceeds} onChange={(event) => setProceeds(event.target.value)} disabled={disposalType === 'scrap'} className="tabular-nums" />
</Field>
{disposalType !== 'scrap' && <Field label={t('proceeds_account_label')} htmlFor="proceedsAccount"><Input id="proceedsAccount" value={proceedsAccount} onChange={(event) => setProceedsAccount(event.target.value)} className="tabular-nums" /></Field>}
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="period">Räkenskapsperiod</Label>
<Select value={periodId} onValueChange={setPeriodId}>
<SelectTrigger id="period">
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
{periods.map((p) => {
const locked = p.is_closed || p.locked_at !== null
return (
<SelectItem key={p.id} value={p.id}>
{p.name}
{locked ? ' (låst)' : ''}
</SelectItem>
)
})}
</SelectContent>
{disposalType === 'sale' && (
<Card>
<CardHeader><CardTitle className="text-base">{t('vat_title')}</CardTitle></CardHeader>
<CardContent className="space-y-4 p-6 pt-0">
<Field label={t('vat_treatment_label')} htmlFor="vatTreatment">
<Select value={vatTreatment} onValueChange={(value) => setVatTreatment(value as VatTreatment)}>
<SelectTrigger id="vatTreatment"><SelectValue /></SelectTrigger>
<SelectContent>{VAT_TREATMENTS.map((value) => <SelectItem key={value} value={value}>{t(`vat_${value}`)}</SelectItem>)}</SelectContent>
</Select>
{periodLocked && (
<p className="text-xs text-destructive">
Vald period är låst eller stängd: välj en öppen period.
</p>
)}
</Field>
<div className="rounded-md bg-secondary/40 p-3 text-xs">
<SummaryRow label={t('gross')} value={formatCurrency(proceedsNumber)} />
<SummaryRow label={t('vat')} value={formatCurrency(vatAmount)} />
<SummaryRow label={t('net')} value={formatCurrency(netProceeds)} strong />
</div>
<div className="space-y-2">
<Label htmlFor="proceeds">Erhållet belopp (inkl. moms)</Label>
<Input
id="proceeds"
inputMode="decimal"
value={proceeds}
onChange={(e) => setProceeds(e.target.value)}
placeholder="0,00"
className="tabular-nums"
/>
</div>
<div className="space-y-2">
<Label htmlFor="proceedsAccount">Mottagarkonto</Label>
<Input
id="proceedsAccount"
value={proceedsAccount}
onChange={(e) => setProceedsAccount(e.target.value)}
placeholder="1930"
className="tabular-nums"
/>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Moms vid avyttring (ML 3 kap 3 §)</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="vatTreatment">Momsbehandling</Label>
<Select
value={vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment)}
>
<SelectTrigger id="vatTreatment">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_TREATMENT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="vatAmount">Utgående moms</Label>
<Input
id="vatAmount"
inputMode="decimal"
value={vatAmount}
onChange={(e) => {
setVatAutoCalc(false)
setVatAmount(e.target.value)
}}
placeholder="0,00"
disabled={!isVatLineTreatment}
className="tabular-nums"
/>
{isVatLineTreatment && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={vatAutoCalc}
onCheckedChange={setVatAutoCalc}
aria-label="Räkna ut moms automatiskt"
/>
<span>Räkna ut moms automatiskt</span>
</div>
)}
</div>
</div>
<div className="rounded-md bg-secondary/40 p-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Brutto</span>
<span className="tabular-nums">{formatCurrency(proceedsNum)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="tabular-nums">{formatCurrency(Number(vatAmount) || 0)}</span>
</div>
<div className="flex justify-between font-medium">
<span>Netto</span>
<span className="tabular-nums">{formatCurrency(netProceeds)}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Jämkning av ingående moms (ML 8a kap)</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-4">
{eligibility?.withinCorrectionPeriod ? (
<Badge variant="warning">
Inom korrigeringstid ({eligibility.remainingMonths} mån kvar av{' '}
{eligibility.totalCorrectionMonths})
</Badge>
) : (
<Badge variant="secondary">Utanför korrigeringstid: ingen jämkning behövs</Badge>
)}
<div className="flex items-center gap-3">
<Switch
id="jamkningEnabled"
checked={jamkningEnabled}
onCheckedChange={setJamkningEnabled}
disabled={!eligibility?.withinCorrectionPeriod}
/>
<Label htmlFor="jamkningEnabled" className="cursor-pointer">
Bokför jämkning
</Label>
</div>
{jamkningEnabled && eligibility?.withinCorrectionPeriod && (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="originalInputVat">Ursprungligt ingående momsavdrag</Label>
<Input
id="originalInputVat"
inputMode="decimal"
value={originalInputVat}
onChange={(e) => setOriginalInputVat(e.target.value)}
placeholder="0,00"
className="tabular-nums"
/>
</div>
<div className="space-y-2">
<Label>Korrigeringstid</Label>
<div className="rounded-md border border-border bg-secondary/40 px-3 py-2 text-sm tabular-nums">
{eligibility.totalCorrectionMonths} mån
</div>
</div>
<div className="space-y-2">
<Label>Återstående månader</Label>
<div className="rounded-md border border-border bg-secondary/40 px-3 py-2 text-sm tabular-nums">
{eligibility.remainingMonths} mån
</div>
</div>
<div className="space-y-2">
<Label>Beräknad jämkning</Label>
<div className="rounded-md border border-border bg-secondary/40 px-3 py-2 text-sm tabular-nums font-medium">
{formatCurrency(jamkningAmount)}
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
Jämkningen bokförs som kredit på 2641 (återförd ingående moms) och debet på
förlustkontot för tillgångsklassen.
</p>
<CardHeader><CardTitle className="text-base">{t('adjustment_title')}</CardTitle></CardHeader>
<CardContent className="space-y-4 p-6 pt-0">
{eligibility?.withinAdjustmentPeriod
? <Badge variant="warning">{t('within_adjustment_period', { years: eligibility.remainingYears, total: eligibility.totalYears })}</Badge>
: <Badge variant="secondary">{t('outside_adjustment_period')}</Badge>}
{eligibility?.withinAdjustmentPeriod && (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('original_vat_label')} htmlFor="originalInputVat" hint={t('original_vat_hint', { threshold: eligibility.threshold })}>
<Input id="originalInputVat" inputMode="decimal" value={originalInputVat} onChange={(event) => setOriginalInputVat(event.target.value)} className="tabular-nums" />
</Field>
<Field label={t('original_percent_label')} htmlFor="originalDeductionPercent">
<Input id="originalDeductionPercent" type="number" min={0} max={100} value={originalDeductionPercent} onChange={(event) => setOriginalDeductionPercent(event.target.value)} className="tabular-nums" />
</Field>
</div>
)}
{jamkningAssessment && (
<div className="rounded-md border border-border bg-secondary/40 p-3 text-sm">
<SummaryRow label={t('adjustment_direction')} value={t(`direction_${jamkningAssessment.direction}`)} />
<SummaryRow label={t('adjustment_amount')} value={formatCurrency(jamkningAssessment.amount)} strong />
{jamkningAssessment.capped && <p className="mt-2 text-xs text-muted-foreground">{t('adjustment_capped')}</p>}
</div>
)}
{disposalType === 'business_transfer' && (
<div className="flex items-center gap-3 rounded-md border border-border p-3">
<Switch id="businessTransfer" checked={businessTransferConfirmed} onCheckedChange={setBusinessTransferConfirmed} />
<Label htmlFor="businessTransfer" className="cursor-pointer">{t('business_transfer_confirm')}</Label>
</div>
)}
{disposalType === 'business_transfer' && transferNeedsDocument && (
<div className="flex items-center gap-3 rounded-md border border-border p-3">
<Switch id="adjustmentDocument" checked={adjustmentDocumentConfirmed} onCheckedChange={setAdjustmentDocumentConfirmed} />
<Label htmlFor="adjustmentDocument" className="cursor-pointer">{t('adjustment_document_confirm')}</Label>
</div>
)}
{missingJamkningData && <p className="text-xs text-destructive">{t('adjustment_data_required')}</p>}
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Link href="/assets">
<Button variant="secondary" disabled={submitting}>
Avbryt
</Button>
</Link>
<Link href="/assets"><Button variant="secondary" disabled={submitting}>{t('cancel')}</Button></Link>
<Button
onClick={handleSubmit}
disabled={
!canWrite ||
submitting ||
!periodId ||
periodLocked ||
proceedsNum < 0 ||
(proceeds !== '' && Number.isNaN(proceedsNum))
}
title={!canWrite ? 'Endast användare med skrivrättigheter kan avyttra tillgångar.' : undefined}
disabled={!canWrite || submitting || !periodId || periodLocked || proceedsInvalid || missingJamkningData || (disposalType === 'business_transfer' && !businessTransferConfirmed) || (transferNeedsDocument && !adjustmentDocumentConfirmed)}
title={!canWrite ? t('write_required') : undefined}
>
{!canWrite && <Lock className="mr-1 h-4 w-4" />}
{submitting && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
Avyttra
{t('submit')}
</Button>
</div>
</div>
)
}
function Field({ label, htmlFor, hint, children }: { label: string; htmlFor: string; hint?: string; children: ReactNode }) {
return <div className="space-y-2"><Label htmlFor={htmlFor}>{label}</Label>{children}{hint && <p className="text-xs text-muted-foreground">{hint}</p>}</div>
}
function SummaryRow({ label, value, strong = false }: { label: string; value: string; strong?: boolean }) {
return <div className={`flex justify-between gap-4 ${strong ? 'font-medium' : ''}`}><span className="text-muted-foreground">{label}</span><span className="text-right tabular-nums">{value}</span></div>
}
+35 -62
View File
@@ -7,8 +7,6 @@ import { disposeAsset } from '@/lib/bokslut/assets/asset-service'
const VAT_TREATMENTS = [
'standard_25',
'reduced_12',
'reduced_6',
'reverse_charge',
'export',
'exempt',
@@ -16,77 +14,52 @@ const VAT_TREATMENTS = [
const DisposeAssetSchema = z
.object({
disposal_type: z.enum(['sale', 'scrap', 'business_transfer']),
disposed_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
/** Gross proceeds (INCL VAT when applicable). */
disposed_proceeds: z.number().nonnegative(),
proceeds_account: z.string().regex(/^\d{4}$/).optional(),
fiscal_period_id: z.string().uuid(),
/** Output VAT on the proceeds. Defaults to 0 (sale was momsfri). */
proceeds_vat: z.number().nonnegative().optional(),
/** Required when proceeds_vat > 0 so the engine can resolve a 26xx account. */
vat_treatment: z.enum(VAT_TREATMENTS).optional(),
/** Precomputed jämkning amount (ML 8a kap 7 §). Caller supplies; engine
* books a 2641 credit + loss-account debit. */
jamkning_amount: z.number().nonnegative().optional(),
/** Audit metadata. */
jamkning_remaining_months: z.number().int().nonnegative().optional(),
jamkning_total_months: z.number().int().positive().optional(),
jamkning_original_input_vat: z.number().nonnegative().optional(),
// accumulated_depreciation is intentionally NOT accepted from the client:
// disposeAsset sums depreciation_schedules server-side so callers cannot
// inflate the book-value calculation.
jamkning_original_deduction_percent: z.number().min(0).max(100).optional(),
business_transfer_confirmed: z.boolean().optional(),
adjustment_document_confirmed: z.boolean().optional(),
})
.superRefine((value, ctx) => {
// VAT consistency: if a treatment that produces a VAT line is selected,
// the VAT amount must equal 25%/12%/6% of the net proceeds. Tolerance is
// ±0.50 kr to handle rounding on item prices.
if (value.proceeds_vat && value.proceeds_vat > 0) {
if (!value.vat_treatment) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment krävs när proceeds_vat > 0.',
})
return
}
const rate = vatRateFromTreatment(value.vat_treatment)
if (rate === null) {
// Treatments without a VAT line must carry 0 VAT.
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['proceeds_vat'],
message: `proceeds_vat måste vara 0 för momsbehandling "${value.vat_treatment}".`,
})
return
}
// Expected: proceeds_gross = net × (1 + rate), so net = gross / (1 + rate)
// and vat = gross - net = gross × rate / (1 + rate).
const expectedVat = (value.disposed_proceeds * rate) / (1 + rate)
if (Math.abs(expectedVat - value.proceeds_vat) > 0.5) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['proceeds_vat'],
message: `proceeds_vat ska vara ~${Math.round(expectedVat * 100) / 100} kr för momsbehandling "${value.vat_treatment}" på ${value.disposed_proceeds} kr brutto.`,
})
}
if (value.disposal_type === 'scrap' && value.disposed_proceeds !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['disposed_proceeds'],
message: 'disposed_proceeds måste vara 0 vid utrangering.',
})
}
if (value.disposal_type === 'sale' && value.disposed_proceeds > 0 && !value.vat_treatment) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment krävs vid försäljning.',
})
}
if (value.disposal_type !== 'sale' && value.vat_treatment !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment får bara anges vid försäljning.',
})
}
const hasVat = value.jamkning_original_input_vat !== undefined
const hasPercent = value.jamkning_original_deduction_percent !== undefined
if (hasVat !== hasPercent) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: hasVat
? ['jamkning_original_deduction_percent']
: ['jamkning_original_input_vat'],
message: 'Ursprungsmoms och ursprunglig avdragsprocent måste anges tillsammans.',
})
}
})
function vatRateFromTreatment(t: (typeof VAT_TREATMENTS)[number]): number | null {
switch (t) {
case 'standard_25':
return 0.25
case 'reduced_12':
return 0.12
case 'reduced_6':
return 0.06
case 'reverse_charge':
case 'export':
case 'exempt':
return null
}
}
export const POST = withRouteContext(
'assets.dispose',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+112
View File
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
disposeAsset: vi.fn(),
}))
import { disposeAsset } from '@/lib/bokslut/assets/asset-service'
import { POST } from '../[id]/dispose/route'
const mockDisposeAsset = vi.mocked(disposeAsset)
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
const validBody = {
disposal_type: 'sale',
disposed_at: '2026-06-30',
disposed_proceeds: 125_000,
proceeds_account: '1930',
fiscal_period_id: '11111111-1111-4111-8111-111111111111',
vat_treatment: 'standard_25',
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('POST /api/assets/[id]/dispose', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(
createMockRequest('/api/assets/asset-1/dispose', { method: 'POST', body: validBody }),
routeParams,
)
expect(response.status).toBe(401)
expect(mockDisposeAsset).not.toHaveBeenCalled()
})
it('returns 400 for inconsistent scrapping proceeds', async () => {
const response = await POST(
createMockRequest('/api/assets/asset-1/dispose', {
method: 'POST',
body: { ...validBody, disposal_type: 'scrap', disposed_proceeds: 100, vat_treatment: undefined },
}),
routeParams,
)
expect(response.status).toBe(400)
expect(mockDisposeAsset).not.toHaveBeenCalled()
})
it('returns 404 when the asset does not exist', async () => {
mockDisposeAsset.mockRejectedValue(Object.assign(new Error('Asset not found'), { code: 'ASSET_NOT_FOUND' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(
createMockRequest('/api/assets/asset-1/dispose', { method: 'POST', body: validBody }),
routeParams,
),
)
expect(status).toBe(404)
expect(body.error.code).toBe('ASSET_NOT_FOUND')
})
it('returns the atomically posted disposal', async () => {
mockDisposeAsset.mockResolvedValue({
asset: { id: 'asset-1', disposed_at: '2026-06-30' },
disposal_entry: { id: 'entry-1', status: 'posted', voucher_number: 42 },
gain_or_loss: 10_000,
} as Awaited<ReturnType<typeof disposeAsset>>)
const { status, body } = await parseJsonResponse<{ data: { gain_or_loss: number } }>(
await POST(
createMockRequest('/api/assets/asset-1/dispose', { method: 'POST', body: validBody }),
routeParams,
),
)
expect(status).toBe(200)
expect(body.data.gain_or_loss).toBe(10_000)
expect(mockDisposeAsset).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
'asset-1',
validBody,
)
})
})