Files
accounted/components/bookkeeping/year-end/DispositionsStep.tsx
T
12c59399ee fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) (#1421)
* fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) and confirm K3 to K2 reversion

In juridisk person K3 29.37 keeps obeskattade reserver at gross; the 79.4/20.6 split belongs to koncernredovisning. The old disposition double-counted the tax portion (result charged twice, 2240 overstated on top of gross 21xx). Removes the proposal step, POST kind, UI case, K2-to-K3 account seeding and the interim framework gate; keeps LATENT_TAX_DEFAULT_RATE for analytical soliditet presentation. Also adds the K3-to-K2 consequence confirmation dialog in settings.

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

* docs(decisions): scope the batch log to shipped code and record the 29.37 election nuance

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

* fix(settings): stop promising deferred-tax accounting the engine no longer does

The framework help text and the K2-to-K3 confirmation both told the user that switching to K3 means uppskjuten skatt is recognised separately on 2240/8940 with a 79.4/20.6 split. This PR removes exactly that behaviour, so the copy would have promised something the product does not do, which is the defect class this batch exists to remove. Both now describe what actually happens: kassaflodesanalys, komponentavskrivning and a wider note set, with obeskattade reserver carried gross per K3 29.37.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:05:15 +02:00

754 lines
25 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowRight, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { DepreciationPanel } from './DepreciationPanel'
import { EfDeclarationSection } from './EfDeclarationSection'
import type {
DispositionsProposal,
ProposedDisposition,
DispositionKind,
TaxAdjustmentSnapshot,
} from '@/lib/bokslut/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface DispositionsStepProps {
periodId: string
onBack: () => void
onContinue: () => void
onNavigationBlockedChange?: (blocked: boolean) => void
}
interface UiState {
/** kind → user-controlled selection state */
selections: Record<string, { accept: boolean; overrideAmount?: number; lockedSkip: boolean }>
}
interface TaxAdjustmentDraft {
nonDeductibleExpenses: string
nonTaxableIncome: string
detectedAccounts: { '6992': boolean; '8423': boolean }
}
/**
* Phase 2 bokslutsdispositioner step. Fetches proposals from the dispositions
* API, lets the user adjust amounts (or skip) per proposal, then POSTs the
* accepted ones. Mandatory p-fond reversals (cohort ≥ 6 years old) cannot be
* skipped: checkbox stays disabled-on.
*
* 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,
onNavigationBlockedChange,
}: DispositionsStepProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState<DispositionsProposal | null>(null)
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState<string | null>(null)
const [ui, setUi] = useState<UiState>({ selections: {} })
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
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 () => {
setLoading(true)
setFetchError(null)
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`,
)
const body = await res.json()
if (!res.ok) {
setFetchError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda dispositioner')
return
}
const data = body.data as DispositionsProposal
setProposal(data)
setUi(createUiState(data))
setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments))
} catch {
setFetchError('Kunde inte ladda dispositioner')
} finally {
setLoading(false)
}
}, [periodId])
const handleSaveAdjustments = useCallback(async () => {
setSavingAdjustments(true)
setAdjustmentError(null)
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
manualAdjustments: {
nonDeductibleExpenses: parseNonNegativeAmount(
taxAdjustmentDraft.nonDeductibleExpenses,
),
nonTaxableIncome: parseNonNegativeAmount(taxAdjustmentDraft.nonTaxableIncome),
},
detectedAccounts: taxAdjustmentDraft.detectedAccounts,
}),
},
)
const body = await res.json()
if (!res.ok) {
setAdjustmentError(
getUserErrorMessage(body?.error) ?? 'Kunde inte spara skattemässiga justeringar',
)
return
}
const data = body.data as DispositionsProposal
setProposal(data)
setUi(createUiState(data))
setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments))
toast({
title: 'Skatteunderlaget är uppdaterat',
description: 'Bolagsskatten har räknats om utan att ändra periodiseringsfonden.',
})
} catch (err) {
setAdjustmentError(
err instanceof Error
? getUserErrorMessage(err)
: 'Kunde inte spara skattemässiga justeringar',
)
} finally {
setSavingAdjustments(false)
}
}, [periodId, taxAdjustmentDraft, toast])
useEffect(() => {
void loadProposals()
}, [loadProposals])
// ---- 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
}
setPosting(true)
setPostError(null)
try {
const items = buildPostItems(proposal, ui)
if (items.length === 0) {
// Nothing selected: just move on
onContinue()
return
}
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
})
const body = await res.json()
if (!res.ok) {
setPostError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra dispositioner')
return
}
const created = body.data?.created ?? []
toast({
title: `${created.length} verifikation${created.length === 1 ? '' : 'er'} bokförd${
created.length === 1 ? '' : 'a'
}`,
description: 'Dispositionerna ligger nu i bokföringen.',
})
onContinue()
} catch (err) {
setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
}, [proposal, ui, periodId, onContinue, toast, taxDepreciationDirty])
// ---- Render branches ----
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
)
}
if (fetchError) {
return (
<Card>
<CardContent className="p-6">
<p className="text-destructive">{fetchError}</p>
</CardContent>
</Card>
)
}
if (!proposal) return null
const hasCorrectionRequired = proposal.completedDispositions?.some(
(item) => item.status === 'needs_correction',
) ?? false
// EF: depreciation can apply (skattemässig hanteras separat); replace the
// AB-only dispositioner with a NE-bilaga declaration section.
if (proposal.entityType !== 'aktiebolag') {
const fiscalYear = parseInt(proposal.fiscalPeriod.period_end.slice(0, 4), 10)
return (
<div className="space-y-6">
<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}
disabled={taxDepreciationDirty}
>
← Tillbaka
</Button>
<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>
</div>
)
}
return (
<div className="space-y-6">
<DepreciationPanel
periodId={periodId}
onPosted={() => void loadProposals()}
onTaxDirtyChange={setTaxDepreciationDirty}
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Bokslutsdispositioner</CardTitle>
<p className="text-sm text-muted-foreground">
Resultat före dispositioner:{' '}
<span className="tabular-nums font-medium">
{formatCurrency(proposal.netResultBefore)}
</span>
. Justera beloppen och bocka av de dispositioner du vill boka.
</p>
</CardHeader>
</Card>
{proposal.taxAdjustments && (
<TaxAdjustmentsCard
snapshot={proposal.taxAdjustments}
draft={taxAdjustmentDraft}
taxProposal={proposal.proposals.find((item) => item.kind === 'bolagsskatt')}
saving={savingAdjustments}
error={adjustmentError}
onChange={setTaxAdjustmentDraft}
onSave={() => void handleSaveAdjustments()}
/>
)}
{proposal.warnings?.map((warning) => (
<Card key={warning}>
<CardContent className="p-4 text-sm text-warning-foreground flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{warning}</span>
</CardContent>
</Card>
))}
{(proposal.completedDispositions ?? []).map((completed) => (
<Card key={`completed-${completed.kind}`}>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">{completed.label}</CardTitle>
<Badge
variant={completed.status === 'booked' ? 'success' : 'warning'}
className="mt-2 gap-1"
>
{completed.status === 'booked' ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{completed.status === 'booked' ? 'Redan bokförd' : 'Behöver rättas'}
</Badge>
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(completed.amount)}
</p>
</div>
</CardHeader>
{completed.warnings.length > 0 && (
<CardContent className="space-y-2">
{completed.warnings.map((warning) => (
<p key={warning} className="text-sm text-warning-foreground">{warning}</p>
))}
</CardContent>
)}
</Card>
))}
{proposal.proposals.length === 0
&& (proposal.completedDispositions ?? []).length === 0 && (
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
Inga bokslutsdispositioner behöver bokföras utifrån det aktuella underlaget.
</CardContent>
</Card>
)}
{proposal.proposals.map((p, i) => {
const key = proposalKey(p, i)
const sel = ui.selections[key]
if (!sel) return null
return (
<ProposalCard
key={key}
proposal={p}
accept={sel.accept}
overrideAmount={sel.overrideAmount}
lockedSkip={sel.lockedSkip}
onChange={(next) => {
setUi((prev) => ({
selections: { ...prev.selections, [key]: { ...sel, ...next } },
}))
}}
/>
)
})}
{postError && (
<Card>
<CardContent className="p-4 text-sm text-destructive">{postError}</CardContent>
</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 || taxDepreciationDirty}
>
← Tillbaka
</Button>
<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…
</>
) : (
<>
Bokför valda dispositioner <ArrowRight className="ml-1 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
function TaxAdjustmentsCard({
snapshot,
draft,
taxProposal,
saving,
error,
onChange,
onSave,
}: {
snapshot: TaxAdjustmentSnapshot
draft: TaxAdjustmentDraft
taxProposal?: ProposedDisposition
saving: boolean
error: string | null
onChange: (draft: TaxAdjustmentDraft) => void
onSave: () => void
}) {
const detected = snapshot.items.filter(
(item) => item.source === 'detected' && item.accountNumber && item.amount > 0,
)
const computation = taxProposal?.computation
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Skattemässiga justeringar</CardTitle>
<p className="text-sm text-muted-foreground">
Återläggningar påverkar skatten och INK2, men skapar ingen egen verifikation och ändrar
inte en redan bokförd periodiseringsfond.
</p>
</CardHeader>
<CardContent className="space-y-5">
{detected.length > 0 && (
<div className="space-y-3">
<Label>Upptäckt i bokföringen</Label>
{detected.map((item) => {
const account = item.accountNumber as '6992' | '8423'
return (
<div key={item.sourceKey} className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Checkbox
id={`tax-adjustment-${account}`}
checked={draft.detectedAccounts[account]}
onCheckedChange={(checked) =>
onChange({
...draft,
detectedAccounts: {
...draft.detectedAccounts,
[account]: Boolean(checked),
},
})
}
/>
<Label htmlFor={`tax-adjustment-${account}`} className="cursor-pointer">
Konto {account}: {item.description}
</Label>
</div>
<span className="tabular-nums font-medium">{formatCurrency(item.amount)}</span>
</div>
)
})}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="manual-non-deductible">Ytterligare ej avdragsgilla kostnader</Label>
<Input
id="manual-non-deductible"
type="number"
min="0"
step="0.01"
value={draft.nonDeductibleExpenses}
onChange={(event) =>
onChange({ ...draft, nonDeductibleExpenses: event.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-non-taxable">Ej skattepliktiga intäkter</Label>
<Input
id="manual-non-taxable"
type="number"
min="0"
step="0.01"
value={draft.nonTaxableIncome}
onChange={(event) => onChange({ ...draft, nonTaxableIncome: event.target.value })}
/>
</div>
</div>
{computation && (
<div className="rounded-lg bg-muted/50 p-4 text-sm space-y-1">
<CalculationRow label="Resultat efter dispositioner" amount={numberValue(computation.resultBeforeTax)} />
<CalculationRow label="Ej avdragsgilla kostnader" amount={numberValue(computation.nonDeductibleExpenses)} prefix="+" />
<CalculationRow label="Ej skattepliktiga intäkter" amount={numberValue(computation.nonTaxableIncome)} prefix="-" />
<CalculationRow label="Skattemässigt resultat" amount={numberValue(computation.taxableResultClamped)} strong />
<CalculationRow label="Bolagsskatt 20,6 %" amount={numberValue(computation.taxAmount)} strong />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="button" variant="outline" onClick={onSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara och räkna om
</Button>
</CardContent>
</Card>
)
}
function CalculationRow({
label,
amount,
prefix = '',
strong = false,
}: {
label: string
amount: number
prefix?: string
strong?: boolean
}) {
return (
<div className={`flex justify-between gap-4 ${strong ? 'font-medium' : ''}`}>
<span>{label}</span>
<span className="tabular-nums">{prefix}{formatCurrency(amount)}</span>
</div>
)
}
function ProposalCard({
proposal,
accept,
overrideAmount,
lockedSkip,
onChange,
}: {
proposal: ProposedDisposition
accept: boolean
overrideAmount: number | undefined
lockedSkip: boolean
onChange: (next: { accept?: boolean; overrideAmount?: number }) => void
}) {
const overridable = isOverridable(proposal)
const displayedAmount = overridable ? overrideAmount ?? proposal.amount : proposal.amount
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-base">{proposal.label}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">{proposal.description}</p>
{proposal.required && (
<Badge variant="warning" className="mt-2 gap-1">
<AlertTriangle className="h-3.5 w-3.5" /> Obligatorisk
</Badge>
)}
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(displayedAmount)}
</p>
</div>
</CardHeader>
<CardContent className="space-y-3">
{proposal.warnings.map((w, i) => (
<p key={i} className="text-sm text-warning-foreground">
{w}
</p>
))}
{overridable && (
<div className="flex items-center gap-3">
<Label htmlFor={`amount-${proposal.kind}`} className="text-sm shrink-0">
Belopp (kr)
</Label>
<Input
id={`amount-${proposal.kind}`}
type="number"
step="1"
className="max-w-[180px] tabular-nums"
value={overrideAmount ?? proposal.amount}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
onChange({ overrideAmount: Number.isFinite(value) ? value : 0 })
}}
/>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<Checkbox
id={`accept-${proposal.kind}`}
checked={accept}
disabled={lockedSkip}
onCheckedChange={(checked) => onChange({ accept: Boolean(checked) })}
/>
<Label
htmlFor={`accept-${proposal.kind}`}
className="text-sm cursor-pointer select-none"
>
{accept ? 'Boka denna disposition' : 'Hoppa över'}
{lockedSkip && (
<span className="text-muted-foreground ml-2 text-xs">(kan inte hoppas över)</span>
)}
</Label>
</div>
</CardContent>
</Card>
)
}
function isOverridable(proposal: ProposedDisposition): boolean {
// Bolagsskatt and SLP are derived from posted entries: overriding the amount
// would silently break the journal posting (the calculator would still
// recompute server-side). p-fond avsättning and överavskrivningar take a
// desired amount as input, so editing is meaningful. p-fond återföring is
// composed of mandatory cohorts and isn't safely editable from a single
// amount field.
return (
proposal.kind === 'periodiseringsfond_avsattning'
|| (
proposal.kind === 'overavskrivningar'
&& (proposal.signedAmount ?? proposal.amount) > 0
)
)
}
function proposalKey(p: ProposedDisposition, index = 0): string {
// For återföring there can be multiple cards (one per cohort): disambiguate
// by including the line account in the key. For other kinds, the kind is unique.
if (p.kind === 'periodiseringsfond_ateforing') {
return `${p.kind}:${p.lines[0]?.account_number ?? index}`
}
return `${p.kind}:${index}`
}
interface PostItem {
kind: DispositionKind
[key: string]: unknown
}
function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[] {
const items: PostItem[] = []
// Group återföring entries: server expects a single item with a `returns`
// map keyed by cohort account.
const ateforingReturns: Record<string, number> = {}
for (const p of proposal.proposals) {
const key = proposalKey(p, proposal.proposals.indexOf(p))
const sel = ui.selections[key]
if (!sel || !sel.accept) continue
switch (p.kind) {
case 'bolagsskatt':
items.push({ kind: 'bolagsskatt' })
break
case 'sarskild_loneskatt':
items.push({ kind: 'sarskild_loneskatt' })
break
case 'periodiseringsfond_avsattning':
items.push({
kind: 'periodiseringsfond_avsattning',
desiredAmount: sel.overrideAmount ?? p.amount,
})
break
case 'periodiseringsfond_ateforing': {
const account = p.lines[0]?.account_number
if (account) ateforingReturns[account] = p.amount
break
}
case 'overavskrivningar': {
const signedDefault = p.signedAmount ?? p.amount
const selectedAmount = sel.overrideAmount ?? p.amount
items.push({
kind: 'overavskrivningar',
additionalAmount:
signedDefault < 0 ? -Math.abs(selectedAmount) : selectedAmount,
})
break
}
}
}
if (Object.keys(ateforingReturns).length > 0) {
items.push({ kind: 'periodiseringsfond_ateforing', returns: ateforingReturns })
}
return items
}
const emptyTaxDraft: TaxAdjustmentDraft = {
nonDeductibleExpenses: '0',
nonTaxableIncome: '0',
detectedAccounts: { '6992': false, '8423': false },
}
function createUiState(proposal: DispositionsProposal): UiState {
const selections: UiState['selections'] = {}
proposal.proposals.forEach((item, index) => {
const key = proposalKey(item, index)
selections[key] = {
accept: true,
overrideAmount: item.amount,
lockedSkip: Boolean(item.required),
}
})
return { selections }
}
function createTaxAdjustmentDraft(
snapshot: TaxAdjustmentSnapshot | undefined,
): TaxAdjustmentDraft {
if (!snapshot) return emptyTaxDraft
const manualNonDeductible = snapshot.items.find(
(item) => item.sourceKey === 'manual:non_deductible_expenses',
)
const manualNonTaxable = snapshot.items.find(
(item) => item.sourceKey === 'manual:non_taxable_income',
)
const account6992 = snapshot.items.find((item) => item.sourceKey === 'account:6992')
const account8423 = snapshot.items.find((item) => item.sourceKey === 'account:8423')
return {
nonDeductibleExpenses: String(manualNonDeductible?.amount ?? 0),
nonTaxableIncome: String(manualNonTaxable?.amount ?? 0),
detectedAccounts: {
'6992': Boolean(account6992?.included),
'8423': Boolean(account8423?.included),
},
}
}
function parseNonNegativeAmount(value: string): number {
const parsed = Number(value.replace(',', '.'))
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
function numberValue(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}