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
parent 1c9d378df8
commit cb3ef45f14
22 changed files with 19660 additions and 1821 deletions
@@ -79,24 +79,20 @@ Regardless of whether the lease is capitalized in accounting.
Asset must be depreciated up to the disposal date before calculating gain/loss.
### Booking Pattern (Sale of Inventory)
### Booking Pattern
**Step 1 - Record sale with VAT:**
```
Debit 1510 Kundfordran [sale price incl. 25% VAT]
Credit 2611 Utgående moms 25% [VAT amount]
Credit 3973 Vinst avyttring [sale price ex VAT] (or Debit 7973 if loss)
```
Use one balanced entry after depreciation through the disposal date. The gain
or loss equals `försäljningspris ex moms bokfört restvärde`.
**Step 2 - Remove asset from books:**
```
Debit 1229 Ack. avskr. inventarier [accumulated depreciation]
Debit 7973 Förlust avyttring [remaining book value] (or Credit 3973 if already used above)
Credit 1221 Inventarier [original acquisition cost]
Debit 1510 Kundfordran [gross proceeds]
Debit 1229 Ack. avskr. inventarier [accumulated depreciation]
Debit 7973 Förlust vid avyttring [only for a loss]
Credit 1221 Inventarier [original acquisition cost]
Credit 2611 Utgående moms 25% [output VAT]
Credit 3973 Vinst vid avyttring [only for a gain]
```
**Alternative (net method):** Some systems use a combined entry. The gain/loss equals: `försäljningspris ex moms bokfört restvärde`.
### Gain/Loss Accounts
| Account | Direction | Description |
@@ -114,17 +110,15 @@ Asset: anskaffningsvärde 50,000 kr, ack. avskr. 10,000 kr (book value 40,000 kr
Sold for 56,000 kr ex VAT.
```
Debit 1510 70,000 (56,000 + 14,000 VAT)
Credit 2611 14,000 (25% moms)
Credit 3973 16,000 (gain: 56,000 40,000)
Debit 1510 70,000 (gross proceeds)
Debit 1229 10,000 (remove accumulated depreciation)
Credit 1221 50,000 (remove asset at cost)
Debit 7973 40,000 (book value to loss account)
Credit 2611 14,000 (25% output VAT)
Credit 3973 16,000 (gain: 56,000 40,000)
```
Net effect on income: 3973 16,000 credit + 7973 40,000 debit = 3973 16,000 gain (after netting with book value removal, the two 7973 entries cancel).
**Note:** Many Swedish systems handle this more cleanly by netting directly. The above shows the full debit/credit flow.
Debits and credits are both 80,000. The 16,000 credit is the complete income
statement effect of the disposal.
---
@@ -157,19 +151,30 @@ Credit 1221 [full amount]
### Exceptions
1. **Verksamhetsöverlåtelse (business transfer, ML 5 kap. 38 §):** No VAT when transferring entire business or independent branch.
2. **No original input VAT deduction:** No output VAT on sale if input VAT was never deducted.
2. **No original input VAT deduction (ML 10 kap. 37 §):** The exemption applies only when no part of the input VAT was deductible, including VAT on significant later additions to the asset.
3. **Real property (fastighet):** Generally VAT-exempt sales.
### Jämkning (VAT Adjustment Rules, ML 15 kap.)
Applies to capital goods where significant input VAT was deducted:
Applies to investment goods based on total original input VAT, whether or not
the full amount was deducted:
| Asset type | Correction period | Threshold (ingående moms) |
| Asset type | Adjustment period | Threshold (input VAT) |
|---|---|---|
| Byggnader | 10 years | ≥ 100,000 kr |
| Maskiner/inventarier | 5 years | ≥ 50,000 kr |
If a building is sold outside a business transfer, seller must repay remaining investment VAT in one lump sum for the rest of the correction period.
The acquisition or completion tax year counts as year 1. The disposal tax year
also counts. A one-time adjustment uses:
`original input VAT × change in deduction percentage × remaining years / total years`
No adjustment is made when the change is less than 5 percentage points. A
positive adjustment on a taxable sale of movable property is capped at 25% of
the sale price excluding VAT. In a qualifying business transfer under ML 5
kap. 38 §, the acquirer takes over the adjustment rights and obligations when
the statutory conditions are met. An adjustment document with the information
required by ML 15 kap. 28-31 §§ must be prepared and retained.
---
@@ -189,4 +194,4 @@ Disposal triggers reversal of prior värdeminskningsavdrag. Difference between s
### Restvärdeavskrivning
Sale proceeds reduce the skattemässigt restvärde directly.
Sale proceeds reduce the skattemässigt restvärde directly.
+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 2641 (återförd ingående moms) och debet
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,
)
})
})
+3 -247
View File
@@ -1,21 +1,13 @@
import { describe, it, expect, vi } from 'vitest'
import {
AssetCorrectionBlockedError,
DEFAULT_ACCOUNTS_BY_CATEGORY,
disposeAsset,
updateAsset,
} from '../assets/asset-service'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import type { Asset } from '@/types'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn().mockResolvedValue({
id: 'entry-1',
voucher_series: 'A',
voucher_number: 1,
}),
}))
describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
it('maps every AssetCategory to a BAS-aligned account triple', () => {
const expected = {
@@ -67,244 +59,6 @@ describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
})
})
describe('disposeAsset: gain/loss account selection', () => {
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'u',
company_id: 'co',
name: 'Test',
category: 'equipment',
acquisition_date: '2023-01-01',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
...overrides,
}
}
function makeSupabaseForDispose(asset: Asset, schedules: Array<{ planned_depreciation: number }>) {
// Three from() calls happen inside disposeAsset:
// 1. getAsset (.maybeSingle on 'assets')
// 2. sumPostedDepreciation (.then on 'depreciation_schedules': server-derived
// accumulated_depreciation; replaces the previously client-supplied value)
// 3. update (.single on 'assets', returning the disposed row)
const builders = {
getBuilder: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: asset, error: null }),
},
schedulesBuilder: (() => {
const b: Record<string, unknown> = {
select: vi.fn(),
eq: vi.fn(),
not: vi.fn(),
then: undefined,
}
;(b.select as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.eq as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.not as ReturnType<typeof vi.fn>).mockReturnValue(b)
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) =>
resolve({ data: schedules, error: null })
return b as { select: ReturnType<typeof vi.fn>; eq: ReturnType<typeof vi.fn>; not: ReturnType<typeof vi.fn> }
})(),
updateBuilder: {
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { ...asset, disposed_at: '2025-06-30', disposed_proceeds: 50_000 },
error: null,
}),
},
}
let calls = 0
const supabase = {
from: vi.fn((table: string) => {
calls++
if (table === 'depreciation_schedules') return builders.schedulesBuilder
return calls === 1 ? builders.getBuilder : builders.updateBuilder
}),
}
return { supabase, builders } as const
}
it('uses 3973 / 7973 for tangible asset disposal (equipment)', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment' })
// Two prior posted schedules summing to 40_000 → NBV = 60_000, proceeds 80_000 → gain 20_000
const { supabase } = makeSupabaseForDispose(asset, [
{ planned_depreciation: 20_000 },
{ planned_depreciation: 20_000 },
])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 80_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// Server-derived accumulated debits 1229
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(40_000)
// Gain goes to 3973 (tangible), not 3013
expect(lines.find((l) => l.account_number === '3973')).toBeDefined()
expect(lines.find((l) => l.account_number === '3013')).toBeUndefined()
})
it('uses 3013 / 7813 for immaterial asset disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'immaterial',
bas_asset_account: '1010',
bas_accumulated_account: '1019',
bas_expense_account: '7810',
})
// NBV = 50_000, proceeds 10_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 50_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number }[] }).lines
expect(lines.find((l) => l.account_number === '7813')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('uses 3971 / 7971 for building disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'building',
acquisition_cost: 2_000_000,
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
})
// NBV = 1_500_000, proceeds 2_000_000 → gain 500_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 500_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 2_000_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Buildings route to 3971/7971, not 3973/7973
expect(lines.find((l) => l.account_number === '3971')).toBeDefined()
expect(lines.find((l) => l.account_number === '3973')).toBeUndefined()
})
it('uses 3971 / 7971 for land_improvement disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'land_improvement',
acquisition_cost: 100_000,
bas_asset_account: '1150',
bas_accumulated_account: '1159',
bas_expense_account: '7824',
})
// NBV = 80_000, proceeds 40_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 20_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 40_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Markanläggning routes to 7971 like buildings
expect(lines.find((l) => l.account_number === '7971')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('server-derives accumulated_depreciation: caller cannot inflate gain', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment', acquisition_cost: 100_000 })
// Real accumulated = 30_000 from one posted schedule. A malicious client
// could previously pass accumulated_depreciation: 100_000 to fake a fully
// depreciated asset and pocket a 50_000 phantom gain on proceeds. With
// server derivation, the lines reflect the actual 30_000.
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 30_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 50_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// accumulated debit must be 30_000 (server-derived), not anything else
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(30_000)
// NBV = 100_000 30_000 = 70_000, proceeds 50_000 → loss 20_000 to 7973
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(20_000)
})
})
describe('updateAsset: acquisition-basis correction guard', () => {
function makeAssetRow(overrides: Partial<Asset> = {}): Asset {
return {
@@ -507,3 +261,5 @@ describe('updateAsset: acquisition-basis correction guard', () => {
expect(captured.update).toMatchObject({ acquisition_date: '2025-08-15' })
})
})
@@ -0,0 +1,228 @@
import { describe, expect, it } from 'vitest'
import { buildAssetDisposalPlan, type DisposeAssetInput } from '../asset-service'
import type { Asset, AssetCategory } from '@/types'
const PERIOD = { id: 'period-2026', period_start: '2026-01-01', period_end: '2026-12-31' }
const PERIODS = [
{ id: 'period-2025', period_start: '2025-01-01' },
{ id: 'period-2026', period_start: '2026-01-01' },
{ id: 'period-2027', period_start: '2027-01-01' },
]
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Machine',
category: 'equipment',
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
}
}
function makeInput(overrides: Partial<DisposeAssetInput> = {}): DisposeAssetInput {
return {
disposal_type: 'sale',
disposed_at: '2026-06-30',
disposed_proceeds: 100_000,
proceeds_account: '1930',
fiscal_period_id: PERIOD.id,
vat_treatment: 'standard_25',
...overrides,
}
}
function build(overrides: {
asset?: Partial<Asset>
input?: Partial<DisposeAssetInput>
schedules?: Array<{
fiscal_period_id: string
planned_depreciation: number
journal_entry_id: string | null
}>
} = {}) {
return buildAssetDisposalPlan({
asset: makeAsset(overrides.asset),
input: makeInput(overrides.input),
fiscalPeriod: PERIOD,
periods: PERIODS,
schedules: overrides.schedules ?? [
{
fiscal_period_id: 'period-2025',
planned_depreciation: 20_000,
journal_entry_id: 'entry-2025',
},
],
})
}
describe('buildAssetDisposalPlan', () => {
it('books depreciation through the disposal date before removing the asset', () => {
const plan = build()
expect(plan.currentDepreciation).toBe(9_918)
expect(plan.accumulatedDepreciation).toBe(29_918)
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '7832', debit_amount: 9_918 }),
expect.objectContaining({ account_number: '1229', credit_amount: 9_918 }),
expect.objectContaining({ account_number: '1229', debit_amount: 29_918 }),
expect.objectContaining({ account_number: '1220', credit_amount: 100_000 }),
]),
)
})
it('derives 25 percent output VAT from gross proceeds on the server', () => {
const plan = build({ input: { disposed_proceeds: 125_000 } })
expect(plan.proceedsVat).toBe(25_000)
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '1930', debit_amount: 125_000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 25_000 }),
]),
)
})
it.each<[AssetCategory, string, string]>([
['immaterial', '3971', '7971'],
['building', '3972', '7972'],
['land_improvement', '3972', '7972'],
['equipment', '3973', '7973'],
])('uses the BAS disposal pair for %s', (category, gainAccount, lossAccount) => {
const accountOverrides =
category === 'immaterial'
? { bas_asset_account: '1010', bas_accumulated_account: '1019', bas_expense_account: '7810' }
: category === 'building' || category === 'land_improvement'
? { bas_asset_account: '1110', bas_accumulated_account: '1119', bas_expense_account: '7821' }
: {}
const gain = build({
asset: { category, acquisition_cost: 20_000, ...accountOverrides },
input: { disposed_proceeds: 125_000 },
schedules: [],
})
const loss = build({
asset: { category, acquisition_cost: 100_000, ...accountOverrides },
input: { disposal_type: 'scrap', disposed_proceeds: 0, vat_treatment: undefined },
schedules: [],
})
expect(gain.lines.some((line) => line.account_number === gainAccount)).toBe(true)
expect(loss.lines.some((line) => line.account_number === lossAccount)).toBe(true)
})
it('fully clears a fully depreciated asset on scrapping', () => {
const plan = build({
asset: { acquisition_date: '2021-01-01' },
input: { disposal_type: 'scrap', disposed_proceeds: 0, vat_treatment: undefined },
schedules: [
{ fiscal_period_id: 'period-2025', planned_depreciation: 100_000, journal_entry_id: 'entry' },
],
})
expect(plan.gainOrLoss).toBe(0)
expect(plan.lines).toEqual([
expect.objectContaining({ account_number: '1229', debit_amount: 100_000 }),
expect.objectContaining({ account_number: '1220', credit_amount: 100_000 }),
])
})
it('refuses disposal when a later period already has posted depreciation', () => {
expect(() =>
build({
schedules: [
{ fiscal_period_id: 'period-2027', planned_depreciation: 20_000, journal_entry_id: 'entry' },
],
}),
).toThrow('later_depreciation_posted')
})
it('refuses to overwrite a mismatched posted current-period schedule', () => {
expect(() =>
build({
schedules: [
{ fiscal_period_id: 'period-2026', planned_depreciation: 20_000, journal_entry_id: 'entry' },
],
}),
).toThrow('current_depreciation_mismatch')
})
it('books negative VAT adjustment to 6999 and 2641', () => {
const plan = build({
asset: { acquisition_cost: 300_000 },
input: {
vat_treatment: 'exempt',
jamkning_original_input_vat: 75_000,
jamkning_original_deduction_percent: 100,
},
})
expect(plan.jamkning).toMatchObject({ direction: 'decrease', amount: 60_000 })
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '6999', debit_amount: 60_000 }),
expect.objectContaining({ account_number: '2641', credit_amount: 60_000 }),
]),
)
})
it('requires explicit ML 5:38 confirmation for a business transfer', () => {
expect(() =>
build({
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
},
}),
).toThrow('ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED')
})
it('requires an adjustment document when an investment-good obligation transfers', () => {
expect(() =>
build({
asset: { acquisition_cost: 300_000 },
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
business_transfer_confirmed: true,
jamkning_original_input_vat: 50_000,
jamkning_original_deduction_percent: 100,
},
}),
).toThrow('ASSET_ADJUSTMENT_DOCUMENT_REQUIRED')
const plan = build({
asset: { acquisition_cost: 300_000 },
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
business_transfer_confirmed: true,
adjustment_document_confirmed: true,
jamkning_original_input_vat: 50_000,
jamkning_original_deduction_percent: 100,
},
})
expect(plan.jamkning.direction).toBe('transferred')
})
})
@@ -0,0 +1,163 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
describe('commit_asset_disposal (pg-real)', () => {
async function insertAsset(userId: string, companyId: string): Promise<string> {
const assetId = randomUUID()
await getPool().query(
`INSERT INTO public.assets (
id, user_id, company_id, name, category, acquisition_date,
acquisition_cost, salvage_value, useful_life_months,
depreciation_method, bas_asset_account, bas_accumulated_account,
bas_expense_account
) VALUES ($1, $2, $3, 'Machine', 'equipment', '2025-01-01',
100000, 0, 60, 'linear', '1220', '1229', '7832')`,
[assetId, userId, companyId],
)
return assetId
}
async function insertDraft(args: {
userId: string
companyId: string
fiscalPeriodId: string
debitLines: Array<[string, number]>
creditLines: Array<[string, number]>
}): Promise<string> {
const entryId = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries (
id, user_id, company_id, fiscal_period_id, voucher_number,
voucher_series, entry_date, description, source_type, status
) VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-30',
'Asset disposal', 'system', 'draft')`,
[entryId, args.userId, args.companyId, args.fiscalPeriodId],
)
for (const [account, amount] of args.debitLines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0)`,
[entryId, account, amount],
)
}
for (const [account, amount] of args.creditLines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, 0, $3)`,
[entryId, account, amount],
)
}
return entryId
}
async function commit(args: {
companyId: string
assetId: string
entryId: string
fiscalPeriodId: string
disposalType?: string
currentDepreciation?: number
}) {
return getPool().query(
`SELECT * FROM public.commit_asset_disposal(
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::text,
'2026-06-30'::date, 80000::numeric, 0::numeric, 'exempt'::text,
$6::numeric, 0::numeric, 'none'::text, 4::integer, 5::integer,
0::numeric, 0::numeric, 0::numeric, NULL::text, NULL::text
)`,
[
args.companyId,
args.assetId,
args.entryId,
args.fiscalPeriodId,
args.disposalType ?? 'sale',
args.currentDepreciation ?? 0,
],
)
}
it('posts the voucher, schedule, and register state in one transaction', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const assetId = await insertAsset(userId, companyId)
const entryId = await insertDraft({
userId,
companyId,
fiscalPeriodId,
debitLines: [['7832', 10_000], ['1229', 30_000], ['1930', 80_000]],
creditLines: [['1229', 10_000], ['1220', 100_000], ['3973', 10_000]],
})
await commit({ companyId, assetId, entryId, fiscalPeriodId, currentDepreciation: 10_000 })
const entry = await getPool().query(
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
[entryId],
)
const asset = await getPool().query(
`SELECT disposed_at::text, disposal_type, disposal_journal_entry_id
FROM public.assets WHERE id = $1`,
[assetId],
)
const schedule = await getPool().query(
`SELECT planned_depreciation::numeric, journal_entry_id
FROM public.depreciation_schedules
WHERE asset_id = $1 AND fiscal_period_id = $2`,
[assetId, fiscalPeriodId],
)
expect(entry.rows[0]).toMatchObject({ status: 'posted' })
expect(entry.rows[0].voucher_number).toBeGreaterThan(0)
expect(asset.rows[0]).toMatchObject({
disposed_at: '2026-06-30',
disposal_type: 'sale',
disposal_journal_entry_id: entryId,
})
expect(Number(schedule.rows[0].planned_depreciation)).toBe(10_000)
expect(schedule.rows[0].journal_entry_id).toBe(entryId)
await expect(
getPool().query(`UPDATE public.assets SET disposed_proceeds = 1 WHERE id = $1`, [assetId]),
).rejects.toThrow(/Cannot modify financial or disposal attributes/)
await expect(
getPool().query(`UPDATE public.assets SET notes = 'Audit note' WHERE id = $1`, [assetId]),
).resolves.toBeDefined()
})
it('rolls the voucher commit back when the register update fails', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const assetId = await insertAsset(userId, companyId)
const entryId = await insertDraft({
userId,
companyId,
fiscalPeriodId,
debitLines: [['1930', 80_000], ['7973', 20_000]],
creditLines: [['1220', 100_000]],
})
await expect(
commit({
companyId,
assetId,
entryId,
fiscalPeriodId,
disposalType: 'invalid',
}),
).rejects.toThrow()
const entry = await getPool().query(
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
[entryId],
)
const asset = await getPool().query(
`SELECT disposed_at FROM public.assets WHERE id = $1`,
[assetId],
)
expect(entry.rows[0]).toMatchObject({ status: 'draft', voucher_number: 0 })
expect(asset.rows[0].disposed_at).toBeNull()
})
})
@@ -1,436 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { disposeAsset } from '../asset-service'
import type { Asset } from '@/types'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn().mockResolvedValue({
id: 'entry-1',
voucher_series: 'A',
voucher_number: 1,
}),
}))
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'u',
company_id: 'co',
name: 'Test',
category: 'equipment',
acquisition_date: '2023-01-01',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
...overrides,
}
}
interface CapturedLine {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}
function makeSupabaseForDispose(asset: Asset, schedules: Array<{ planned_depreciation: number }>) {
const builders = {
getBuilder: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: asset, error: null }),
},
schedulesBuilder: (() => {
const b: Record<string, unknown> = {
select: vi.fn(),
eq: vi.fn(),
not: vi.fn(),
then: undefined,
}
;(b.select as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.eq as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.not as ReturnType<typeof vi.fn>).mockReturnValue(b)
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) =>
resolve({ data: schedules, error: null })
return b as {
select: ReturnType<typeof vi.fn>
eq: ReturnType<typeof vi.fn>
not: ReturnType<typeof vi.fn>
}
})(),
updateBuilder: {
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { ...asset, disposed_at: '2026-05-26' },
error: null,
}),
},
}
let calls = 0
const supabase = {
from: vi.fn((table: string) => {
calls++
if (table === 'depreciation_schedules') return builders.schedulesBuilder
return calls === 1 ? builders.getBuilder : builders.updateBuilder
}),
}
return { supabase, builders } as const
}
async function captureLines(asset: Asset, schedules: Array<{ planned_depreciation: number }>, input: Parameters<typeof disposeAsset>[4]): Promise<{ lines: CapturedLine[]; updateArgs: Record<string, unknown> }> {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const { supabase, builders } = makeSupabaseForDispose(asset, schedules)
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
input,
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: CapturedLine[] }).lines
const updateArgs = (builders.updateBuilder.update.mock.calls[0]?.[0] ?? {}) as Record<string, unknown>
return { lines, updateArgs }
}
function sumDebit(lines: CapturedLine[]): number {
return Math.round(lines.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100
}
function sumCredit(lines: CapturedLine[]): number {
return Math.round(lines.reduce((s, l) => s + l.credit_amount, 0) * 100) / 100
}
describe('disposeAsset: VAT on proceeds', () => {
it('standard_25 sale appends a 2611 credit and balances', async () => {
// Acquisition 100 000, accumulated 40 000 → NBV 60 000.
// Gross proceeds 100 000 → net 80 000 → vat 20 000 → gain 20 000.
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(20_000)
// Gain on NET proceeds, not gross: 80 000 net 60 000 NBV = 20 000 gain
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
// No loss line on a gain scenario
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
// Bank debit = gross
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(100_000)
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('reduced_12 sale uses BAS 2621', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 100_000 - 100_000 / 1.12,
vat_treatment: 'reduced_12',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '2621')).toBeDefined()
expect(lines.find((l) => l.account_number === '2611')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('reduced_6 sale uses BAS 2631', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 100_000 - 100_000 / 1.06,
vat_treatment: 'reduced_6',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '2631')).toBeDefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('reverse_charge sale posts NO VAT line', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 80_000,
proceeds_vat: 0,
vat_treatment: 'reverse_charge',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number.startsWith('261'))).toBeUndefined()
// The full proceeds counts as net (no VAT taken out) → gain = 80 000 60 000 = 20 000
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('export sale posts NO VAT line', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 80_000,
proceeds_vat: 0,
vat_treatment: 'export',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number.startsWith('26'))).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('throws when proceeds_vat > 0 but no vat_treatment is supplied', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const { supabase } = makeSupabaseForDispose(makeAsset(), [{ planned_depreciation: 40_000 }])
await expect(
disposeAsset(supabase as unknown as Parameters<typeof disposeAsset>[0], 'co', 'u', 'asset-1', {
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
fiscal_period_id: 'fp',
}),
).rejects.toThrow(/vat_treatment/)
})
it('throws when reverse_charge is selected but proceeds_vat > 0', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const { supabase } = makeSupabaseForDispose(makeAsset(), [{ planned_depreciation: 40_000 }])
await expect(
disposeAsset(supabase as unknown as Parameters<typeof disposeAsset>[0], 'co', 'u', 'asset-1', {
disposed_at: '2026-05-26',
disposed_proceeds: 80_000,
proceeds_vat: 5_000,
vat_treatment: 'reverse_charge',
fiscal_period_id: 'fp',
}),
).rejects.toThrow(/reverse_charge/)
})
it('zero-VAT sale (no fields passed) still posts a balanced entry', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 50_000,
fiscal_period_id: 'fp',
},
)
// No VAT line
expect(lines.find((l) => l.account_number.startsWith('26'))).toBeUndefined()
// Loss on 50 000 60 000 = -10 000 → 7973 debit
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(10_000)
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
})
describe('disposeAsset: jämkning (input VAT correction)', () => {
it('credits 2641 and debits 6991 for the jämkning amount', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 60_000,
fiscal_period_id: 'fp',
// 5-year asset sold after 3 years; 24 months remain × 20 000 VAT × 24/60 = 8 000
jamkning_amount: 8_000,
jamkning_remaining_months: 24,
jamkning_total_months: 60,
jamkning_original_input_vat: 20_000,
},
)
// 2641 credit (reverses prior input VAT deduction)
expect(lines.find((l) => l.account_number === '2641')?.credit_amount).toBe(8_000)
// Jämkning is a VAT correction (ML 8a kap), not a disposal loss: it must
// route to 6991 "Övriga externa kostnader, avdragsgilla", NOT to 78xx.
expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(8_000)
// No 78xx line: proceeds 60 000 = NBV 60 000 means no gain/loss, and the
// jämkning explicitly does not contaminate the disposal-loss accounts.
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('combined VAT + jämkning + gain stays balanced', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 100_000, // gross
proceeds_vat: 20_000, // 25%
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
jamkning_amount: 8_000,
jamkning_remaining_months: 24,
jamkning_total_months: 60,
jamkning_original_input_vat: 20_000,
},
)
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(20_000)
expect(lines.find((l) => l.account_number === '2641')?.credit_amount).toBe(8_000)
// Gain 20 000 on net proceeds → 3973 credit; jämkning 8 000 → 6991 debit
// (NOT 7973: see ML 8a kap, jämkning is a VAT correction not a loss).
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(8_000)
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('zero jämkning amount produces no extra lines', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 60_000,
fiscal_period_id: 'fp',
jamkning_amount: 0,
},
)
expect(lines.find((l) => l.account_number === '2641')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('routes jämkning to 6991 even for building category (never to 78xx)', async () => {
const asset = makeAsset({
category: 'building',
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
acquisition_cost: 2_000_000,
})
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 200_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 1_800_000,
fiscal_period_id: 'fp',
// 10-year fastighet, 60 months remaining out of 120, original 200 000 → 100 000
jamkning_amount: 100_000,
jamkning_remaining_months: 60,
jamkning_total_months: 120,
jamkning_original_input_vat: 200_000,
},
)
// Jämkning goes to 6991 regardless of asset category: it's a VAT
// correction per ML 8a kap, NOT a förlust vid avyttring (78xx).
expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(100_000)
// The disposal itself is at a loss (NBV 1.8M = proceeds 1.8M? Let's check:
// acq 2.0M ack 0.2M = NBV 1.8M, proceeds 1.8M → no gain/loss line). The
// only debit-side cost line is the 6991 jämkning entry.
expect(lines.find((l) => l.account_number === '7971')).toBeUndefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('persists jämkning + VAT audit metadata on the asset row', async () => {
const asset = makeAsset()
const { updateArgs } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
jamkning_amount: 8_000,
jamkning_remaining_months: 24,
jamkning_total_months: 60,
jamkning_original_input_vat: 20_000,
},
)
expect(updateArgs.disposed_proceeds).toBe(100_000)
expect(updateArgs.disposed_proceeds_vat).toBe(20_000)
expect(updateArgs.disposed_vat_treatment).toBe('standard_25')
expect(updateArgs.jamkning_amount).toBe(8_000)
expect(updateArgs.jamkning_remaining_months).toBe(24)
expect(updateArgs.jamkning_total_months).toBe(60)
expect(updateArgs.jamkning_original_input_vat).toBe(20_000)
})
})
describe('disposeAsset: gain vs loss with VAT', () => {
it('gain scenario: net proceeds > NBV → 3973 credit', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 50_000 }],
{
disposed_at: '2026-05-26',
// NBV = 50 000, net proceeds = 80 000 → gain 30 000
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(30_000)
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('loss scenario: net proceeds < NBV → 7973 debit', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 20_000 }],
{
disposed_at: '2026-05-26',
// NBV = 80 000, net proceeds = 40 000 → loss 40 000
disposed_proceeds: 50_000,
proceeds_vat: 10_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(40_000)
expect(lines.find((l) => l.account_number === '3973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
})
+110 -199
View File
@@ -1,205 +1,116 @@
import { describe, it, expect } from 'vitest'
import {
computeJamkningAmount,
assessJamkningEligibility,
} from '../jamkning'
describe('computeJamkningAmount', () => {
it('5-year asset sold after 3 years (24 months remaining, 20 000 kr input VAT) → 8 000 kr', () => {
// ML 8a kap 7 §: (24 / 60) × 20 000 = 8 000
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 24,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(8_000)
})
it('10-year fastighet sold after 7 years (36 months remaining, 200 000 kr input VAT) → 60 000 kr', () => {
// ML 8a kap 7 §: (36 / 120) × 200 000 = 60 000
const amount = computeJamkningAmount({
originalInputVat: 200_000,
totalCorrectionMonths: 120,
remainingMonths: 36,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(60_000)
})
it('sold after the correction period (0 remaining) → 0', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 0,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
})
it('sold immediately (60 months remaining on 60-month period) → full originalInputVat', () => {
// (60 / 60) × 20 000 = 20 000: the full deduction must be reversed
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 60,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(20_000)
})
it('returns 0 when disposalEvent is no_jamkning', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 24,
disposalEvent: 'no_jamkning',
})
expect(amount).toBe(0)
})
it('caps remaining months at totalCorrectionMonths (defensive)', () => {
// A caller bug could pass remainingMonths > totalCorrectionMonths.
// Cap at the total so the answer never exceeds originalInputVat.
const amount = computeJamkningAmount({
originalInputVat: 10_000,
totalCorrectionMonths: 60,
remainingMonths: 120,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(10_000)
})
it('handles negligible cost (zero originalInputVat) → 0 without NaN', () => {
const amount = computeJamkningAmount({
originalInputVat: 0,
totalCorrectionMonths: 60,
remainingMonths: 24,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
expect(Number.isNaN(amount)).toBe(false)
})
it('returns 0 when totalCorrectionMonths is 0 (avoid divide-by-zero)', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 0,
remainingMonths: 0,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
expect(Number.isFinite(amount)).toBe(true)
})
it('returns 0 when totalCorrectionMonths is negative (defensive)', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: -60,
remainingMonths: -24,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
expect(Number.isFinite(amount)).toBe(true)
})
it('rounds to two decimals (no öre stray cents)', () => {
// (17 / 60) × 10 000 = 2833.333... → 2833.33
const amount = computeJamkningAmount({
originalInputVat: 10_000,
totalCorrectionMonths: 60,
remainingMonths: 17,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(2_833.33)
})
})
import { describe, expect, it } from 'vitest'
import { assessJamkning, assessJamkningEligibility } from '../jamkning'
describe('assessJamkningEligibility', () => {
it('returns 120 months for fastighet BAS 1110', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1110',
basExpenseAccount: '7821',
category: 'building',
acquisitionDate: '2020-01-01',
disposalDate: '2026-01-01',
})
expect(e.totalCorrectionMonths).toBe(120)
// 6 years = 72 months elapsed → 48 months remaining
expect(e.elapsedMonths).toBe(72)
expect(e.remainingMonths).toBe(48)
expect(e.withinCorrectionPeriod).toBe(true)
it('counts the acquisition and disposal tax years for movable property', () => {
expect(
assessJamkningEligibility({
acquisitionDate: '2023-11-30',
disposalDate: '2026-01-02',
category: 'equipment',
}),
).toMatchObject({ totalYears: 5, elapsedYears: 3, remainingYears: 2 })
})
it('returns 60 months for equipment BAS 1220', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1220',
basExpenseAccount: '7832',
category: 'equipment',
acquisitionDate: '2024-01-01',
disposalDate: '2026-01-01',
})
expect(e.totalCorrectionMonths).toBe(60)
// 2 years = 24 months → 36 months remaining
expect(e.elapsedMonths).toBe(24)
expect(e.remainingMonths).toBe(36)
expect(e.withinCorrectionPeriod).toBe(true)
})
it('detects markanläggning (BAS 1150) as real property → 120 months', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1150',
basExpenseAccount: '7824',
category: 'land_improvement',
acquisitionDate: '2023-06-01',
disposalDate: '2024-06-01',
})
expect(e.totalCorrectionMonths).toBe(120)
})
it('falls back to category when account is unrecognized', () => {
// No BAS account provided: has to rely on the category signal.
const e = assessJamkningEligibility({
category: 'building',
acquisitionDate: '2024-01-01',
disposalDate: '2026-01-01',
})
expect(e.totalCorrectionMonths).toBe(120)
})
it('reports withinCorrectionPeriod = false after the full period elapses', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1220',
category: 'equipment',
acquisitionDate: '2020-01-01',
disposalDate: '2026-01-01',
})
// 6 years = 72 months elapsed > 60 → 0 remaining
expect(e.remainingMonths).toBe(0)
expect(e.withinCorrectionPeriod).toBe(false)
})
it('counts complete months only (day-precision)', () => {
// 2023-01-15 to 2026-01-14 → 35 complete months (the 36th hasn't finished)
const e = assessJamkningEligibility({
basAssetAccount: '1220',
category: 'equipment',
acquisitionDate: '2023-01-15',
disposalDate: '2026-01-14',
})
expect(e.elapsedMonths).toBe(35)
expect(e.remainingMonths).toBe(25)
})
it('clamps elapsedMonths to 0 if disposalDate precedes acquisitionDate', () => {
// Defensive: should never happen in practice but must not blow up.
const e = assessJamkningEligibility({
basAssetAccount: '1220',
category: 'equipment',
acquisitionDate: '2026-01-01',
disposalDate: '2024-01-01',
})
expect(e.elapsedMonths).toBe(0)
expect(e.remainingMonths).toBe(60)
it('uses ten years and the higher threshold for real property', () => {
expect(
assessJamkningEligibility({
acquisitionDate: '2023-01-01',
disposalDate: '2026-12-31',
basAssetAccount: '1110',
}),
).toMatchObject({ totalYears: 10, remainingYears: 7, threshold: 100_000 })
})
})
describe('assessJamkning', () => {
it('calculates a positive adjustment from total original VAT', () => {
const result = assessJamkning({
acquisitionDate: '2023-01-01',
disposalDate: '2025-06-30',
category: 'equipment',
originalInputVat: 100_000,
originalDeductionPercent: 40,
disposalType: 'sale',
vatTreatment: 'standard_25',
netProceeds: 1_000_000,
})
expect(result).toMatchObject({
direction: 'increase',
remainingYears: 3,
amount: 36_000,
capped: false,
})
})
it('caps positive movable-property adjustment at 25 percent of net proceeds', () => {
const result = assessJamkning({
acquisitionDate: '2025-01-01',
disposalDate: '2025-12-31',
category: 'equipment',
originalInputVat: 100_000,
originalDeductionPercent: 0,
disposalType: 'sale',
vatTreatment: 'standard_25',
netProceeds: 40_000,
})
expect(result).toMatchObject({ direction: 'increase', amount: 10_000, capped: true })
})
it('calculates a negative adjustment for an exempt sale', () => {
const result = assessJamkning({
acquisitionDate: '2024-01-01',
disposalDate: '2026-01-01',
category: 'machinery',
originalInputVat: 75_000,
originalDeductionPercent: 100,
disposalType: 'sale',
vatTreatment: 'exempt',
netProceeds: 200_000,
})
expect(result).toMatchObject({ direction: 'decrease', remainingYears: 3, amount: 45_000 })
})
it('does not adjust below the investment-good threshold', () => {
expect(
assessJamkning({
acquisitionDate: '2026-01-01',
disposalDate: '2026-06-30',
category: 'equipment',
originalInputVat: 49_999,
originalDeductionPercent: 100,
disposalType: 'sale',
vatTreatment: 'exempt',
}),
).toMatchObject({ direction: 'none', amount: 0, reason: 'below_threshold' })
})
it('transfers the obligation in a qualifying business transfer', () => {
expect(
assessJamkning({
acquisitionDate: '2026-01-01',
disposalDate: '2026-06-30',
category: 'equipment',
originalInputVat: 50_000,
originalDeductionPercent: 100,
disposalType: 'business_transfer',
}),
).toMatchObject({ direction: 'transferred', amount: 0, reason: 'transferred' })
})
it('does not adjust a scrapped asset', () => {
expect(
assessJamkning({
acquisitionDate: '2026-01-01',
disposalDate: '2026-06-30',
category: 'equipment',
originalInputVat: 50_000,
originalDeductionPercent: 100,
disposalType: 'scrap',
}),
).toMatchObject({ direction: 'none', amount: 0, reason: 'scrap' })
})
})
+374 -269
View File
@@ -1,11 +1,18 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
commitAssetDisposal,
createDraftEntry,
} from '@/lib/bookkeeping/engine'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { computeAnnualDepreciation } from './depreciation-engine'
import { assessJamkning, assessJamkningEligibility } from './jamkning'
import type {
Asset,
AssetCategory,
AssetDisposalType,
DepreciationMethod,
FiscalPeriod,
K3Component,
CreateJournalEntryLineInput,
JournalEntry,
@@ -477,54 +484,23 @@ function inBasRange(account: string, range: [string, string]): boolean {
return account >= range[0] && account <= range[1]
}
export type AssetDisposalVatTreatment = Exclude<VatTreatment, 'reduced_12' | 'reduced_6'>
export interface DisposeAssetInput {
/** ISO date of disposal: typically the day of sale or scrapping. */
disposal_type: AssetDisposalType
disposed_at: string
/** Cash / receivable received for the asset, INCLUDING VAT when applicable.
* Zero for scrapping. */
/** Gross consideration, including VAT for a taxable sale. */
disposed_proceeds: number
/** Optional override for the bank/receivable account credited with the
* proceeds. Defaults to 1930 (företagskonto). */
proceeds_account?: string
/** Fiscal period the disposal entry lands in. Caller resolves this from
* disposed_at: we don't auto-derive to keep the period-lock check at
* the route layer. */
fiscal_period_id: string
/**
* Output VAT on the proceeds (ML 3 kap 3 § / 7 kap 3 §). When > 0, a
* credit on the matching 26xx account is appended to the journal entry,
* and `disposed_proceeds` is treated as the GROSS amount (incl. VAT).
* The net (proceeds vat) is what gets compared to NBV to compute
* gain/loss. Defaults to 0 (sale was momsfri / outside scope).
*/
proceeds_vat?: number
/**
* Treatment for `proceeds_vat`. Required when `proceeds_vat > 0` because
* the engine needs to know which 26xx account to credit:
* - standard_25 → 2611
* - reduced_12 → 2621
* - reduced_6 → 2631
* - reverse_charge / export / exempt → no VAT line (treated as
* informational; proceeds_vat must be 0 in those cases)
*/
vat_treatment?: VatTreatment
/**
* Jämkning amount per ML 8a kap 7 §: when the disposal happens inside
* the korrigeringstid, the originally-deducted input VAT must be
* partially paid back. The caller computes this via
* computeJamkningAmount() (lib/bokslut/assets/jamkning.ts) and passes
* the result here. A positive value means "pay back to the state" and
* is booked as a CREDIT to 2641 (reverses the original input-VAT
* deduction) with an offsetting debit on the asset's gain/loss account.
* Zero / undefined = no jämkning line.
*/
jamkning_amount?: number
/** Audit metadata: remaining months in korrigeringstid at disposal date. */
jamkning_remaining_months?: number
/** Audit metadata: total korrigeringstid (60 or 120 months). */
jamkning_total_months?: number
/** Audit metadata: original input VAT deducted at acquisition. */
vat_treatment?: AssetDisposalVatTreatment
/** Total original input VAT, whether or not it was fully deducted. */
jamkning_original_input_vat?: number
jamkning_original_deduction_percent?: number
/** Confirms that the transaction qualifies under ML 5:38. */
business_transfer_confirmed?: boolean
/** Confirms that the required adjustment document is handled at transfer. */
adjustment_document_confirmed?: boolean
}
export interface DisposalResult {
@@ -535,29 +511,265 @@ export interface DisposalResult {
gain_or_loss: number
}
/**
* Dispose of an asset. Posts a journal entry that:
* - Debit accumulated depreciation (to zero out the asset's accumulated
* account)
* - Credit acquisition cost (to zero out the asset's anskaffning account)
* - Debit proceeds account (bank / receivable) for sale price (gross,
* incl VAT)
* - Credit 26xx (output VAT) when the sale is momspliktig (standard_25 →
* 2611, reduced_12 → 2621, reduced_6 → 2631)
* - Credit 2641 + Debit loss account for the jämkning amount when the
* disposal happens inside the korrigeringstid (ML 8a kap 4-7 §§)
* - Debit 78xx (loss on sale) OR Credit 30xx (gain on sale) for the
* net gain / loss vs NBV: accounts branch on category (3013/7813
* for immaterial, 3971/7971 for building / markanläggning, 3973/7973
* for everything else).
*
* Gain/loss is computed on the NET proceeds (excl VAT), since VAT is a
* pass-through to Skatteverket and does not affect resultaträkningen.
*
* After posting, marks the asset row with disposed_at, disposed_proceeds,
* disposed_proceeds_vat, disposed_vat_treatment, and jämkning audit fields.
* The DB trigger then prevents further edits to financial fields.
*/
export class AssetNotFoundError extends Error {
readonly code = 'ASSET_NOT_FOUND'
}
export class AssetAlreadyDisposedError extends Error {
readonly code = 'ASSET_ALREADY_DISPOSED'
}
export class AssetDisposalBlockedError extends Error {
readonly code = 'ASSET_DISPOSAL_BLOCKED'
constructor(readonly reason: 'later_depreciation_posted' | 'current_depreciation_mismatch') {
super(reason)
}
}
export class AssetJamkningDataRequiredError extends Error {
readonly code = 'ASSET_JAMKNING_DATA_REQUIRED'
}
export class AssetAdjustmentDocumentRequiredError extends Error {
readonly code = 'ASSET_ADJUSTMENT_DOCUMENT_REQUIRED'
constructor() {
super('ASSET_ADJUSTMENT_DOCUMENT_REQUIRED')
}
}
export class AssetBusinessTransferConfirmationRequiredError extends Error {
readonly code = 'ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED'
constructor() {
super('ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED')
}
}
interface DisposalScheduleRow {
fiscal_period_id: string
planned_depreciation: number | string
journal_entry_id: string | null
}
interface DisposalPlan {
lines: CreateJournalEntryLineInput[]
currentDepreciation: number
accumulatedDepreciation: number
proceedsGross: number
proceedsVat: number
vatTreatment: AssetDisposalVatTreatment | null
gainOrLoss: number
jamkning: ReturnType<typeof assessJamkning>
}
export function buildAssetDisposalPlan(args: {
asset: Asset
input: DisposeAssetInput
fiscalPeriod: Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>
periods: Array<Pick<FiscalPeriod, 'id' | 'period_start'>>
schedules: DisposalScheduleRow[]
}): DisposalPlan {
const { asset, input, fiscalPeriod, periods, schedules } = args
if (input.disposed_at < asset.acquisition_date) {
throw new Error('Avyttringsdatum kan inte vara före anskaffningsdatum.')
}
const periodStartById = new Map(periods.map((period) => [period.id, period.period_start]))
const posted = schedules.filter((schedule) => schedule.journal_entry_id !== null)
const laterPosted = posted.some(
(schedule) => (periodStartById.get(schedule.fiscal_period_id) ?? '') > fiscalPeriod.period_start,
)
if (laterPosted) throw new AssetDisposalBlockedError('later_depreciation_posted')
const currentPosted = posted.find(
(schedule) => schedule.fiscal_period_id === fiscalPeriod.id,
)
const priorAccumulated = round2(
posted
.filter((schedule) => {
const start = periodStartById.get(schedule.fiscal_period_id)
return start !== undefined && start < fiscalPeriod.period_start
})
.reduce((sum, schedule) => sum + Number(schedule.planned_depreciation), 0),
)
const requiredCurrent = computeAnnualDepreciation(
{ ...asset, disposed_at: input.disposed_at },
fiscalPeriod,
priorAccumulated,
).amount
let currentDepreciation = requiredCurrent
if (currentPosted) {
if (Math.abs(Number(currentPosted.planned_depreciation) - requiredCurrent) > 0.01) {
throw new AssetDisposalBlockedError('current_depreciation_mismatch')
}
currentDepreciation = 0
}
const accumulatedDepreciation = round2(
priorAccumulated + (currentPosted ? Number(currentPosted.planned_depreciation) : requiredCurrent),
)
const acquisitionCost = round2(Number(asset.acquisition_cost))
const proceedsGross = input.disposal_type === 'scrap' ? 0 : round2(input.disposed_proceeds)
const vatTreatment = input.disposal_type === 'sale' ? input.vat_treatment ?? null : null
if (input.disposal_type === 'sale' && proceedsGross > 0 && !vatTreatment) {
throw new Error('Momsbehandling krävs vid försäljning.')
}
const proceedsVat = round2(
vatTreatment === 'standard_25' ? proceedsGross * (0.25 / 1.25) : 0,
)
const proceedsNet = round2(proceedsGross - proceedsVat)
const netBookValue = round2(acquisitionCost - accumulatedDepreciation)
const gainOrLoss = round2(proceedsNet - netBookValue)
if (input.disposal_type === 'business_transfer' && !input.business_transfer_confirmed) {
throw new AssetBusinessTransferConfirmationRequiredError()
}
const eligibility = assessJamkningEligibility({
acquisitionDate: asset.acquisition_date,
disposalDate: input.disposed_at,
basAssetAccount: asset.bas_asset_account,
category: asset.category,
})
const possibleInvestmentGoodCost = eligibility.totalYears === 10 ? 400_000 : 200_000
if (
eligibility.withinAdjustmentPeriod &&
acquisitionCost >= possibleInvestmentGoodCost &&
(input.jamkning_original_input_vat === undefined ||
input.jamkning_original_deduction_percent === undefined)
) {
throw new AssetJamkningDataRequiredError()
}
const jamkning = assessJamkning({
acquisitionDate: asset.acquisition_date,
disposalDate: input.disposed_at,
category: asset.category,
basAssetAccount: asset.bas_asset_account,
originalInputVat: input.jamkning_original_input_vat ?? 0,
originalDeductionPercent: input.jamkning_original_deduction_percent ?? 0,
disposalType: input.disposal_type,
vatTreatment: vatTreatment ?? undefined,
netProceeds: proceedsNet,
})
if (
jamkning.direction === 'transferred' &&
!input.adjustment_document_confirmed
) {
throw new AssetAdjustmentDocumentRequiredError()
}
const lines: CreateJournalEntryLineInput[] = []
if (currentDepreciation > 0.005) {
lines.push(
{
account_number: asset.bas_expense_account,
debit_amount: currentDepreciation,
credit_amount: 0,
line_description: `Avskrivning till avyttringsdag: ${asset.name}`,
},
{
account_number: asset.bas_accumulated_account,
debit_amount: 0,
credit_amount: currentDepreciation,
line_description: `Ackumulerad avskrivning till avyttringsdag: ${asset.name}`,
},
)
}
if (accumulatedDepreciation > 0.005) {
lines.push({
account_number: asset.bas_accumulated_account,
debit_amount: accumulatedDepreciation,
credit_amount: 0,
line_description: `Avyttring: nollställ ackumulerad avskrivning ${asset.name}`,
})
}
if (acquisitionCost > 0.005) {
lines.push({
account_number: asset.bas_asset_account,
debit_amount: 0,
credit_amount: acquisitionCost,
line_description: `Avyttring: nollställ anskaffningsvärde ${asset.name}`,
})
}
if (proceedsGross > 0.005) {
lines.push({
account_number: input.proceeds_account ?? '1930',
debit_amount: proceedsGross,
credit_amount: 0,
line_description: `Avyttring: erhållet belopp ${asset.name}`,
})
}
if (proceedsVat > 0.005 && vatTreatment) {
lines.push({
account_number: outputVatAccountFor(vatTreatment) ?? '2611',
debit_amount: 0,
credit_amount: proceedsVat,
line_description: `Utgående moms vid avyttring av ${asset.name}`,
})
}
const { gain, loss } = disposalAccounts(asset.category)
if (gainOrLoss > 0.005) {
lines.push({
account_number: gain,
debit_amount: 0,
credit_amount: gainOrLoss,
line_description: `Vinst vid avyttring av ${asset.name}`,
})
} else if (gainOrLoss < -0.005) {
lines.push({
account_number: loss,
debit_amount: Math.abs(gainOrLoss),
credit_amount: 0,
line_description: `Förlust vid avyttring av ${asset.name}`,
})
}
if (jamkning.amount > 0.005 && jamkning.direction === 'decrease') {
lines.push(
{
account_number: '6999',
debit_amount: jamkning.amount,
credit_amount: 0,
line_description: `Negativ justering av ingående moms: ${asset.name}`,
},
{
account_number: '2641',
debit_amount: 0,
credit_amount: jamkning.amount,
line_description: `Justerad ingående moms enligt ML 15 kap: ${asset.name}`,
},
)
} else if (jamkning.amount > 0.005 && jamkning.direction === 'increase') {
lines.push(
{
account_number: '2641',
debit_amount: jamkning.amount,
credit_amount: 0,
line_description: `Justerad ingående moms enligt ML 15 kap: ${asset.name}`,
},
{
account_number: '6999',
debit_amount: 0,
credit_amount: jamkning.amount,
line_description: `Positiv justering av ingående moms: ${asset.name}`,
},
)
}
return {
lines,
currentDepreciation,
accumulatedDepreciation,
proceedsGross,
proceedsVat,
vatTreatment,
gainOrLoss,
jamkning,
}
}
export async function disposeAsset(
supabase: SupabaseClient,
companyId: string,
@@ -566,152 +778,50 @@ export async function disposeAsset(
input: DisposeAssetInput,
): Promise<DisposalResult> {
const asset = await getAsset(supabase, companyId, assetId)
if (!asset) throw new Error('Asset not found')
if (asset.disposed_at) {
throw new Error('Asset is already disposed')
}
if (!asset) throw new AssetNotFoundError()
if (asset.disposed_at) throw new AssetAlreadyDisposedError()
// Derive accumulated depreciation server-side from posted
// depreciation_schedules so a malicious or buggy caller cannot inflate the
// book-value calculation. Limitation: manual avskrivningsverifikationer
// posted outside the engine aren't captured here. Phase 5+ can replace
// this with a trial-balance scan on bas_accumulated_account.
const accumulated = await sumPostedDepreciation(supabase, companyId, assetId)
const acquisitionCost = Number(asset.acquisition_cost)
const proceedsGross = round2(Number(input.disposed_proceeds))
const proceedsVat = round2(Number(input.proceeds_vat ?? 0))
const proceedsNet = round2(proceedsGross - proceedsVat)
const vatTreatment = input.vat_treatment
// Internal validation guard: when caller passes a VAT amount, treatment
// must accompany it so we can resolve the BAS 26xx account. The API
// layer also enforces this via Zod refinement; mirroring here keeps
// the engine self-defending against direct callers (MCP, scripts).
if (proceedsVat > 0.005 && !vatTreatment) {
// Paginated past PostgREST's silent 1000-row cap. A truncated period list
// can drop input.fiscal_period_id (false "Fiscal period not found") and
// starves the later_depreciation_posted guard of period start dates.
let periods: Array<Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>>
let scheduleRows: DisposalScheduleRow[]
try {
;[periods, scheduleRows] = await Promise.all([
fetchAllRows<Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>>(
({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
),
fetchAllRows<DisposalScheduleRow>(({ from, to }) =>
supabase
.from('depreciation_schedules')
.select('fiscal_period_id, planned_depreciation, journal_entry_id')
.eq('company_id', companyId)
.eq('asset_id', assetId)
.order('fiscal_period_id', { ascending: true })
.range(from, to),
),
])
} catch (error) {
throw new Error(
'vat_treatment krävs när proceeds_vat > 0: engine kan inte avgöra rätt 26xx-konto.',
`Failed to load disposal context: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Treatments that produce no VAT line must carry 0 VAT.
if (
proceedsVat > 0.005 &&
vatTreatment &&
(vatTreatment === 'reverse_charge' ||
vatTreatment === 'export' ||
vatTreatment === 'exempt')
) {
throw new Error(
`proceeds_vat måste vara 0 för momsbehandling "${vatTreatment}".`,
)
}
// Gain/loss is computed on the NET proceeds: VAT is pass-through and
// never hits the income statement.
const netBookValue = round2(acquisitionCost - accumulated)
const gainOrLoss = round2(proceedsNet - netBookValue)
const proceedsAccount = input.proceeds_account ?? '1930'
// ── Jämkning (ML 8a kap 7 §) ──────────────────────────────────────
// When the disposal happens inside the korrigeringstid, part of the
// originally-deducted input VAT must be paid back. Caller passes the
// precomputed amount (positive = debt to the state).
//
// Booking direction: We credit 2641 to reverse the original input-VAT
// deduction (2641 normal balance is debit; a credit reduces the
// deduction). The offset is debited to BAS 6991: jämkning is a VAT
// correction per ML 8a kap, NOT a disposal loss, so it must NOT hit
// the 78xx förlust-vid-avyttring accounts. See the jämkning lines
// below for details.
const jamkning = round2(Number(input.jamkning_amount ?? 0))
const lines: CreateJournalEntryLineInput[] = []
if (accumulated > 0.005) {
lines.push({
account_number: asset.bas_accumulated_account,
debit_amount: round2(accumulated),
credit_amount: 0,
line_description: `Avyttring: nollställ ack. avskrivning ${asset.name}`,
})
}
lines.push({
account_number: asset.bas_asset_account,
debit_amount: 0,
credit_amount: round2(acquisitionCost),
line_description: `Avyttring: nollställ anskaffning ${asset.name}`,
const fiscalPeriod = periods.find((period) => period.id === input.fiscal_period_id)
if (!fiscalPeriod) throw new Error('Fiscal period not found')
const plan = buildAssetDisposalPlan({
asset,
input,
fiscalPeriod,
periods,
schedules: scheduleRows,
})
if (proceedsGross > 0.005) {
lines.push({
account_number: proceedsAccount,
debit_amount: proceedsGross,
credit_amount: 0,
line_description: `Avyttring: erhållet belopp ${asset.name}`,
})
}
// Output VAT line: credit the matching 26xx account.
if (proceedsVat > 0.005 && vatTreatment) {
const vatAccount = outputVatAccountFor(vatTreatment)
if (vatAccount) {
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: proceedsVat,
line_description: `Utgående moms ${vatRateLabel(vatTreatment)} avyttring ${asset.name}`,
})
}
}
// Disposal gain/loss accounts vary by asset class: BAS 2026 splits them
// because INK2R routes each pair to a different field. Mixing them
// misclassifies in the tax declaration.
// - immaterial → 3013 (vinst) / 7813 (förlust)
// - building / markanlägg → 3971 / 7971
// - other tangible → 3973 / 7973
const isBuilding = asset.category === 'building' || asset.category === 'land_improvement'
const gainAccount =
asset.category === 'immaterial' ? '3013' : isBuilding ? '3971' : '3973'
const lossAccount =
asset.category === 'immaterial' ? '7813' : isBuilding ? '7971' : '7973'
if (gainOrLoss > 0.005) {
lines.push({
account_number: gainAccount,
debit_amount: 0,
credit_amount: gainOrLoss,
line_description: `Vinst vid avyttring av ${asset.name}`,
})
} else if (gainOrLoss < -0.005) {
lines.push({
account_number: lossAccount,
debit_amount: Math.abs(gainOrLoss),
credit_amount: 0,
line_description: `Förlust vid avyttring av ${asset.name}`,
})
}
// Jämkning lines: credit 2641 + debit 6991. Jämkning is a VAT
// correction per ML 8a kap, NOT a disposal loss. Routing it through
// the 78xx förlust-vid-avyttring accounts would distort both the
// gain/loss line on the income statement and the INK2R mapping, and
// mix tax-correction costs with disposal losses in the audit trail.
// BAS 6991 "Övriga externa kostnader, avdragsgilla" is the seeded
// catch-all för-en-extern-kostnad account that fits a repayment of
// previously-deducted input VAT.
if (jamkning > 0.005) {
lines.push({
account_number: '6991',
debit_amount: jamkning,
credit_amount: 0,
line_description: `Jämkning av tidigare avdragen ingående moms enligt ML 8a kap (${asset.name})`,
})
lines.push({
account_number: '2641',
debit_amount: 0,
credit_amount: jamkning,
line_description: `Återförd ingående moms jämkning ${asset.name}`,
})
}
// K3 component breakdown: when the asset was depreciated per-component,
// we surface the component list in the journal entry notes so auditors
@@ -727,43 +837,71 @@ export async function disposeAsset(
.join('; ')}`
: null
let disposalEntry: JournalEntry | null = null
if (lines.length > 0) {
disposalEntry = await createJournalEntry(supabase, companyId, userId, {
let draft: JournalEntry | null = null
if (plan.lines.length > 0) {
draft = await createDraftEntry(supabase, companyId, userId, {
fiscal_period_id: input.fiscal_period_id,
entry_date: input.disposed_at,
description: `Avyttring av tillgång: ${asset.name}`,
source_type: 'manual',
lines,
source_type: 'system',
lines: plan.lines,
...(componentNotes ? { notes: componentNotes } : {}),
})
}
const { data: updated, error: updateError } = await supabase
.from('assets')
.update({
disposed_at: input.disposed_at,
disposed_proceeds: proceedsGross,
disposed_proceeds_vat: proceedsVat,
disposed_vat_treatment: vatTreatment ?? null,
jamkning_amount: jamkning,
jamkning_remaining_months: input.jamkning_remaining_months ?? null,
jamkning_total_months: input.jamkning_total_months ?? null,
jamkning_original_input_vat: input.jamkning_original_input_vat ?? null,
})
.eq('id', assetId)
.eq('company_id', companyId)
.select('*')
.single()
let disposalEntry: JournalEntry | null
try {
disposalEntry = await commitAssetDisposal(
supabase,
companyId,
userId,
draft?.id ?? null,
{
asset_id: assetId,
fiscal_period_id: input.fiscal_period_id,
disposal_type: input.disposal_type,
disposed_at: input.disposed_at,
disposed_proceeds: plan.proceedsGross,
proceeds_vat: plan.proceedsVat,
vat_treatment: plan.vatTreatment,
current_depreciation: plan.currentDepreciation,
jamkning_amount: plan.jamkning.amount,
jamkning_direction: plan.jamkning.direction,
jamkning_remaining_years: plan.jamkning.remainingYears ?? null,
jamkning_total_years: plan.jamkning.totalYears || null,
jamkning_original_input_vat: input.jamkning_original_input_vat ?? null,
jamkning_original_deduction_percent:
input.jamkning_original_deduction_percent ?? null,
jamkning_new_deduction_percent: plan.jamkning.newDeductionPercent,
},
)
} catch (error) {
if (draft) {
await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', draft.id)
.eq('status', 'draft')
}
throw error
}
if (updateError || !updated) {
throw new Error(`Failed to mark asset disposed: ${updateError?.message ?? 'unknown'}`)
const updated = (await getAsset(supabase, companyId, assetId)) ?? {
...asset,
disposed_at: input.disposed_at,
disposed_proceeds: plan.proceedsGross,
disposed_proceeds_vat: plan.proceedsVat,
disposed_vat_treatment: plan.vatTreatment,
disposal_type: input.disposal_type,
disposal_journal_entry_id: disposalEntry?.id ?? null,
jamkning_amount: plan.jamkning.amount,
jamkning_direction: plan.jamkning.direction,
}
return {
asset: updated as Asset,
disposal_entry: disposalEntry,
gain_or_loss: gainOrLoss,
gain_or_loss: plan.gainOrLoss,
}
}
@@ -784,49 +922,16 @@ function outputVatAccountFor(treatment: VatTreatment): string | null {
}
}
function vatRateLabel(treatment: VatTreatment): string {
switch (treatment) {
case 'standard_25':
return '25%'
case 'reduced_12':
return '12%'
case 'reduced_6':
return '6%'
default:
return ''
}
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/**
* Sum every posted depreciation_schedules row for an asset to get accumulated
* depreciation as of "now". Used by disposeAsset so the caller cannot
* influence the book-value calculation.
*/
async function sumPostedDepreciation(
supabase: SupabaseClient,
companyId: string,
assetId: string,
): Promise<number> {
const { data, error } = await supabase
.from('depreciation_schedules')
.select('planned_depreciation')
.eq('company_id', companyId)
.eq('asset_id', assetId)
.not('journal_entry_id', 'is', null)
if (error) {
throw new Error(`Failed to sum depreciation for asset ${assetId}: ${error.message}`)
function disposalAccounts(category: AssetCategory): { gain: string; loss: string } {
if (category === 'immaterial') return { gain: '3971', loss: '7971' }
if (category === 'building' || category === 'land_improvement') {
return { gain: '3972', loss: '7972' }
}
type Row = { planned_depreciation: number | string }
return ((data ?? []) as Row[]).reduce(
(sum, row) => sum + (Number(row.planned_depreciation) || 0),
0,
)
return { gain: '3973', loss: '7973' }
}
/**
+3 -1
View File
@@ -1,6 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { listAssets } from './asset-service'
import type {
Asset,
FiscalPeriod,
@@ -272,6 +271,9 @@ export async function proposeAnnualPostings(
companyId: string,
fiscalPeriodId: string,
): Promise<DepreciationProposal> {
// Loaded here to keep the pure depreciation calculator reusable from the
// asset disposal service without creating a module initialization cycle.
const { listAssets } = await import('./asset-service')
const [periodResult, assets, currentSchedulesResult, priorSchedulesResult] = await Promise.all([
supabase
.from('fiscal_periods')
+163 -155
View File
@@ -1,190 +1,198 @@
/**
* Jämkning helpers: input-VAT correction on disposal of investeringsvara
* within the korrigeringstid (ML 8a kap 4-7 §§).
* Pure helpers for adjustment of input VAT on investment goods under
* Mervardesskattelagen (2023:200), chapter 15.
*
* When an asset that had input VAT deducted at acquisition is disposed of
* within the correction period, part of the original deducted input VAT
* must be paid back. The amount is the portion that corresponds to the
* remaining months of the correction period.
*
* Correction periods per ML 8a kap 6 §:
* - 60 months (5 years) for lös egendom / movable property
* - 120 months (10 years) for fastighet / markanläggning (real property)
*
* The two functions in this file are PURE (no I/O, no Supabase, no clock
* read) so they can be tested with simple input/output cases.
*
* Caller responsibility:
* - Decide whether a disposal event triggers jämkning. The most common
* trigger is a sale within korrigeringstid, but ML 8a kap also lists
* "ändrad användning" and "utträde ur skattskyldighet". The caller
* passes the boolean so this helper stays domain-agnostic.
* - Source `originalInputVat`. For new assets this comes from the
* supplier invoice that booked the acquisition; for legacy assets the
* user has to enter it manually.
* The acquisition year and disposal year both count in the adjustment
* period. The basis is total original input VAT, not only the amount that was
* deducted at acquisition.
*/
import type { AssetCategory } from '@/types'
import type { AssetCategory, VatTreatment } from '@/types'
/**
* Inputs to compute the jämkning amount on disposal.
*/
export interface JamkningInput {
/** Original input VAT deducted at acquisition (BAS 2641 debit). */
export type JamkningDirection = 'increase' | 'decrease' | 'none' | 'transferred'
export interface JamkningAssessmentInput {
acquisitionDate: string
disposalDate: string
category?: AssetCategory
basAssetAccount?: string
originalInputVat: number
/**
* Total correction period in months. 60 for movable property,
* 120 for fastighet / markanläggning. Caller decides which.
*/
totalCorrectionMonths: number
/**
* Months remaining in the correction period as of the disposal date.
* Caller computes this so the helper avoids any clock / calendar
* dependency.
*/
remainingMonths: number
/**
* Whether the disposal event triggers jämkning at all. Most disposals
* within the korrigeringstid trigger it, but the caller may opt out
* (e.g. the buyer continues to use the asset in a fully taxable
* verksamhet and assumes the jämkning obligation via avtal: ML 8a kap
* 12 §).
*/
disposalEvent: 'triggers_jamkning' | 'no_jamkning'
originalDeductionPercent: number
disposalType: 'sale' | 'scrap' | 'business_transfer'
vatTreatment?: VatTreatment
netProceeds?: number
}
export interface JamkningAssessment {
isInvestmentGood: boolean
threshold: number
totalYears: number
remainingYears: number
originalDeductionPercent: number
newDeductionPercent: number | null
direction: JamkningDirection
amount: number
capped: boolean
reason:
| 'below_threshold'
| 'outside_adjustment_period'
| 'scrap'
| 'transferred'
| 'change_below_five_points'
| 'adjustment'
}
/**
* Compute the jämkning amount per ML 8a kap 7 §. Returns a positive number
* representing the amount to be paid back to the state (i.e. reverse the
* input-VAT deduction). When disposal happens AFTER the correction period
* (remainingMonths <= 0) the formula returns 0: caller can simply skip
* the line.
*
* Formula: (remaining / total) × originalInputVat
*
* Edge cases:
* - disposalEvent = 'no_jamkning' → 0
* - totalCorrectionMonths <= 0 → 0 (defensive: caller bug)
* - remainingMonths <= 0 → 0 (asset is past the correction period)
* - remainingMonths > totalCorrectionMonths → caps at originalInputVat
* (sold immediately, before any correction period has elapsed)
* Assess a disposal using the one-time adjustment formula in ML 15:13-18.
* A taxable sale of movable property is capped at 25 percent of net proceeds.
*/
export function computeJamkningAmount(input: JamkningInput): number {
if (input.disposalEvent === 'no_jamkning') return 0
if (input.totalCorrectionMonths <= 0) return 0
if (input.remainingMonths <= 0) return 0
export function assessJamkning(input: JamkningAssessmentInput): JamkningAssessment {
const realProperty = isRealProperty(input)
const totalYears = realProperty ? 10 : 5
const threshold = realProperty ? 100_000 : 50_000
const remainingYears = yearsRemaining(
input.acquisitionDate,
input.disposalDate,
totalYears,
)
const originalDeductionPercent = clampPercent(input.originalDeductionPercent)
const base = {
isInvestmentGood: input.originalInputVat >= threshold,
threshold,
totalYears,
remainingYears,
originalDeductionPercent,
}
const remaining = Math.min(input.remainingMonths, input.totalCorrectionMonths)
const raw = (remaining / input.totalCorrectionMonths) * input.originalInputVat
return Math.round(raw * 100) / 100
if (!base.isInvestmentGood) {
return noAdjustment(base, 'below_threshold', originalDeductionPercent)
}
if (remainingYears === 0) {
return noAdjustment(base, 'outside_adjustment_period', originalDeductionPercent)
}
if (input.disposalType === 'scrap') {
return noAdjustment(base, 'scrap', originalDeductionPercent)
}
if (input.disposalType === 'business_transfer') {
return {
...base,
newDeductionPercent: null,
direction: 'transferred',
amount: 0,
capped: false,
reason: 'transferred',
}
}
const newDeductionPercent = treatmentRetainsDeduction(input.vatTreatment) ? 100 : 0
const change = newDeductionPercent - originalDeductionPercent
if (Math.abs(change) < 5) {
return noAdjustment(base, 'change_below_five_points', newDeductionPercent)
}
const rawAmount = round2(
input.originalInputVat * (Math.abs(change) / 100) * (remainingYears / totalYears),
)
let amount = rawAmount
let capped = false
if (!realProperty && change > 0 && treatmentRetainsDeduction(input.vatTreatment)) {
const cap = round2(Math.max(0, Number(input.netProceeds ?? 0)) * 0.25)
if (amount > cap) {
amount = cap
capped = true
}
}
return {
...base,
newDeductionPercent,
direction: change > 0 ? 'increase' : 'decrease',
amount,
capped,
reason: 'adjustment',
}
}
/**
* Suggested eligibility check for an asset disposal. Returns the
* totalCorrectionMonths the caller should pass to computeJamkningAmount,
* along with the remainingMonths derived from acquisitionDate and
* disposalDate.
*
* The threshold lives here (not in the asset row) because it's a property
* of the asset CATEGORY / BAS account class, not user-editable per-asset:
*
* - Fastighet (BAS 1100-1199) → 120 months
* - Markanläggning (BAS 1150-1159): also 120 months
* - All other movable property → 60 months
*
* Pure: takes only dates and the asset's BAS account, returns numbers.
* Caller decides whether to surface the suggestion in the UI.
*/
export interface JamkningEligibility {
/** Suggested total correction period (60 or 120 months). */
totalCorrectionMonths: number
/** Months elapsed between acquisitionDate and disposalDate (clamped at 0). */
elapsedMonths: number
/** Remaining months in the correction period (clamped at 0). */
remainingMonths: number
/**
* Whether the disposal falls WITHIN the correction period. Convenience
* boolean: equivalent to `remainingMonths > 0`. Caller uses this to
* show / hide the jämkning UI.
*/
withinCorrectionPeriod: boolean
totalYears: number
elapsedYears: number
remainingYears: number
withinAdjustmentPeriod: boolean
threshold: number
}
export function assessJamkningEligibility(args: {
basExpenseAccount?: string
basAssetAccount?: string
category?: AssetCategory
acquisitionDate: string
disposalDate: string
}): JamkningEligibility {
const totalCorrectionMonths = isRealProperty(args) ? 120 : 60
const elapsed = monthsBetween(args.acquisitionDate, args.disposalDate)
const elapsedClamped = Math.max(0, elapsed)
const remaining = Math.max(0, totalCorrectionMonths - elapsedClamped)
const realProperty = isRealProperty(args)
const totalYears = realProperty ? 10 : 5
const acquisitionYear = isoYear(args.acquisitionDate)
const disposalYear = isoYear(args.disposalDate)
const elapsedYears = Math.max(0, disposalYear - acquisitionYear)
const remainingYears = yearsRemaining(args.acquisitionDate, args.disposalDate, totalYears)
return {
totalCorrectionMonths,
elapsedMonths: elapsedClamped,
remainingMonths: remaining,
withinCorrectionPeriod: remaining > 0,
totalYears,
elapsedYears,
remainingYears,
withinAdjustmentPeriod: remainingYears > 0,
threshold: realProperty ? 100_000 : 50_000,
}
}
/**
* Real property (fastighet / markanläggning) per BAS 1100-1199 lives on
* the 10-year (120 mån) correction period. Everything else uses 5 years.
*
* The plan's contract is that this resolves off the asset's BAS account
* range, with category as a secondary signal. Two reasons we prefer
* account-driven over category-driven:
* 1. The account is what BAS reports / SIE / INK2R actually read; the
* category is just a UI label.
* 2. Users who override the BAS account to something outside the
* category's default range get a consistent answer with what their
* reports show.
*/
function noAdjustment(
base: Pick<
JamkningAssessment,
| 'isInvestmentGood'
| 'threshold'
| 'totalYears'
| 'remainingYears'
| 'originalDeductionPercent'
>,
reason: Exclude<JamkningAssessment['reason'], 'transferred' | 'adjustment'>,
newDeductionPercent: number,
): JamkningAssessment {
return {
...base,
newDeductionPercent,
direction: 'none',
amount: 0,
capped: false,
reason,
}
}
function treatmentRetainsDeduction(treatment: VatTreatment | undefined): boolean {
return treatment !== undefined && treatment !== 'exempt'
}
function isRealProperty(args: {
basExpenseAccount?: string
basAssetAccount?: string
category?: AssetCategory
}): boolean {
// Prefer the asset (anskaffning) account when supplied: it's the most
// direct mapping to the BAS class.
const assetAccount = args.basAssetAccount
if (assetAccount && /^1[1][0-9]{2}$/.test(assetAccount)) return true
// Expense account check: 7820-7829 = byggnader/markanläggning.
const expense = args.basExpenseAccount
if (expense && /^782[0-9]$/.test(expense)) return true
// Category fallback for callers who only have the asset row's category
// (e.g. UI that hasn't loaded the full asset yet).
if (args.category === 'building' || args.category === 'land_improvement') {
return true
}
return false
if (args.basAssetAccount && /^11\d{2}$/.test(args.basAssetAccount)) return true
return args.category === 'building' || args.category === 'land_improvement'
}
/**
* Calendar months between two ISO date strings, rounded toward zero.
* Counts complete months only: partial months don't tick the clock.
*
* The Swedish tax authorities count months, not days, for jämkning
* (ML 8a kap 6 §). Example: acquired 2023-01-15, sold 2026-01-14 →
* 35 months elapsed (the 36th month hasn't completed yet).
*/
function monthsBetween(fromIso: string, toIso: string): number {
const from = parseIsoDate(fromIso)
const to = parseIsoDate(toIso)
if (!from || !to) return 0
let months = (to.year - from.year) * 12 + (to.month - from.month)
if (to.day < from.day) months -= 1
return months
function yearsRemaining(acquisitionDate: string, disposalDate: string, totalYears: number): number {
const acquisitionYear = isoYear(acquisitionDate)
const disposalYear = isoYear(disposalDate)
return Math.max(0, Math.min(totalYears, totalYears - (disposalYear - acquisitionYear)))
}
function parseIsoDate(iso: string): { year: number; month: number; day: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
if (!m) return null
return {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
}
function isoYear(value: string): number {
const match = /^(\d{4})-\d{2}-\d{2}$/.exec(value)
return match ? Number(match[1]) : 0
}
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value))
}
function round2(value: number): number {
return Math.round(value * 100) / 100
}
+120
View File
@@ -29,11 +29,14 @@ import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { getActor } from '@/lib/bookkeeping/actor-context'
import type {
AssetDisposalType,
AssetJamkningDirection,
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
JournalEntryLine,
JournalEntrySourceType,
VatTreatment,
} from '@/types'
const log = createLogger('bookkeeping.engine')
@@ -669,6 +672,123 @@ export async function commitEntry(
return result
}
export interface CommitAssetDisposalInput {
asset_id: string
fiscal_period_id: string
disposal_type: AssetDisposalType
disposed_at: string
disposed_proceeds: number
proceeds_vat: number
vat_treatment: VatTreatment | null
current_depreciation: number
jamkning_amount: number
jamkning_direction: AssetJamkningDirection
jamkning_remaining_years: number | null
jamkning_total_years: number | null
jamkning_original_input_vat: number | null
jamkning_original_deduction_percent: number | null
jamkning_new_deduction_percent: number | null
}
/**
* Commit a prepared asset-disposal draft and update the asset register in the
* same database transaction. The dedicated RPC delegates voucher numbering to
* commit_journal_entry, so disposal cannot leave a posted voucher without the
* corresponding immutable register state.
*/
export async function commitAssetDisposal(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string | null,
input: CommitAssetDisposalInput,
): Promise<JournalEntry | null> {
const actor = getActor()
const { error } = await supabase.rpc('commit_asset_disposal', {
p_company_id: companyId,
p_asset_id: input.asset_id,
p_entry_id: entryId,
p_fiscal_period_id: input.fiscal_period_id,
p_disposal_type: input.disposal_type,
p_disposed_at: input.disposed_at,
p_disposed_proceeds: input.disposed_proceeds,
p_proceeds_vat: input.proceeds_vat,
p_vat_treatment: input.vat_treatment,
p_current_depreciation: input.current_depreciation,
p_jamkning_amount: input.jamkning_amount,
p_jamkning_direction: input.jamkning_direction,
p_jamkning_remaining_years: input.jamkning_remaining_years,
p_jamkning_total_years: input.jamkning_total_years,
p_jamkning_original_input_vat: input.jamkning_original_input_vat,
p_jamkning_original_deduction_percent: input.jamkning_original_deduction_percent,
p_jamkning_new_deduction_percent: input.jamkning_new_deduction_percent,
p_actor_type: actor?.type ?? null,
p_actor_label: actor?.label ?? null,
})
if (error) {
log.error('commit_asset_disposal RPC failed', error, {
operation: 'commit_asset_disposal',
companyId,
userId,
entityType: 'asset',
entityId: input.asset_id,
journalEntryId: entryId,
pgCode: (error as { code?: string }).code,
})
throw new BookkeepingDatabaseError('commit_asset_disposal', error.message)
}
if (!entryId) return null
// The RPC has already committed the voucher and the register update at this
// point. A transient reload failure must not masquerade as a failed
// disposal, so retry once and log the divergence before surfacing it.
let completeEntry: JournalEntry | null = null
let lastFetchError: { message: string } | null = null
for (let attempt = 0; attempt < 2 && !completeEntry; attempt++) {
const { data, error: fetchError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entryId)
.eq('company_id', companyId)
.single()
if (data && !fetchError) {
completeEntry = data as JournalEntry
} else {
lastFetchError = fetchError ?? { message: 'posted entry not found' }
}
}
if (!completeEntry) {
log.error(
'asset disposal committed but posted entry reload failed',
lastFetchError,
{
operation: 'commit_asset_disposal',
companyId,
userId,
entityType: 'asset',
entityId: input.asset_id,
journalEntryId: entryId,
},
)
throw new BookkeepingDatabaseError(
'fetch_asset_disposal_entry',
`disposal voucher is committed but could not be reloaded: ${
lastFetchError?.message ?? 'posted entry not found'
}`,
)
}
const result = completeEntry
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId, companyId },
})
return result
}
/**
* Create a journal entry with lines (verifikation)
* Convenience wrapper: creates draft + commits in one step.
+2
View File
@@ -289,6 +289,8 @@ export type BookkeepingOperation =
| 'create_draft_entry'
| 'create_entry_lines'
| 'commit_entry'
| 'commit_asset_disposal'
| 'fetch_asset_disposal_entry'
| 'create_reversal_entry'
| 'create_reversal_lines'
| 'post_reversal_entry'
+38
View File
@@ -3115,6 +3115,44 @@ const BOLAGSVERKET: Record<string, StructuredErrorEntry> = {
}
const ASSETS: Record<string, StructuredErrorEntry> = {
ASSET_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Tillgången kunde inte hittas.',
message_en: 'Asset not found.',
},
ASSET_ALREADY_DISPOSED: {
httpStatus: 409,
message_sv: 'Tillgången är redan avyttrad.',
message_en: 'The asset has already been disposed.',
},
ASSET_DISPOSAL_BLOCKED: {
httpStatus: 409,
message_sv:
'Avyttringen kan inte bokföras eftersom avskrivningar redan finns för samma eller en senare period. Återför den felaktiga avskrivningen med storno först.',
message_en:
'The disposal cannot be posted because depreciation already exists for the same or a later period. Reverse the incorrect depreciation first.',
},
ASSET_JAMKNING_DATA_REQUIRED: {
httpStatus: 422,
message_sv:
'Ange ursprunglig ingående moms och ursprunglig avdragsprocent för att bedöma justering enligt ML 15 kap.',
message_en:
'Enter the original input VAT and original deduction percentage to assess adjustment under ML chapter 15.',
},
ASSET_ADJUSTMENT_DOCUMENT_REQUIRED: {
httpStatus: 422,
message_sv:
'Bekräfta att en justeringshandling upprättas när justeringsskyldigheten överförs.',
message_en:
'Confirm that an adjustment document is prepared when the adjustment obligation is transferred.',
},
ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED: {
httpStatus: 422,
message_sv:
'Bekräfta att överlåtelsen omfattar en hel verksamhet eller självständig verksamhetsgren och uppfyller villkoren i ML 5 kap. 38 §.',
message_en:
'Confirm that the transfer covers an entire business or independent branch and meets the conditions in ML chapter 5, section 38.',
},
ASSET_CORRECTION_BLOCKED: {
httpStatus: 409,
message_sv:
+56 -1
View File
@@ -836,7 +836,62 @@
"category_vehicle": "Vehicle",
"category_computer": "Computer",
"category_other_tangible": "Other tangible",
"count_footer": "{count, plural, one {1 asset} other {# assets}}"
"count_footer": "{count, plural, one {1 asset} other {# assets}}",
"disposal": {
"title": "Dispose of asset",
"load_failed_title": "Could not load the disposal",
"try_again": "Please try again.",
"submit_failed_title": "Disposal failed",
"success_title": "Asset disposed",
"success_description": "Depreciation, disposal, and the asset register were posted together.",
"not_found": "The asset could not be found.",
"already_disposed": "The asset was already disposed ({date}).",
"back": "Back",
"acquisition_cost": "Acquisition cost",
"acquired": "Acquired",
"bas_accounts": "Accounts (BAS)",
"details_title": "Disposal details",
"type_label": "Event",
"type_sale": "Sale",
"type_scrap": "Scrapping",
"type_business_transfer": "Transfer of a business",
"date_label": "Disposal date",
"period_label": "Fiscal period",
"period_placeholder": "Select period",
"locked": "locked",
"period_locked": "The selected period is locked or closed. Select an open period.",
"proceeds_label": "Amount received including VAT",
"consideration_label": "Consideration for the asset",
"proceeds_account_label": "Receiving account",
"vat_title": "VAT on the sale",
"vat_treatment_label": "VAT treatment",
"vat_standard_25": "Swedish VAT 25%",
"vat_reverse_charge": "Reverse charge",
"vat_export": "Export outside the EU",
"vat_exempt": "VAT-exempt sale",
"gross": "Gross",
"vat": "Output VAT",
"net": "Net",
"adjustment_title": "Input VAT adjustment (ML chapter 15)",
"within_adjustment_period": "Within the adjustment period: {years} years remain of {total}",
"outside_adjustment_period": "Outside the adjustment period",
"original_vat_label": "Total original input VAT",
"original_vat_hint": "Enter the full VAT amount, not only the deducted part. Threshold: SEK {threshold}.",
"original_percent_label": "Original deduction right (%)",
"adjustment_direction": "Direction",
"adjustment_amount": "Calculated adjustment",
"direction_increase": "Increased deduction",
"direction_decrease": "Decreased deduction",
"direction_none": "No adjustment",
"direction_transferred": "Transferred to the acquirer",
"adjustment_capped": "The amount was capped at 25% of the sale price excluding VAT.",
"business_transfer_confirm": "I confirm that the transfer covers an entire business or independent branch and meets the conditions in ML chapter 5, section 38.",
"adjustment_document_confirm": "I confirm that an adjustment document is prepared for the acquirer.",
"adjustment_data_required": "VAT basis data is required because the asset may be an investment good.",
"cancel": "Cancel",
"submit": "Post disposal",
"write_required": "Only users with write access can dispose of assets."
}
},
"employees": {
"title": "Employees",
+56 -1
View File
@@ -836,7 +836,62 @@
"category_vehicle": "Fordon",
"category_computer": "Dator",
"category_other_tangible": "Övriga materiella",
"count_footer": "{count, plural, one {1 tillgång} other {# tillgångar}}"
"count_footer": "{count, plural, one {1 tillgång} other {# tillgångar}}",
"disposal": {
"title": "Avyttra tillgång",
"load_failed_title": "Kunde inte ladda avyttringen",
"try_again": "Försök igen.",
"submit_failed_title": "Avyttringen misslyckades",
"success_title": "Tillgången är avyttrad",
"success_description": "Avskrivning, avyttring och tillgångsregister har bokförts tillsammans.",
"not_found": "Tillgången kunde inte hittas.",
"already_disposed": "Tillgången är redan avyttrad ({date}).",
"back": "Tillbaka",
"acquisition_cost": "Anskaffningsvärde",
"acquired": "Anskaffad",
"bas_accounts": "Konton (BAS)",
"details_title": "Avyttringsuppgifter",
"type_label": "Händelse",
"type_sale": "Försäljning",
"type_scrap": "Utrangering",
"type_business_transfer": "Verksamhetsöverlåtelse",
"date_label": "Avyttringsdatum",
"period_label": "Räkenskapsperiod",
"period_placeholder": "Välj period",
"locked": "låst",
"period_locked": "Vald period är låst eller stängd. Välj en öppen period.",
"proceeds_label": "Erhållet belopp inklusive moms",
"consideration_label": "Ersättning för tillgången",
"proceeds_account_label": "Mottagarkonto",
"vat_title": "Moms vid försäljning",
"vat_treatment_label": "Momsbehandling",
"vat_standard_25": "Svensk moms 25 %",
"vat_reverse_charge": "Omvänd betalningsskyldighet",
"vat_export": "Export utanför EU",
"vat_exempt": "Momsfri försäljning",
"gross": "Brutto",
"vat": "Utgående moms",
"net": "Netto",
"adjustment_title": "Justering av ingående moms (ML 15 kap.)",
"within_adjustment_period": "Inom justeringsperioden: {years} år kvar av {total}",
"outside_adjustment_period": "Utanför justeringsperioden",
"original_vat_label": "Total ursprunglig ingående moms",
"original_vat_hint": "Ange hela momsbeloppet, inte bara avdragen del. Tröskel: {threshold} kr.",
"original_percent_label": "Ursprunglig avdragsrätt (%)",
"adjustment_direction": "Riktning",
"adjustment_amount": "Beräknad justering",
"direction_increase": "Ökat avdrag",
"direction_decrease": "Minskat avdrag",
"direction_none": "Ingen justering",
"direction_transferred": "Överförs till förvärvaren",
"adjustment_capped": "Beloppet har begränsats till 25 % av försäljningspriset exklusive moms.",
"business_transfer_confirm": "Jag bekräftar att överlåtelsen omfattar en hel verksamhet eller självständig verksamhetsgren och uppfyller villkoren i ML 5 kap. 38 §.",
"adjustment_document_confirm": "Jag bekräftar att en justeringshandling upprättas för förvärvaren.",
"adjustment_data_required": "Momsunderlaget krävs eftersom tillgången kan vara en investeringsvara.",
"cancel": "Avbryt",
"submit": "Bokför avyttring",
"write_required": "Endast användare med skrivrättigheter kan avyttra tillgångar."
}
},
"employees": {
"title": "Anställda",
+2 -2
View File
@@ -36,8 +36,8 @@
"version": 1
},
"horizontal/swedish-asset-accounting/leasing-and-disposal": {
"hash": "485d7f1d4669f718dbba8611a3644fc3b307b22fb59448af65eebce8466c5b8a",
"version": 1
"hash": "cefd92341d2946c97ae10f456634a7cf569b7d0eab65d9ca9f8ca7017675f66e",
"version": 2
},
"horizontal/swedish-e-invoicing": {
"hash": "287a9a7d4239de8db25b941345bb9b418e5fcf5b4dd65963c06b68215f54b2ce",
@@ -0,0 +1,343 @@
-- Atomic fixed-asset disposal.
--
-- A disposal voucher and the immutable asset-register update are one legal
-- event. This RPC calls commit_journal_entry for sequential voucher numbering,
-- records any disposal-date depreciation schedule, and marks the asset as
-- disposed in the same transaction.
ALTER TABLE public.assets
ADD COLUMN IF NOT EXISTS disposal_type text,
ADD COLUMN IF NOT EXISTS disposal_journal_entry_id uuid,
ADD COLUMN IF NOT EXISTS jamkning_direction text,
ADD COLUMN IF NOT EXISTS jamkning_remaining_years integer,
ADD COLUMN IF NOT EXISTS jamkning_total_years integer,
ADD COLUMN IF NOT EXISTS jamkning_original_deduction_percent numeric(5, 2),
ADD COLUMN IF NOT EXISTS jamkning_new_deduction_percent numeric(5, 2);
-- All constrained columns are new and NULL for existing rows, so validation
-- can never fail. Add every constraint NOT VALID and validate separately:
-- an immediate FK validation takes SHARE ROW EXCLUSIVE on journal_entries (a
-- hot table) and each plain CHECK scans assets under a blocking lock, while
-- VALIDATE CONSTRAINT only needs SHARE UPDATE EXCLUSIVE and does not block
-- writes.
ALTER TABLE public.assets
DROP CONSTRAINT IF EXISTS assets_disposal_journal_entry_id_fkey,
ADD CONSTRAINT assets_disposal_journal_entry_id_fkey
FOREIGN KEY (disposal_journal_entry_id)
REFERENCES public.journal_entries(id) ON DELETE RESTRICT
NOT VALID,
DROP CONSTRAINT IF EXISTS assets_disposal_type_check,
ADD CONSTRAINT assets_disposal_type_check CHECK (
disposal_type IS NULL OR disposal_type IN ('sale', 'scrap', 'business_transfer')
) NOT VALID,
DROP CONSTRAINT IF EXISTS assets_jamkning_direction_check,
ADD CONSTRAINT assets_jamkning_direction_check CHECK (
jamkning_direction IS NULL OR jamkning_direction IN ('increase', 'decrease', 'none', 'transferred')
) NOT VALID,
DROP CONSTRAINT IF EXISTS assets_jamkning_years_check,
ADD CONSTRAINT assets_jamkning_years_check CHECK (
(jamkning_remaining_years IS NULL OR jamkning_remaining_years >= 0)
AND (jamkning_total_years IS NULL OR jamkning_total_years IN (5, 10))
AND (
jamkning_remaining_years IS NULL
OR jamkning_total_years IS NULL
OR jamkning_remaining_years <= jamkning_total_years
)
) NOT VALID,
DROP CONSTRAINT IF EXISTS assets_jamkning_percent_check,
ADD CONSTRAINT assets_jamkning_percent_check CHECK (
(jamkning_original_deduction_percent IS NULL OR jamkning_original_deduction_percent BETWEEN 0 AND 100)
AND (jamkning_new_deduction_percent IS NULL OR jamkning_new_deduction_percent BETWEEN 0 AND 100)
) NOT VALID;
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_disposal_journal_entry_id_fkey;
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_disposal_type_check;
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_jamkning_direction_check;
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_jamkning_years_check;
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_jamkning_percent_check;
CREATE OR REPLACE FUNCTION public.enforce_asset_post_disposal_immutability()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $function$
BEGIN
IF OLD.disposed_at IS NOT NULL THEN
IF NEW.category IS DISTINCT FROM OLD.category
OR NEW.acquisition_cost IS DISTINCT FROM OLD.acquisition_cost
OR NEW.salvage_value IS DISTINCT FROM OLD.salvage_value
OR NEW.useful_life_months IS DISTINCT FROM OLD.useful_life_months
OR NEW.depreciation_method IS DISTINCT FROM OLD.depreciation_method
OR NEW.restvarde_target IS DISTINCT FROM OLD.restvarde_target
OR NEW.bas_asset_account IS DISTINCT FROM OLD.bas_asset_account
OR NEW.bas_accumulated_account IS DISTINCT FROM OLD.bas_accumulated_account
OR NEW.bas_expense_account IS DISTINCT FROM OLD.bas_expense_account
OR NEW.acquisition_date IS DISTINCT FROM OLD.acquisition_date
OR NEW.k3_components IS DISTINCT FROM OLD.k3_components
OR NEW.disposed_at IS DISTINCT FROM OLD.disposed_at
OR NEW.disposed_proceeds IS DISTINCT FROM OLD.disposed_proceeds
OR NEW.disposed_proceeds_vat IS DISTINCT FROM OLD.disposed_proceeds_vat
OR NEW.disposed_vat_treatment IS DISTINCT FROM OLD.disposed_vat_treatment
OR NEW.disposal_type IS DISTINCT FROM OLD.disposal_type
OR NEW.disposal_journal_entry_id IS DISTINCT FROM OLD.disposal_journal_entry_id
OR NEW.jamkning_amount IS DISTINCT FROM OLD.jamkning_amount
OR NEW.jamkning_remaining_months IS DISTINCT FROM OLD.jamkning_remaining_months
OR NEW.jamkning_total_months IS DISTINCT FROM OLD.jamkning_total_months
OR NEW.jamkning_original_input_vat IS DISTINCT FROM OLD.jamkning_original_input_vat
OR NEW.jamkning_direction IS DISTINCT FROM OLD.jamkning_direction
OR NEW.jamkning_remaining_years IS DISTINCT FROM OLD.jamkning_remaining_years
OR NEW.jamkning_total_years IS DISTINCT FROM OLD.jamkning_total_years
OR NEW.jamkning_original_deduction_percent IS DISTINCT FROM OLD.jamkning_original_deduction_percent
OR NEW.jamkning_new_deduction_percent IS DISTINCT FROM OLD.jamkning_new_deduction_percent THEN
RAISE EXCEPTION 'Cannot modify financial or disposal attributes of a disposed asset (id=%)', OLD.id
USING ERRCODE = '23514';
END IF;
END IF;
RETURN NEW;
END;
$function$;
CREATE OR REPLACE FUNCTION public.commit_asset_disposal(
p_company_id uuid,
p_asset_id uuid,
p_entry_id uuid,
p_fiscal_period_id uuid,
p_disposal_type text,
p_disposed_at date,
p_disposed_proceeds numeric,
p_proceeds_vat numeric,
p_vat_treatment text,
p_current_depreciation numeric,
p_jamkning_amount numeric,
p_jamkning_direction text,
p_jamkning_remaining_years integer,
p_jamkning_total_years integer,
p_jamkning_original_input_vat numeric,
p_jamkning_original_deduction_percent numeric,
p_jamkning_new_deduction_percent numeric,
p_actor_type text DEFAULT NULL,
p_actor_label text DEFAULT NULL
)
RETURNS TABLE(voucher_number integer)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_asset_user_id uuid;
v_entry_user_id uuid;
v_schedule_id uuid;
v_schedule_entry_id uuid;
v_period_start date;
v_period_closed boolean;
v_period_locked_at timestamptz;
v_company_lock_date date;
v_voucher_number integer;
v_jwt_role text := coalesce(
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
''
);
BEGIN
-- NULL-safe membership guard (20260703180000): never the raw
-- "NOT IN (SELECT user_company_ids())" form, which is NULL-unsafe.
IF v_jwt_role IN ('anon', 'authenticated')
AND (
NOT public.caller_is_company_member(p_company_id)
OR NOT public.current_user_can_write()
) THEN
RAISE EXCEPTION 'unauthorized asset disposal for company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- Disposal metadata invariants. The values are derived server-side by the
-- same planner that builds the draft entry, but the RPC is independently
-- callable, so reject internally inconsistent register metadata here.
IF coalesce(p_disposed_proceeds, 0) < 0 OR coalesce(p_proceeds_vat, 0) < 0 THEN
RAISE EXCEPTION 'Disposal proceeds and VAT must be non-negative'
USING ERRCODE = '23514';
END IF;
IF p_proceeds_vat > 0 AND p_vat_treatment IS NULL THEN
RAISE EXCEPTION 'Disposal VAT requires a VAT treatment'
USING ERRCODE = '23514';
END IF;
IF p_proceeds_vat > p_disposed_proceeds THEN
RAISE EXCEPTION 'Disposal VAT cannot exceed gross proceeds'
USING ERRCODE = '23514';
END IF;
IF p_disposal_type = 'scrap' AND coalesce(p_disposed_proceeds, 0) <> 0 THEN
RAISE EXCEPTION 'Scrapping (utrangering) cannot carry proceeds'
USING ERRCODE = '23514';
END IF;
SELECT a.user_id
INTO v_asset_user_id
FROM public.assets a
WHERE a.id = p_asset_id
AND a.company_id = p_company_id
AND a.disposed_at IS NULL
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Asset not found or already disposed: %', p_asset_id
USING ERRCODE = 'P0002';
END IF;
SELECT fp.period_start, fp.is_closed, fp.locked_at
INTO v_period_start, v_period_closed, v_period_locked_at
FROM public.fiscal_periods fp
WHERE fp.id = p_fiscal_period_id
AND fp.company_id = p_company_id
AND p_disposed_at BETWEEN fp.period_start AND fp.period_end;
IF NOT FOUND THEN
RAISE EXCEPTION 'Fiscal period does not contain disposal date'
USING ERRCODE = '22007';
END IF;
IF v_period_closed OR v_period_locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot dispose asset in a locked or closed fiscal period'
USING ERRCODE = '23514';
END IF;
SELECT cs.bookkeeping_locked_through
INTO v_company_lock_date
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
IF v_company_lock_date IS NOT NULL AND p_disposed_at <= v_company_lock_date THEN
RAISE EXCEPTION 'Bookkeeping is locked through %', v_company_lock_date
USING ERRCODE = '23514';
END IF;
IF EXISTS (
SELECT 1
FROM public.depreciation_schedules ds
JOIN public.fiscal_periods fp ON fp.id = ds.fiscal_period_id
WHERE ds.company_id = p_company_id
AND ds.asset_id = p_asset_id
AND ds.journal_entry_id IS NOT NULL
AND fp.period_start > v_period_start
) THEN
RAISE EXCEPTION 'Later depreciation is already posted for asset %', p_asset_id
USING ERRCODE = '23514';
END IF;
IF p_entry_id IS NOT NULL THEN
SELECT je.user_id
INTO v_entry_user_id
FROM public.journal_entries je
WHERE je.id = p_entry_id
AND je.company_id = p_company_id
AND je.fiscal_period_id = p_fiscal_period_id
AND je.entry_date = p_disposed_at
AND je.status = 'draft'
AND je.source_type = 'system'
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Valid disposal draft not found: %', p_entry_id
USING ERRCODE = 'P0002';
END IF;
ELSIF abs(coalesce(p_current_depreciation, 0)) > 0.005 THEN
RAISE EXCEPTION 'Current depreciation requires a disposal voucher'
USING ERRCODE = '23514';
END IF;
IF coalesce(p_current_depreciation, 0) > 0.005 THEN
SELECT ds.id, ds.journal_entry_id
INTO v_schedule_id, v_schedule_entry_id
FROM public.depreciation_schedules ds
WHERE ds.asset_id = p_asset_id
AND ds.fiscal_period_id = p_fiscal_period_id
FOR UPDATE;
IF FOUND AND v_schedule_entry_id IS NOT NULL THEN
RAISE EXCEPTION 'Depreciation was posted concurrently for asset %', p_asset_id
USING ERRCODE = '23514';
ELSIF FOUND THEN
UPDATE public.depreciation_schedules
SET planned_depreciation = p_current_depreciation,
journal_entry_id = p_entry_id,
posted_at = now()
WHERE id = v_schedule_id;
ELSE
INSERT INTO public.depreciation_schedules (
user_id,
company_id,
asset_id,
fiscal_period_id,
planned_depreciation,
journal_entry_id,
posted_at
) VALUES (
coalesce(v_entry_user_id, v_asset_user_id),
p_company_id,
p_asset_id,
p_fiscal_period_id,
p_current_depreciation,
p_entry_id,
now()
);
END IF;
END IF;
IF p_entry_id IS NOT NULL THEN
SELECT committed.voucher_number
INTO v_voucher_number
-- commit_method must be one of journal_entries_commit_method_check's
-- allowed values; the disposal dialog is a user-accepted commit.
FROM public.commit_journal_entry(
p_company_id,
p_entry_id,
'user_accept',
NULL,
p_actor_type,
p_actor_label
) AS committed;
END IF;
UPDATE public.assets
SET disposed_at = p_disposed_at,
disposed_proceeds = p_disposed_proceeds,
disposed_proceeds_vat = p_proceeds_vat,
disposed_vat_treatment = p_vat_treatment,
disposal_type = p_disposal_type,
disposal_journal_entry_id = p_entry_id,
jamkning_amount = p_jamkning_amount,
jamkning_direction = p_jamkning_direction,
jamkning_remaining_years = p_jamkning_remaining_years,
jamkning_total_years = p_jamkning_total_years,
jamkning_original_input_vat = p_jamkning_original_input_vat,
jamkning_original_deduction_percent = p_jamkning_original_deduction_percent,
jamkning_new_deduction_percent = p_jamkning_new_deduction_percent,
jamkning_remaining_months = NULL,
jamkning_total_months = NULL
WHERE id = p_asset_id
AND company_id = p_company_id
AND disposed_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'Asset disposal lost concurrent update: %', p_asset_id
USING ERRCODE = '40001';
END IF;
RETURN QUERY SELECT v_voucher_number;
END;
$function$;
REVOKE ALL ON FUNCTION public.commit_asset_disposal(
uuid, uuid, uuid, uuid, text, date, numeric, numeric, text, numeric,
numeric, text, integer, integer, numeric, numeric, numeric, text, text
) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.commit_asset_disposal(
uuid, uuid, uuid, uuid, text, date, numeric, numeric, text, numeric,
numeric, text, integer, integer, numeric, numeric, numeric, text, text
) TO authenticated;
COMMENT ON FUNCTION public.commit_asset_disposal(
uuid, uuid, uuid, uuid, text, date, numeric, numeric, text, numeric,
numeric, text, integer, integer, numeric, numeric, numeric, text, text
) IS 'Atomically posts a fixed-asset disposal voucher, disposal-date depreciation schedule, and immutable asset-register state.';
NOTIFY pgrst, 'reload schema';
File diff suppressed because it is too large Load Diff
+31 -12
View File
@@ -396,15 +396,17 @@ describe('RLS: cross-company isolation', () => {
// can't loosen them without us noticing.
describe('assets: disposal VAT + jämkning constraints', () => {
it('accepts a disposed_vat_treatment from the allowed enum', async () => {
// Disposal attributes are written in the same UPDATE that transitions the
// asset to disposed: once disposed_at is set, the post-disposal
// immutability trigger (20260803226000) freezes them.
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 100_000,
})
await getPool().query(
`UPDATE public.assets
SET disposed_proceeds_vat = 20000, disposed_vat_treatment = 'standard_25'
SET disposed_at = '2025-12-31', disposed_proceeds = 100000,
disposed_proceeds_vat = 20000, disposed_vat_treatment = 'standard_25'
WHERE id = $1`,
[assetId],
)
@@ -420,12 +422,13 @@ describe('assets: disposal VAT + jämkning constraints', () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 100_000,
})
await expect(
getPool().query(
`UPDATE public.assets SET disposed_vat_treatment = 'reduced_999' WHERE id = $1`,
`UPDATE public.assets
SET disposed_at = '2025-12-31', disposed_proceeds = 100000,
disposed_vat_treatment = 'reduced_999'
WHERE id = $1`,
[assetId],
),
).rejects.toThrow(/check/i)
@@ -435,20 +438,36 @@ describe('assets: disposal VAT + jämkning constraints', () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 100_000,
})
// Treatment NULL + VAT > 0 must violate the consistency CHECK.
await expect(
getPool().query(
`UPDATE public.assets
SET disposed_proceeds_vat = 20000, disposed_vat_treatment = NULL
SET disposed_at = '2025-12-31', disposed_proceeds = 100000,
disposed_proceeds_vat = 20000, disposed_vat_treatment = NULL
WHERE id = $1`,
[assetId],
),
).rejects.toThrow(/check|consistency/i)
})
it('freezes disposal attributes once the asset is disposed', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 100_000,
})
await expect(
getPool().query(
`UPDATE public.assets
SET disposed_proceeds_vat = 20000, disposed_vat_treatment = 'standard_25'
WHERE id = $1`,
[assetId],
),
).rejects.toThrow(/disposed asset/i)
})
it('accepts zero VAT with null treatment (legacy / non-VAT disposal)', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
@@ -469,12 +488,12 @@ describe('assets: disposal VAT + jämkning constraints', () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 60_000,
})
await getPool().query(
`UPDATE public.assets
SET jamkning_amount = 8000,
SET disposed_at = '2025-12-31',
disposed_proceeds = 60000,
jamkning_amount = 8000,
jamkning_remaining_months = 24,
jamkning_total_months = 60,
jamkning_original_input_vat = 20000
+14 -3
View File
@@ -3334,6 +3334,9 @@ export type DepreciationMethod =
| 'declining_balance_20'
| 'restvardesavskrivning_25'
export type AssetDisposalType = 'sale' | 'scrap' | 'business_transfer'
export type AssetJamkningDirection = 'increase' | 'decrease' | 'none' | 'transferred'
/**
* K3 component (BFNAR 2012:1 ch 17.4: komponentavskrivning). When a
* substantial asset (typically real estate) has significant components with
@@ -3378,6 +3381,10 @@ export interface Asset {
restvarde_target: number | null
disposed_at: string | null
disposed_proceeds: number | null
/** How the asset left the register. Null for legacy disposal records. */
disposal_type?: AssetDisposalType | null
/** Posted voucher that atomically completed the disposal. */
disposal_journal_entry_id?: string | null
/** Output VAT on disposal proceeds (ML 3 kap 3 § / 7 kap 3 §). Defaults to
* 0: only nonzero when the sale was momspliktig. The VAT account
* (2611/2621/2631) is derived from disposed_vat_treatment. */
@@ -3386,9 +3393,7 @@ export interface Asset {
* without VAT data. Constrained by DB CHECK to the same enum as
* VatTreatment. */
disposed_vat_treatment: VatTreatment | null
/** Jämkning amount per ML 8a kap 7 §: input VAT paid back on disposal
* inside the correction period. Defaults to 0; positive number = debt
* to the state booked on 2641 credit. */
/** Absolute input VAT adjustment under ML (2023:200), chapter 15. */
jamkning_amount: number
/** Remaining months in the korrigeringstid at disposal date. Audit
* metadata only: the booking sits on the journal entry. */
@@ -3399,6 +3404,12 @@ export interface Asset {
/** Original input VAT that was deducted at acquisition. Audit metadata
* the user supplies (or the system derives from the supplier invoice). */
jamkning_original_input_vat: number | null
/** Current-law adjustment metadata. Old month fields remain for legacy rows. */
jamkning_direction?: AssetJamkningDirection | null
jamkning_remaining_years?: number | null
jamkning_total_years?: number | null
jamkning_original_deduction_percent?: number | null
jamkning_new_deduction_percent?: number | null
/** K3 component depreciation (BFNAR 2012:1 ch.17.4). When non-null, the
* depreciation engine sums per-component linear depreciation instead of
* applying `depreciation_method` to the asset as a whole. Null for K2