feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)

* feat(bokslut): IL 18 kap pooled tax depreciation with method election

Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25
as a company-level annual pool separate from per-asset book depreciation.
Method election persisted with immutable snapshots and book-conformity
confirmation for rakenskapsenlig.

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

* chore(db): move tax depreciation migrations to coordinated versions

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

* fix(bokslut): keep tax depreciation view loadable when a saved election goes stale

A predecessor's changed closing value can push a saved elected deduction
above the new statutory maximum; the view now falls back to the statutory
recomputation so the snapshot is flagged stale instead of crashing.
Ratchet naive-ore-round baseline down by 3.

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

* fix(bokslut): resolve tax-depreciation period selects statically

The no-phantom-columns guard counts every select it cannot resolve
toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo
4 over (364 > 360). Inline the literal column list at the four call
sites so the guard verifies these columns instead of skipping them.

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

* fix(bokslut): address review findings on tax depreciation election

- DepreciationPanel: gate the saving flag on a dedicated save sequence
  so a successful save (which refreshes the view and bumps the request
  version) no longer leaves the card permanently busy
- computeTaxDepreciation: refuse kompletteringsregel_20 with a positive
  basis and no acquisition cohorts instead of degenerating to a full
  write-off the cohort evidence does not support (IL 18 kap. 17 §)
- migration 227000: judge the asset-method guards on NEW.disposed_at so
  reversing a disposal cannot reactivate a grandfathered non-linear row
- migration 227200: require snapshot column completeness in the CHECK;
  SQL NULL semantics let partially populated snapshots pass the pure
  arithmetic comparisons
- depreciation route: use the string issue code 'custom' like the rest
  of the codebase instead of the Zod 3 compat enum

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-04 11:43:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 00ae3540db
commit 5ca64bde30
25 changed files with 2863 additions and 657 deletions
@@ -22,7 +22,7 @@ import { Loader2, Plus, X } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
import type { AssetCategory, DepreciationMethod, K3Component } from '@/types'
import type { AssetCategory, K3Component } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface CreateAssetDialogProps {
@@ -67,29 +67,6 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: num
{ value: 'other_tangible', label: 'Övrig materiell tillgång', defaultYears: 5 },
]
const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; hint: string }[] = [
{
value: 'linear',
label: 'Linjär',
hint: 'Planenlig raklinje över nyttjandeperioden (ÅRL 4 kap 4§).',
},
{
value: 'declining_balance_30',
label: 'Räkenskapsenlig 30 %',
hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.',
},
{
value: 'declining_balance_20',
label: 'Räkenskapsenlig 20 %',
hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.',
},
{
value: 'restvardesavskrivning_25',
label: 'Restvärdeavskrivning 25 %',
hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.',
},
]
export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAssetDialogProps) {
const { toast } = useToast()
// useCompanyOptional so the dialog still works in tests / storyboards
@@ -104,8 +81,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
)
const [acquisitionCost, setAcquisitionCost] = useState('')
const [usefulLifeYears, setUsefulLifeYears] = useState('5')
const [depreciationMethod, setDepreciationMethod] = useState<DepreciationMethod>('linear')
const [restvardeTarget, setRestvardeTarget] = useState('')
// K3 component depreciation. `useComponents` toggles the advanced section;
// null when disabled, an array (possibly empty during editing) when enabled.
const [useComponents, setUseComponents] = useState(false)
@@ -119,10 +94,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
if (option) setUsefulLifeYears(option.defaultYears.toString())
}
const isRestvarde = depreciationMethod === 'restvardesavskrivning_25'
const methodHint =
DEPRECIATION_METHOD_OPTIONS.find((o) => o.value === depreciationMethod)?.hint ?? ''
const totalComponentCost = useMemo(() => {
return componentRows.reduce((sum, row) => {
const v = parseFloat(row.cost)
@@ -161,19 +132,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
setError('Fyll i namn, anskaffningsvärde och avskrivningstid.')
return
}
let restvardeTargetNumber: number | null = null
if (isRestvarde) {
const parsed = parseFloat(restvardeTarget)
if (!Number.isFinite(parsed) || parsed < 0) {
setError('Ange ett restvärde (0 kr eller högre).')
return
}
if (parsed >= cost) {
setError('Restvärdet måste vara lägre än anskaffningsvärdet.')
return
}
restvardeTargetNumber = parsed
}
// K3 components: only when both the framework permits (gate at API)
// and the user opted into the section. Empty array is invalid (the
// validator rejects it) so the dialog also flips back to "off" when
@@ -239,10 +197,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
acquisition_date: acquisitionDate,
acquisition_cost: cost,
useful_life_months: years * 12,
depreciation_method: depreciationMethod,
...(restvardeTargetNumber !== null
? { restvarde_target: restvardeTargetNumber }
: {}),
...(componentsPayload !== null ? { k3_components: componentsPayload } : {}),
}),
})
@@ -255,8 +209,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
// Reset form for next entry
setName('')
setAcquisitionCost('')
setDepreciationMethod('linear')
setRestvardeTarget('')
setUseComponents(false)
setComponentRows([])
onCreated()
@@ -340,44 +292,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
För skattemässig avskrivning kan annan livslängd gälla (IL 18-20 kap).
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="asset-method">Avskrivningsmetod</Label>
<Select
value={depreciationMethod}
onValueChange={(v) => setDepreciationMethod(v as DepreciationMethod)}
>
<SelectTrigger id="asset-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPRECIATION_METHOD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{methodHint}</p>
</div>
{isRestvarde && (
<div className="space-y-1.5">
<Label htmlFor="asset-restvarde">Restvärde (kr)</Label>
<Input
id="asset-restvarde"
type="number"
min="0"
step="1"
value={restvardeTarget}
onChange={(e) => setRestvardeTarget(e.target.value)}
placeholder="t.ex. 5000"
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">
Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet
måste vara lägre än anskaffningsvärdet.
</p>
</div>
)}
{isK3 && (
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-start justify-between gap-4">
@@ -18,11 +18,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { AlertTriangle, Loader2, Lock } from 'lucide-react'
import { Loader2, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { Asset, AssetCategory, DepreciationMethod } from '@/types'
import type { Asset, AssetCategory } from '@/types'
/** The list route annotates each asset with whether any depreciation has been
* posted against it. When true, the acquisition-basis fields are locked. */
@@ -35,8 +35,7 @@ interface EditAssetDialogProps {
onSaved: () => void
}
// Same category labels and depreciation hints as CreateAssetDialog (kept in
// sync deliberately: this register surface is Swedish-only, like creation).
// Same category labels as CreateAssetDialog.
const CATEGORY_OPTIONS: { value: AssetCategory; label: string }[] = [
{ value: 'computer', label: 'Dator / IT-utrustning' },
{ value: 'equipment', label: 'Inventarier' },
@@ -48,29 +47,6 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string }[] = [
{ value: 'other_tangible', label: 'Övrig materiell tillgång' },
]
const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; hint: string }[] = [
{
value: 'linear',
label: 'Linjär',
hint: 'Planenlig raklinje över nyttjandeperioden (ÅRL 4 kap 4§).',
},
{
value: 'declining_balance_30',
label: 'Räkenskapsenlig 30 %',
hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.',
},
{
value: 'declining_balance_20',
label: 'Räkenskapsenlig 20 %',
hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.',
},
{
value: 'restvardesavskrivning_25',
label: 'Restvärdeavskrivning 25 %',
hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.',
},
]
export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAssetDialogProps) {
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -86,19 +62,9 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
const [usefulLifeYears, setUsefulLifeYears] = useState(
String(Math.round(asset.useful_life_months / 12)),
)
const [depreciationMethod, setDepreciationMethod] = useState<DepreciationMethod>(
asset.depreciation_method,
)
const [restvardeTarget, setRestvardeTarget] = useState(
asset.restvarde_target != null ? String(asset.restvarde_target) : '',
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const isRestvarde = depreciationMethod === 'restvardesavskrivning_25'
const methodHint =
DEPRECIATION_METHOD_OPTIONS.find((o) => o.value === depreciationMethod)?.hint ?? ''
const handleSubmit = async () => {
setError(null)
const trimmedName = name.trim()
@@ -133,31 +99,6 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
const months = years * 12
if (months !== asset.useful_life_months) patch.useful_life_months = months
if (depreciationMethod !== asset.depreciation_method) {
patch.depreciation_method = depreciationMethod
}
if (isRestvarde) {
const target = parseFloat(restvardeTarget)
const cost = !basisLocked ? parseFloat(acquisitionCost) : Number(asset.acquisition_cost)
if (!Number.isFinite(target) || target < 0) {
setError('Ange ett restvärde (0 kr eller högre).')
return
}
if (Number.isFinite(cost) && target >= cost) {
setError('Restvärdet måste vara lägre än anskaffningsvärdet.')
return
}
// Send the target when switching into restvärde or when it changed, so
// the method/target biconditional always holds.
if (
depreciationMethod !== asset.depreciation_method ||
target !== Number(asset.restvarde_target)
) {
patch.restvarde_target = target
}
}
if (Object.keys(patch).length === 0) {
toast({ title: 'Inga ändringar', description: 'Inget att spara.' })
onOpenChange(false)
@@ -255,7 +196,7 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
<span>
Anskaffningsdatum, anskaffningsvärde och kategori är låsta eftersom avskrivningar
redan har bokförts. Återför avskrivningen (storno) eller använd avyttring för att
ändra grunduppgifterna. Namn, avskrivningstid och metod kan fortfarande justeras.
ändra grunduppgifterna. Namn och avskrivningstid kan fortfarande justeras.
</span>
</div>
)}
@@ -274,58 +215,6 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-asset-method">Avskrivningsmetod</Label>
<Select
value={depreciationMethod}
onValueChange={(v) => setDepreciationMethod(v as DepreciationMethod)}
>
<SelectTrigger id="edit-asset-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPRECIATION_METHOD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{methodHint}</p>
</div>
{basisLocked && depreciationMethod !== asset.depreciation_method && (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Byte av avskrivningsmetod efter att avskrivning påbörjats. Enligt K2
(BFNAR 2016:10 p. 10.26) ska vald metod tillämpas konsekvent: ändra
bara vid särskilda skäl och lämna i så fall upplysning i bokslutet.
Ändringen gäller framåt; redan bokförda avskrivningar påverkas inte.
</span>
</div>
)}
{isRestvarde && (
<div className="space-y-1.5">
<Label htmlFor="edit-asset-restvarde">Restvärde (kr)</Label>
<Input
id="edit-asset-restvarde"
type="number"
min="0"
step="1"
value={restvardeTarget}
onChange={(e) => setRestvardeTarget(e.target.value)}
placeholder="t.ex. 5000"
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">
Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet måste vara lägre
än anskaffningsvärdet.
</p>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
@@ -1,11 +1,21 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2 } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import Link from 'next/link'
import {
Table,
@@ -35,19 +45,73 @@ interface Proposal {
totalAmount: number
}
type TaxMethod = 'rakenskapsenlig' | 'restvarde'
type TaxRule = 'huvudregel_30' | 'kompletteringsregel_20'
interface TaxAlternative {
rule: TaxRule | 'restvarde_25'
rate: number | null
deduction: number
closingTaxValue: number
}
interface TaxResult {
openingTaxValue: number
additions: number
disposals: number
basis: number
maximumDeduction: number
deduction: number
closingTaxValue: number
excessDisposals: number
alternatives: TaxAlternative[]
}
interface TaxView {
status:
| 'needs_previous_period'
| 'needs_period_history'
| 'needs_method'
| 'needs_opening_value'
| 'needs_rule'
| 'ready'
method: TaxMethod | null
selectedRule: TaxRule | null
methodLocked: boolean
openingTaxValue: number | null
openingSource: 'saved' | 'previous_period' | 'previous_period_required' | 'manual_required'
periodMonths: number
eligibleAssetCount: number
excludedAssetCount: number
excludedCategories: string[]
cohortHistoryComplete: boolean
incompleteCohortCount: number
result: TaxResult | null
snapshot: { deduction: number } | null
isStale: boolean
}
interface DepreciationPanelProps {
periodId: string
/** Called after a successful post: parent refetches dispositions because
* posted avskrivningar change the result which affects bolagsskatt etc. */
onPosted: () => void
onTaxDirtyChange?: (dirty: boolean) => void
}
export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps) {
export function DepreciationPanel({ periodId, onPosted, onTaxDirtyChange }: DepreciationPanelProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState<Proposal | null>(null)
const [tax, setTax] = useState<TaxView | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const [taxDirty, setTaxDirty] = useState(false)
const handleTaxDirtyChange = useCallback((dirty: boolean) => {
setTaxDirty(dirty)
onTaxDirtyChange?.(dirty)
}, [onTaxDirtyChange])
const load = useCallback(async () => {
setLoading(true)
@@ -60,6 +124,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
return
}
setProposal(body.data as Proposal)
setTax(body.data.tax as TaxView)
} catch (err) {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
@@ -72,6 +137,10 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
}, [load])
const handlePost = useCallback(async () => {
if (taxDirty) {
setError('Spara eller återställ ändringarna i skattemässig avskrivning först.')
return
}
setPosting(true)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, {
@@ -97,7 +166,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
} finally {
setPosting(false)
}
}, [periodId, onPosted, load, toast])
}, [periodId, onPosted, load, toast, taxDirty])
if (loading) {
return (
@@ -120,6 +189,15 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
if (!proposal) return null
const taxPanel = tax ? (
<TaxDepreciationCard
periodId={periodId}
view={tax}
onSaved={load}
onDirtyChange={handleTaxDirtyChange}
/>
) : null
const allPosted =
proposal.items.length > 0 && proposal.items.every((i) => Boolean(i.existingJournalEntryId))
const anyPending =
@@ -127,22 +205,26 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
if (proposal.items.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Inga aktiva anläggningstillgångar att skriva av.{' '}
<Link href="/assets" className="text-primary hover:underline">
Lägg till tillgångar
</Link>{' '}
så räknar bokslutet ut avskrivningarna automatiskt.
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Inga aktiva anläggningstillgångar att skriva av.{' '}
<Link href="/assets" className="text-primary hover:underline">
Lägg till tillgångar
</Link>{' '}
så räknar bokslutet ut avskrivningarna automatiskt.
</CardContent>
</Card>
{taxPanel}
</div>
)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
@@ -195,7 +277,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
{anyPending && (
<div className="flex justify-end">
<Button onClick={handlePost} disabled={posting}>
<Button onClick={handlePost} disabled={posting || taxDirty}>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
@@ -208,5 +290,441 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
)}
</CardContent>
</Card>
{taxPanel}
</div>
)
}
function TaxDepreciationCard({
periodId,
view,
onSaved,
onDirtyChange,
}: {
periodId: string
view: TaxView
onSaved: () => Promise<void>
onDirtyChange?: (dirty: boolean) => void
}) {
const { toast } = useToast()
const [method, setMethod] = useState<TaxMethod>(view.method ?? 'rakenskapsenlig')
const [rule, setRule] = useState<TaxRule>(view.selectedRule ?? 'huvudregel_30')
const [openingValue, setOpeningValue] = useState(
view.openingTaxValue === null ? '' : String(view.openingTaxValue),
)
const [electedDeduction, setElectedDeduction] = useState(
view.snapshot ? String(view.snapshot.deduction) : '',
)
const [bookConformityConfirmed, setBookConformityConfirmed] = useState(false)
const [preview, setPreview] = useState<TaxView | null>(null)
const [dirty, setDirty] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const requestVersion = useRef(0)
const saveSequence = useRef(0)
useEffect(() => {
requestVersion.current += 1
setMethod(view.method ?? 'rakenskapsenlig')
setRule(view.selectedRule ?? 'huvudregel_30')
setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue))
setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '')
setBookConformityConfirmed(false)
setPreview(null)
setDirty(false)
}, [view])
useEffect(() => {
onDirtyChange?.(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange?.(false), [onDirtyChange])
const resetCalculatedDraft = () => {
setPreview(null)
setElectedDeduction('')
setBookConformityConfirmed(false)
setSaveError(null)
setDirty(true)
}
const restoreSavedValues = () => {
requestVersion.current += 1
setMethod(view.method ?? 'rakenskapsenlig')
setRule(view.selectedRule ?? 'huvudregel_30')
setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue))
setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '')
setBookConformityConfirmed(false)
setPreview(null)
setPreviewing(false)
setSaving(false)
setSaveError(null)
setDirty(false)
}
const parseOpening = (): number | null => {
if (openingValue.trim() === '') {
setSaveError('Ange skattemässigt värde vid årets ingång.')
return null
}
const opening = Number(openingValue.replace(',', '.'))
if (!Number.isFinite(opening) || opening < 0) {
setSaveError('Ange ett ingående skattemässigt värde på 0 kr eller mer.')
return null
}
return opening
}
const loadPreview = async () => {
setSaveError(null)
const opening = parseOpening()
if (opening === null) return
setDirty(true)
setPreviewing(true)
const version = ++requestVersion.current
try {
const query = new URLSearchParams({
tax_method: method,
opening_tax_value: String(opening),
})
if (method === 'rakenskapsenlig') query.set('tax_rule', rule)
const response = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/depreciation?${query.toString()}`,
)
const body = await response.json()
if (version !== requestVersion.current) return
if (!response.ok) {
setSaveError(getUserErrorMessage(body?.error ?? body) ?? 'Kunde inte beräkna förslaget')
return
}
const nextPreview = body.data.tax as TaxView
setPreview(nextPreview)
setElectedDeduction(
nextPreview.result ? String(nextPreview.result.maximumDeduction) : '',
)
setBookConformityConfirmed(false)
setDirty(true)
} catch (err) {
if (version !== requestVersion.current) return
setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
if (version === requestVersion.current) setPreviewing(false)
}
}
const save = async () => {
setSaveError(null)
const opening = parseOpening()
if (opening === null) return
const calculation = preview ?? (dirty && view.result ? view : null)
if (!calculation?.result) {
setSaveError('Beräkna förslaget innan valet sparas.')
return
}
if (electedDeduction.trim() === '') {
setSaveError('Ange årets faktiska skattemässiga avdrag.')
return
}
const deduction = Number(electedDeduction.replace(',', '.'))
if (!Number.isFinite(deduction) || deduction < 0) {
setSaveError('Årets faktiska avdrag måste vara 0 kr eller mer.')
return
}
if (deduction > calculation.result.maximumDeduction) {
setSaveError('Avdraget får inte överstiga högsta avdrag enligt den valda regeln.')
return
}
if (method === 'rakenskapsenlig' && !bookConformityConfirmed) {
setSaveError('Bekräfta att avdraget motsvarar bokslutets totala avskrivning.')
return
}
setSaving(true)
const version = ++requestVersion.current
const saveId = ++saveSequence.current
try {
const response = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method,
...(method === 'rakenskapsenlig' ? { selected_rule: rule } : {}),
opening_tax_value: opening,
elected_deduction: deduction,
...(method === 'rakenskapsenlig'
? { book_conformity_confirmed: bookConformityConfirmed }
: {}),
}),
})
const body = await response.json()
if (version !== requestVersion.current) return
if (!response.ok) {
setSaveError(getUserErrorMessage(body?.error) ?? 'Kunde inte spara beräkningen')
return
}
toast({ title: 'Skattemässig avskrivning sparad' })
setDirty(false)
await onSaved()
} catch (err) {
if (version !== requestVersion.current) return
setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
// A successful save triggers onSaved -> parent load -> new view prop,
// which bumps requestVersion before this finally runs. Gate on the
// save sequence instead so the card never stays stuck busy after its
// own save completes.
if (saveId === saveSequence.current) setSaving(false)
}
}
const calculation = dirty ? preview : view
const result = calculation?.result ?? null
const controlsBusy = previewing || saving
const formBlocked = view.openingSource === 'previous_period_required'
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">Skattemässig avskrivning</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
Beräkna högsta avdrag för den gemensamma inventariepoolen enligt IL 18 kap.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Du sparar det faktiska avdraget efter avstämning. Eventuell överavskrivning
bokförs separat.
</p>
</div>
{dirty && <Badge variant="warning">Ej sparad</Badge>}
{!dirty && view.snapshot && !view.isStale && (
<span className="text-xs text-muted-foreground">Sparad</span>
)}
{!dirty && view.isStale && <Badge variant="warning">Behöver sparas om</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
{view.openingSource === 'previous_period_required' && (
<p className="text-sm text-destructive" role="alert">
Spara skattemässig avskrivning för närmast föregående räkenskapsår först. Ett osparat
mellanår får inte hoppas över.
</p>
)}
{view.status === 'needs_period_history' && (
<p className="text-sm text-destructive" role="alert">
Kompletteringsregeln kan inte beräknas utan fullständig räkenskapsårshistorik för
alla kvarvarande inventarier. Välj huvudregeln eller komplettera periodhistoriken.
</p>
)}
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="tax-depreciation-method">Metod</Label>
<Select
value={method}
onValueChange={(value) => {
setMethod(value as TaxMethod)
resetCalculatedDraft()
}}
disabled={view.methodLocked || controlsBusy || formBlocked}
>
<SelectTrigger id="tax-depreciation-method"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="rakenskapsenlig">Räkenskapsenlig</SelectItem>
<SelectItem value="restvarde">Restvärdeavskrivning</SelectItem>
</SelectContent>
</Select>
{view.methodLocked && (
<p className="text-xs text-muted-foreground">
Metoden följer föregående sparade år. Ett byte kräver en separat övergångsbedömning.
</p>
)}
</div>
{method === 'rakenskapsenlig' && (
<div className="space-y-2">
<Label htmlFor="tax-depreciation-rule">Årets regel</Label>
<Select
value={rule}
onValueChange={(value) => {
setRule(value as TaxRule)
resetCalculatedDraft()
}}
disabled={controlsBusy || formBlocked}
>
<SelectTrigger id="tax-depreciation-rule"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="huvudregel_30">Huvudregeln 30 %</SelectItem>
<SelectItem value="kompletteringsregel_20">Kompletteringsregeln 20 %</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="tax-opening-value">Skattemässigt värde vid ingången</Label>
<Input
id="tax-opening-value"
type="number"
min="0"
step="0.01"
value={openingValue}
onChange={(event) => {
setOpeningValue(event.target.value)
resetCalculatedDraft()
}}
disabled={
view.openingSource === 'previous_period'
|| formBlocked
|| controlsBusy
}
className="tabular-nums"
/>
{view.openingSource === 'previous_period' && (
<p className="text-xs text-muted-foreground">Hämtat från föregående sparade år.</p>
)}
</div>
</div>
{dirty && !preview && (
<p className="rounded-md border border-border bg-muted/20 p-3 text-sm text-muted-foreground">
Inställningarna är ändrade. Beräkna förslaget för att se rätt gränsbelopp innan du
sparar.
</p>
)}
{result && (
<>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border border-border bg-muted/20 p-4 text-sm sm:grid-cols-3">
<TaxValue label="Ingående värde" value={result.openingTaxValue} />
<TaxValue label="Årets anskaffningar" value={result.additions} />
<TaxValue label="Årets avyttringar" value={-result.disposals} />
<TaxValue label="Avskrivningsunderlag" value={result.basis} />
<TaxValue label="Högsta avdrag" value={-result.maximumDeduction} />
{!dirty && view.snapshot ? (
<TaxValue label="Faktiskt sparat avdrag" value={-result.deduction} strong />
) : (
<TaxValue label="Lägsta skattemässigt värde" value={result.closingTaxValue} strong />
)}
</div>
{method === 'rakenskapsenlig' && (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Jämförelse
</p>
{result.alternatives.map((alternative) => (
<div key={alternative.rule} className="flex justify-between text-sm">
<span>{alternative.rule === 'huvudregel_30' ? '30-procentsregeln' : '20-procentsregeln'}</span>
<span className="tabular-nums">
{formatCurrency(alternative.closingTaxValue)} kvar
</span>
</div>
))}
</div>
)}
{dirty && (
<div className="space-y-2">
<Label htmlFor="tax-elected-deduction">Faktiskt avdrag efter avstämning</Label>
<Input
id="tax-elected-deduction"
type="number"
min="0"
max={result.maximumDeduction}
step="0.01"
value={electedDeduction}
onChange={(event) => setElectedDeduction(event.target.value)}
disabled={controlsBusy}
className="max-w-xs tabular-nums"
/>
<p className="text-xs text-muted-foreground">
Beloppet får vara lägre än gränsbeloppet men aldrig högre.
</p>
</div>
)}
{dirty && method === 'rakenskapsenlig' && (
<div className="flex items-start gap-2 rounded-md border border-border p-3">
<Checkbox
id="tax-book-conformity"
checked={bookConformityConfirmed}
onCheckedChange={(checked) => setBookConformityConfirmed(checked === true)}
disabled={controlsBusy}
/>
<Label htmlFor="tax-book-conformity" className="cursor-pointer text-sm leading-5">
Jag har stämt av att det faktiska avdraget motsvarar bokslutets totala
avskrivning, inklusive eventuell bokförd överavskrivning.
</Label>
</div>
)}
</>
)}
{view.excludedAssetCount > 0 && (
<p className="text-xs text-muted-foreground">
{view.excludedAssetCount} tillgång{view.excludedAssetCount === 1 ? '' : 'ar'} i andra
kategorier ingår inte i inventariepoolen och måste bedömas separat.
</p>
)}
{result && result.excessDisposals > 0 && (
<p className="text-xs text-destructive">
Avyttringsersättningen överstiger underlaget med {formatCurrency(result.excessDisposals)}.
Överskjutande belopp behöver hanteras i deklarationen.
</p>
)}
{saveError && <p className="text-sm text-destructive" role="alert">{saveError}</p>}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{view.eligibleAssetCount} tillgång{view.eligibleAssetCount === 1 ? '' : 'ar'} i poolen,
{view.periodMonths} månader i räkenskapsåret.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
{dirty && (
<Button
type="button"
variant="ghost"
onClick={restoreSavedValues}
disabled={controlsBusy}
className="w-full sm:w-auto"
>
Återställ
</Button>
)}
<Button
type="button"
variant="outline"
onClick={loadPreview}
disabled={
previewing
|| saving
|| formBlocked
}
className="w-full sm:w-auto"
>
{previewing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Beräkna förslag
</Button>
<Button
type="button"
onClick={save}
disabled={
saving
|| !dirty
|| !result
|| formBlocked
}
className="w-full sm:w-auto"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara faktiskt avdrag
</Button>
</div>
</div>
</CardContent>
</Card>
)
}
function TaxValue({ label, value, strong = false }: { label: string; value: number; strong?: boolean }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`tabular-nums ${strong ? 'font-medium' : ''}`}>{formatCurrency(value)}</p>
</div>
)
}
@@ -25,6 +25,7 @@ interface DispositionsStepProps {
periodId: string
onBack: () => void
onContinue: () => void
onNavigationBlockedChange?: (blocked: boolean) => void
}
interface UiState {
@@ -47,7 +48,12 @@ interface TaxAdjustmentDraft {
* EF companies get an empty `proposals` array from the server, so this step
* renders a short pass-through note and lets the user continue.
*/
export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsStepProps) {
export function DispositionsStep({
periodId,
onBack,
onContinue,
onNavigationBlockedChange,
}: DispositionsStepProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState<DispositionsProposal | null>(null)
const [loading, setLoading] = useState(true)
@@ -58,6 +64,13 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const [savingAdjustments, setSavingAdjustments] = useState(false)
const [adjustmentError, setAdjustmentError] = useState<string | null>(null)
const [taxAdjustmentDraft, setTaxAdjustmentDraft] = useState<TaxAdjustmentDraft>(emptyTaxDraft)
const [taxDepreciationDirty, setTaxDepreciationDirty] = useState(false)
useEffect(() => {
onNavigationBlockedChange?.(taxDepreciationDirty)
}, [onNavigationBlockedChange, taxDepreciationDirty])
useEffect(() => () => onNavigationBlockedChange?.(false), [onNavigationBlockedChange])
// ---- Fetch proposals ----
const loadProposals = useCallback(async () => {
@@ -136,6 +149,10 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
// ---- POST accepted dispositions ----
const handleCommit = useCallback(async () => {
if (!proposal) return
if (taxDepreciationDirty) {
setPostError('Spara eller återställ ändringarna i skattemässig avskrivning innan du fortsätter.')
return
}
if (proposal.completedDispositions?.some((item) => item.status === 'needs_correction')) {
setPostError('Rätta den bokförda bolagsskatten och ladda om sidan innan du fortsätter.')
return
@@ -172,7 +189,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
} finally {
setPosting(false)
}
}, [proposal, ui, periodId, onContinue, toast])
}, [proposal, ui, periodId, onContinue, toast, taxDepreciationDirty])
// ---- Render branches ----
if (loading) {
@@ -209,17 +226,41 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const fiscalYear = parseInt(proposal.fiscalPeriod.period_end.slice(0, 4), 10)
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<DepreciationPanel
periodId={periodId}
onPosted={() => void loadProposals()}
onTaxDirtyChange={setTaxDepreciationDirty}
/>
<EfDeclarationSection
fiscalPeriodId={periodId}
bookedSurplus={proposal.netResultBefore}
fiscalYear={fiscalYear}
/>
{taxDepreciationDirty && (
<p className="text-sm text-warning-foreground" role="status">
Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.
</p>
)}
<div className="flex justify-between">
<Button variant="outline" size="sm" onClick={onBack}>
<Button
variant="outline"
size="sm"
onClick={onBack}
disabled={taxDepreciationDirty}
>
← Tillbaka
</Button>
<Button variant="outline" size="sm" onClick={onContinue}>
<Button
variant="outline"
size="sm"
onClick={onContinue}
disabled={taxDepreciationDirty}
title={
taxDepreciationDirty
? 'Spara ändringarna i skattemässig avskrivning innan du fortsätter.'
: undefined
}
>
Nästa: Förhandsgranska →
</Button>
</div>
@@ -229,7 +270,11 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<DepreciationPanel
periodId={periodId}
onPosted={() => void loadProposals()}
onTaxDirtyChange={setTaxDepreciationDirty}
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Bokslutsdispositioner</CardTitle>
@@ -332,11 +377,30 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
</Card>
)}
{taxDepreciationDirty && (
<p className="text-sm text-warning-foreground" role="status">
Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.
</p>
)}
<div className="flex justify-between">
<Button variant="outline" size="sm" onClick={onBack} disabled={posting}>
<Button
variant="outline"
size="sm"
onClick={onBack}
disabled={posting || taxDepreciationDirty}
>
← Tillbaka
</Button>
<Button onClick={handleCommit} disabled={posting || hasCorrectionRequired}>
<Button
onClick={handleCommit}
disabled={posting || hasCorrectionRequired || taxDepreciationDirty}
title={
taxDepreciationDirty
? 'Spara ändringarna i skattemässig avskrivning innan du fortsätter.'
: undefined
}
>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…