Files
accounted/app/(dashboard)/bookkeeping/year-end/page.tsx
T
MattssonandClaude Fable 5 5ca64bde30 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>
2026-08-04 11:43:58 +02:00

393 lines
15 KiB
TypeScript

'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent } from '@/components/ui/card'
import { EmptyState } from '@/components/ui/empty-state'
import { ContextPicker } from '@/components/common/ContextPicker'
import { Skeleton } from '@/components/ui/skeleton'
import { CalendarPlus, Check, Lock } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { FiscalPeriod, YearEndPreview, YearEndResult } from '@/types'
import type { BokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
import { PreflightStep } from '@/components/bookkeeping/year-end/PreflightStep'
import { DispositionsStep } from '@/components/bookkeeping/year-end/DispositionsStep'
import { AccrualsStep } from '@/components/bookkeeping/year-end/AccrualsStep'
import { PreviewStep } from '@/components/bookkeeping/year-end/PreviewStep'
import { ExecuteStep } from '@/components/bookkeeping/year-end/ExecuteStep'
import { ResultStep } from '@/components/bookkeeping/year-end/ResultStep'
type Step = 'preflight' | 'accruals' | 'dispositions' | 'preview' | 'execute' | 'result'
const STEP_ORDER: Step[] = ['preflight', 'accruals', 'dispositions', 'preview', 'execute', 'result']
const STEP_LABELS: Record<Step, string> = {
preflight: 'Kontroll',
accruals: 'Periodiseringar',
dispositions: 'Dispositioner',
preview: 'Förhandsgranska',
execute: 'Verkställ',
result: 'Klart',
}
interface PeriodOption {
id: string
name: string
period_start: string
period_end: string
}
export default function YearEndPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
// ---- Period selection ----
const [periods, setPeriods] = useState<PeriodOption[] | null>(null)
// Whether the company has ANY fiscal periods (eligible or not) — separates
// "inget att stänga ännu" from "inget räkenskapsår har skapats".
const [hasAnyPeriods, setHasAnyPeriods] = useState(true)
const [periodsError, setPeriodsError] = useState<string | null>(null)
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(
searchParams.get('period') ?? null,
)
// ---- Wizard state ----
const [step, setStep] = useState<Step>('preflight')
const [report, setReport] = useState<BokslutReadinessReport | null>(null)
const [reportLoading, setReportLoading] = useState(false)
const [reportError, setReportError] = useState<string | null>(null)
const [preview, setPreview] = useState<YearEndPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [executing, setExecuting] = useState(false)
const [executeError, setExecuteError] = useState<string | null>(null)
const [result, setResult] = useState<YearEndResult | null>(null)
const [navigationBlocked, setNavigationBlocked] = useState(false)
// ---- Load eligible periods ----
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) {
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
return
}
const { data } = (await res.json()) as { data: FiscalPeriod[] }
const all = data ?? []
const today = new Date().toISOString().split('T')[0]
const eligible = all.filter(
(p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today,
)
// Oldest first: accountants close in order.
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
if (cancelled) return
setHasAnyPeriods(all.length > 0)
// The URL ?period= param may point anywhere: at an ineligible period
// (e.g. a year that has not ended) or, after a company switch, at a
// period that does not exist in this company at all. An unknown id is
// reset to the first eligible period; a known-but-ineligible one is
// kept selectable in the dropdown so the user can navigate away from
// it instead of being stuck (the readiness step explains why it
// cannot be closed).
let options = eligible
if (selectedPeriodId) {
const known = all.find((p) => p.id === selectedPeriodId)
if (!known) {
setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null)
} else if (!eligible.some((p) => p.id === selectedPeriodId)) {
options = [...eligible, known].sort((a, b) =>
a.period_start.localeCompare(b.period_start),
)
}
} else if (eligible.length > 0) {
setSelectedPeriodId(eligible[0].id)
}
setPeriods(options)
} catch {
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
}
}
void load()
return () => {
cancelled = true
}
}, [selectedPeriodId])
// ---- Sync selected period to URL so users can bookmark / share ----
useEffect(() => {
if (!selectedPeriodId) return
const params = new URLSearchParams(searchParams.toString())
if (params.get('period') === selectedPeriodId) return
params.set('period', selectedPeriodId)
router.replace(`/bookkeeping/year-end?${params.toString()}`, { scroll: false })
}, [selectedPeriodId, router, searchParams])
// ---- Fetch readiness report whenever selected period changes ----
useEffect(() => {
if (!selectedPeriodId) return
let cancelled = false
setReportLoading(true)
setReportError(null)
setReport(null)
fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/bokslut-readiness`)
.then(async (res) => {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setReportError(getErrorMessage(body?.error) ?? 'Kunde inte ladda bokslutskontroll')
return
}
setReport(body.data as BokslutReadinessReport)
})
.catch(() => {
if (!cancelled) setReportError('Kunde inte ladda bokslutskontroll')
})
.finally(() => {
if (!cancelled) setReportLoading(false)
})
return () => {
cancelled = true
}
}, [selectedPeriodId])
// ---- Step navigation ----
const goToPreview = useCallback(async () => {
if (!selectedPeriodId) return
setPreviewLoading(true)
setPreviewError(null)
setStep('preview')
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
const body = await res.json()
if (!res.ok) {
setPreviewError(getErrorMessage(body?.error) ?? 'Kunde inte hämta förhandsgranskning')
return
}
setPreview(body.data.preview as YearEndPreview)
} catch (err) {
setPreviewError(getErrorMessage(err))
} finally {
setPreviewLoading(false)
}
}, [selectedPeriodId])
const executeYearEnd = useCallback(async () => {
if (!selectedPeriodId) return
setExecuting(true)
setExecuteError(null)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`, {
method: 'POST',
})
const body = await res.json()
if (!res.ok) {
// body.error.message is the localized Swedish message picked by
// the structured-error registry. Do NOT interpolate raw details
// here: they can contain DB-sourced strings (V2.3 finding).
setExecuteError(getErrorMessage(body?.error) ?? 'Bokslutet kunde inte verkställas')
return
}
setResult(body.data as YearEndResult)
setStep('result')
toast({
title: 'Bokslut verkställt',
description: `${report?.period.name ?? 'Perioden'} är stängd.`,
})
} catch (err) {
setExecuteError(getErrorMessage(err))
} finally {
setExecuting(false)
}
}, [selectedPeriodId, report?.period.name, toast])
const currentStepIndex = STEP_ORDER.indexOf(step)
const showWizard = useMemo(
() => selectedPeriodId !== null && (periods?.length ?? 0) > 0,
[selectedPeriodId, periods],
)
return (
<div className="space-y-8">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="font-display text-2xl leading-8 tracking-tight">Årsbokslut</h1>
<div className="flex items-center gap-2">
<AgentSparkleButton
intentId="bokslut.step"
intentArgs={{ step_id: null }}
contextRef="bokslut:overview"
size="default"
/>
{showWizard && periods && periods.length > 0 && step !== 'result' && (
<ContextPicker
items={periods.map((p) => ({
id: p.id,
label: p.name,
annotation: `${p.period_start} till ${p.period_end}`,
}))}
value={selectedPeriodId}
disabled={navigationBlocked}
onChange={(value) => {
setSelectedPeriodId(value)
setStep('preflight')
setPreview(null)
setResult(null)
setExecuteError(null)
}}
triggerLabel={
periods.find((p) => p.id === selectedPeriodId)?.name ?? 'Välj period'
}
ariaLabel="Period"
/>
)}
</div>
</div>
{periods === null && !periodsError && (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
)}
{periodsError && (
<Card>
<CardContent className="p-6 text-destructive">{periodsError}</CardContent>
</Card>
)}
{periods !== null && periods.length === 0 && (
hasAnyPeriods ? (
<EmptyState
icon={Lock}
title="Inga perioder att stänga"
description="Det finns ingen öppen räkenskapsperiod vars slutdatum redan har passerat. Bokslut görs efter att periodens slutdatum är passerat."
/>
) : (
<EmptyState
icon={CalendarPlus}
title="Inget räkenskapsår ännu"
description="Bokslut görs per räkenskapsår. Skapa företagets räkenskapsår i bokföringsinställningarna för att komma igång."
actionLabel="Öppna bokföringsinställningar"
actionHref="/settings/bookkeeping"
/>
)
)}
{/* Stegen (concept scene 34): dots walk check -> filled -> number.
Steps behind the current one are clickable for going back; forward
navigation stays with each step's own continue action. */}
{showWizard && step !== 'result' && (
<div
className="mx-auto flex w-full max-w-4xl items-center gap-3 overflow-x-auto px-1"
role="tablist"
aria-label="Bokslutets steg"
>
{STEP_ORDER.map((s, i) => (
<React.Fragment key={s}>
{i > 0 && <span className="h-px min-w-4 flex-1 bg-border" aria-hidden="true" />}
<button
type="button"
role="tab"
aria-selected={s === step}
disabled={navigationBlocked || i >= currentStepIndex}
onClick={() => {
if (!navigationBlocked && i < currentStepIndex) setStep(s)
}}
className={cn(
'group flex shrink-0 items-center gap-2 text-left',
(navigationBlocked || i >= currentStepIndex) && 'cursor-default',
)}
>
<span
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums transition-colors duration-150',
i < currentStepIndex
? 'border-success/40 text-success'
: s === step
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground',
)}
>
{i < currentStepIndex ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : i + 1}
</span>
<span
className={cn(
'text-[12.5px] transition-colors duration-150',
s === step
? 'font-medium'
: i < currentStepIndex
? 'text-muted-foreground group-hover:text-foreground'
: 'text-muted-foreground',
)}
>
{STEP_LABELS[s]}
</span>
</button>
</React.Fragment>
))}
</div>
)}
{showWizard && step === 'preflight' && (
<div className="mx-auto w-full max-w-3xl"><PreflightStep
report={report}
isLoading={reportLoading}
error={reportError}
onContinue={() => setStep('accruals')}
/></div>
)}
{showWizard && step === 'accruals' && selectedPeriodId && (
<AccrualsStep
periodId={selectedPeriodId}
onBack={() => setStep('preflight')}
onContinue={() => setStep('dispositions')}
/>
)}
{showWizard && step === 'dispositions' && selectedPeriodId && (
<DispositionsStep
periodId={selectedPeriodId}
onBack={() => setStep('accruals')}
onContinue={goToPreview}
onNavigationBlockedChange={setNavigationBlocked}
/>
)}
{showWizard && step === 'preview' && (
<div className="mx-auto w-full max-w-3xl"><PreviewStep
preview={preview}
isLoading={previewLoading}
error={previewError}
onBack={() => setStep('dispositions')}
onContinue={() => setStep('execute')}
/></div>
)}
{showWizard && step === 'execute' && report && (
<div className="mx-auto w-full max-w-3xl"><ExecuteStep
periodName={report.period.name}
isRunning={executing}
error={executeError}
bolagsskattMissing={preview?.bolagsskattMissing ?? false}
onBack={() => setStep('preview')}
onExecute={executeYearEnd}
/></div>
)}
{step === 'result' && result && (
<div className="mx-auto w-full max-w-3xl"><ResultStep result={result} /></div>
)}
</div>
)
}