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:
co-authored by
Claude Fable 5
parent
1c9d378df8
commit
cb3ef45f14
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 }> }) => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user