Files
accounted/components/bookkeeping/year-end/AccrualsStep.tsx
T
64fc7c783d fix(periodisering): stop overselling automatic periodization to enskild firma (#1730)
* fix(bokslut): honest periodisering for enskild firma (K1)

Stop mis-selling automatic periodisering to sole traders and give the
auto-detect a materiality floor:

- Remove the inert PeriodiseringAutoDetectToggle (write-only localStorage,
  no reader anywhere); the settings row is now a plain link to the
  periodisering wizard, with new i18n keys in sv+en.
- Auto-detect tags suggestions under 5 000 kr as low confidence with the
  reason 'Under 5 000 kr: behöver normalt inte periodiseras', citing K1
  (BFNAR 2006:1) for enskild firma and K2 for aktiebolag; the wizard only
  pre-ticks high-confidence rows, so under-floor posts land unticked.
  Personnel-cost lines (7xxx) are exempt: they must always be accrued.
- The accruals GET route resolves companies.entity_type and threads it to
  the detector.
- Per-line accrual hint in the invoice editors is entity-aware: new
  accruals.k1_hint (K1, förenklat årsbokslut) for EF, k2_hint stays for AB.
- Periodisering wizard and year-end AccrualsStep relabel Revisionsarvode
  to Bokslutsarvode for EF, default the liability account to 2991 instead
  of 2992, and show a muted K1-floor intro line.

All copy stays advisory (behöver normalt inte, never får inte):
entity_type is a proxy since no förenklat-vs-full-årsbokslut flag exists.

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

* fix(bokslut): SEK-correct materiality floor, entity-type via settings, narrower personnel exemption

Review fixes on the K1 periodisering branch:

- The 5 000 kr floor now compares a SEK amount: queries select currency
  and subtotal_sek, the floor uses the periodisation share of
  subtotal_sek for foreign-currency invoices, and is skipped entirely
  when no SEK amount is resolvable (accrual-k2-hint precedent,
  DECISIONS.md 2026-07-26).
- The accruals route resolves entity type via getCompanyEntityType
  (company_settings-primary, companies fallback) instead of reading
  companies.entity_type directly.
- The personnel-cost exemption from the floor is narrowed from
  startsWith('7') to /^7[0-6]/: 78xx/79xx are not personnel costs.

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-20 10:06:01 +02:00

412 lines
14 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, Loader2, Plus, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import type { AccrualsProposal } from '@/lib/bokslut/accruals/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AccrualsStepProps {
periodId: string
onBack: () => void
onContinue: () => void
}
interface AutoState {
vacation: { accept: boolean }
}
interface ManualEntry {
id: string
kind: 'audit_fee' | 'manual_prepaid_expense' | 'manual_accrued_expense'
amount: string
description: string
expenseAccount: string
prepaidAccount: string
accruedAccount: string
liabilityAccount: '2991' | '2992'
}
function makeId() {
return Math.random().toString(36).slice(2, 10)
}
export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps) {
const { toast } = useToast()
const { company } = useCompany()
// An enskild firma has no revisor and normally closes under K1 (BFNAR
// 2006:1, förenklat årsbokslut): its arvode row is the bokslutsarvode
// (2991), and posts under 5 000 kr normally need not be accrued.
const isEF = company?.entity_type === 'enskild_firma'
const [proposal, setProposal] = useState<AccrualsProposal | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [auto, setAuto] = useState<AutoState>({ vacation: { accept: true } })
const [manual, setManual] = useState<ManualEntry[]>([])
const [posting, setPosting] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetch(`/api/bookkeeping/fiscal-periods/${periodId}/accruals`)
.then(async (res) => {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda periodiseringar')
return
}
setProposal(body.data as AccrualsProposal)
})
.catch(() => {
if (!cancelled) setError('Kunde inte ladda periodiseringar')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [periodId])
const addManual = useCallback(
(kind: ManualEntry['kind']) => {
setManual((prev) => [
...prev,
{
id: makeId(),
kind,
amount: '',
description: '',
expenseAccount: kind === 'audit_fee' ? '6420' : '',
prepaidAccount: '',
accruedAccount: '',
// EF defaults to 2991 (bokslut): it has no revision to accrue for.
liabilityAccount: isEF ? '2991' : '2992',
},
])
},
[isEF],
)
const removeManual = useCallback((id: string) => {
setManual((prev) => prev.filter((m) => m.id !== id))
}, [])
const updateManual = useCallback((id: string, patch: Partial<ManualEntry>) => {
setManual((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)))
}, [])
const handleCommit = useCallback(async () => {
if (!proposal) return
setPosting(true)
try {
const items: unknown[] = []
if (proposal.proposals.find((p) => p.kind === 'vacation_liability_change') && auto.vacation.accept) {
items.push({ kind: 'vacation_liability_change' })
}
for (const m of manual) {
const amount = parseFloat(m.amount)
if (!Number.isFinite(amount) || amount <= 0) continue
if (m.kind === 'audit_fee') {
items.push({ kind: 'audit_fee', amount, liability_account: m.liabilityAccount })
} else if (m.kind === 'manual_prepaid_expense') {
if (!m.expenseAccount || !m.prepaidAccount || !m.description) continue
items.push({
kind: 'manual_prepaid_expense',
amount,
expense_account: m.expenseAccount,
prepaid_account: m.prepaidAccount,
description: m.description,
})
} else if (m.kind === 'manual_accrued_expense') {
if (!m.expenseAccount || !m.accruedAccount || !m.description) continue
items.push({
kind: 'manual_accrued_expense',
amount,
expense_account: m.expenseAccount,
accrued_account: m.accruedAccount,
description: m.description,
})
}
}
if (items.length === 0) {
onContinue()
return
}
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/accruals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
})
const body = await res.json()
if (!res.ok) {
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra periodiseringarna')
return
}
const created = body.data?.created?.length ?? 0
toast({
title: `${created} periodisering${created === 1 ? '' : 'ar'} bokförd${
created === 1 ? '' : 'a'
}`,
description: 'Vänd dem första dagen i nästa räkenskapsår.',
})
onContinue()
} catch (err) {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
}, [proposal, auto, manual, periodId, onContinue, toast])
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
)
}
if (error && !proposal) {
return (
<Card>
<CardContent className="p-6 text-destructive">{error}</CardContent>
</Card>
)
}
if (!proposal) return null
const vacation = proposal.proposals.find((p) => p.kind === 'vacation_liability_change')
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
Förutbetalda kostnader (17xx) och upplupna kostnader (29xx). Posteringarna
ska vändas på första dagen av nästa räkenskapsår: datumet visas per
verifikation. Automatisk omvändning är planerad till en kommande version.
</p>
{isEF && (
<p className="text-xs text-muted-foreground">
Enskild firma med förenklat årsbokslut (K1) behöver normalt inte
periodisera poster under 5 000 kr.
</p>
)}
</CardHeader>
</Card>
{vacation && (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-base">{vacation.label}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">{vacation.description}</p>
{vacation.reverses_on ? (
<Badge variant="outline" className="mt-2">
Vänds {vacation.reverses_on}
</Badge>
) : (
<Badge variant="outline" className="mt-2">
Rullas vidare (ingen vändning)
</Badge>
)}
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(vacation.amount)}
</p>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Checkbox
id="accept-vacation"
checked={auto.vacation.accept}
onCheckedChange={(c) => setAuto({ vacation: { accept: Boolean(c) } })}
/>
<Label htmlFor="accept-vacation" className="text-sm cursor-pointer select-none">
Boka denna justering
</Label>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Manuella periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
{isEF
? 'Lägg till bokslutsarvode, hyra som löper över årsskiftet, förutbetalda försäkringar m.m.'
: 'Lägg till revisionsarvode, hyra som löper över årsskiftet, förutbetalda försäkringar m.m.'}
</p>
</CardHeader>
<CardContent className="space-y-4">
{manual.length === 0 && (
<p className="text-sm text-muted-foreground italic">Inga manuella periodiseringar tillagda än.</p>
)}
{manual.map((m) => (
<ManualEntryEditor
key={m.id}
entry={m}
isEF={isEF}
onChange={(patch) => updateManual(m.id, patch)}
onRemove={() => removeManual(m.id)}
/>
))}
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => addManual('audit_fee')}>
<Plus className="mr-1 h-3.5 w-3.5" />{' '}
{isEF ? 'Bokslutsarvode' : 'Revisions-/bokslutsarvode'}
</Button>
<Button variant="outline" size="sm" onClick={() => addManual('manual_prepaid_expense')}>
<Plus className="mr-1 h-3.5 w-3.5" /> Förutbetald kostnad
</Button>
<Button variant="outline" size="sm" onClick={() => addManual('manual_accrued_expense')}>
<Plus className="mr-1 h-3.5 w-3.5" /> Upplupen kostnad
</Button>
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-4 text-sm text-destructive">{error}</CardContent>
</Card>
)}
<div className="flex justify-between">
<Button variant="outline" size="sm" onClick={onBack} disabled={posting}>
← Tillbaka
</Button>
<Button onClick={handleCommit} disabled={posting}>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
</>
) : (
<>
Fortsätt <ArrowRight className="ml-1 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
function ManualEntryEditor({
entry,
isEF,
onChange,
onRemove,
}: {
entry: ManualEntry
isEF: boolean
onChange: (patch: Partial<ManualEntry>) => void
onRemove: () => void
}) {
return (
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{entry.kind === 'audit_fee' && (isEF ? 'Bokslutsarvode' : 'Revisions-/bokslutsarvode')}
{entry.kind === 'manual_prepaid_expense' && 'Förutbetald kostnad'}
{entry.kind === 'manual_accrued_expense' && 'Upplupen kostnad'}
</p>
<Button variant="ghost" size="sm" aria-label="Ta bort post" onClick={onRemove} className="h-7 px-2">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Belopp (kr)</Label>
<Input
type="number"
step="1"
min="0"
value={entry.amount}
onChange={(e) => onChange({ amount: e.target.value })}
className="tabular-nums h-8"
/>
</div>
{entry.kind === 'audit_fee' && (
<div className="space-y-1">
<Label className="text-xs">Konto</Label>
<select
className="border border-border rounded-lg h-8 text-sm px-2 w-full bg-background"
value={entry.liabilityAccount}
onChange={(e) =>
onChange({ liabilityAccount: e.target.value as '2991' | '2992' })
}
>
<option value="2992">2992: Revision</option>
<option value="2991">2991: Bokslut</option>
</select>
</div>
)}
{entry.kind !== 'audit_fee' && (
<>
<div className="space-y-1">
<Label className="text-xs">Kostnadskonto</Label>
<Input
value={entry.expenseAccount}
onChange={(e) => onChange({ expenseAccount: e.target.value })}
placeholder="t.ex. 6310"
className="tabular-nums h-8"
/>
</div>
{entry.kind === 'manual_prepaid_expense' && (
<div className="space-y-1">
<Label className="text-xs">17xx-konto</Label>
<Input
value={entry.prepaidAccount}
onChange={(e) => onChange({ prepaidAccount: e.target.value })}
placeholder="t.ex. 1730"
className="tabular-nums h-8"
/>
</div>
)}
{entry.kind === 'manual_accrued_expense' && (
<div className="space-y-1">
<Label className="text-xs">29xx-konto</Label>
<Input
value={entry.accruedAccount}
onChange={(e) => onChange({ accruedAccount: e.target.value })}
placeholder="t.ex. 2990"
className="tabular-nums h-8"
/>
</div>
)}
<div className="space-y-1 col-span-2">
<Label className="text-xs">Beskrivning</Label>
<Input
value={entry.description}
onChange={(e) => onChange({ description: e.target.value })}
placeholder="t.ex. Försäkring 2026"
className="h-8"
/>
</div>
</>
)}
</div>
</div>
)
}