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