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>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:06:01 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent c402421908
commit 64fc7c783d
16 changed files with 622 additions and 179 deletions
@@ -17,8 +17,8 @@ import {
computeInstallmentAmounts,
countCalendarMonths,
} from '@/lib/bookkeeping/accruals/compute'
import { shouldShowK2AccrualHint } from '@/components/bookkeeping/accrual-k2-hint'
import type { AccrualDirection } from '@/types'
import { accrualHintKey, shouldShowK2AccrualHint } from '@/components/bookkeeping/accrual-k2-hint'
import type { AccrualDirection, EntityType } from '@/types'
export interface AccrualFormValue {
start: string
@@ -59,6 +59,7 @@ export default function AccrualPeriodControl({
onChange,
onRemove,
idPrefix,
entityType,
}: {
direction: AccrualDirection
/** Net line amount (ex VAT), in `currency`: drives the preview and the K2 hint. */
@@ -75,6 +76,12 @@ export default function AccrualPeriodControl({
onChange: (next: AccrualFormValue) => void
onRemove: () => void
idPrefix: string
/**
* Picks the regelverk the materiality hint cites: K1 (BFNAR 2006:1) for
* enskild firma, K2 (BFNAR 2016:10) otherwise. Missing keeps the K2
* wording (the historical default).
*/
entityType?: EntityType | null
}) {
const t = useTranslations('accruals')
@@ -101,11 +108,12 @@ export default function AccrualPeriodControl({
}
}
// K2's 5 000 kr vasentlighetsgrans is measured in kronor, and it is a
// simplification the company may use, not an obligation. So when the line
// The 5 000 kr vasentlighetsgrans (K1/K2) is measured in kronor, and it is
// a simplification the company may use, not an obligation. So when the line
// is in a foreign currency and no rate is available, show nothing at all
// rather than compare the raw foreign amount against a SEK threshold.
const showK2Hint = shouldShowK2AccrualHint({ amount, currency, exchangeRate })
const showMaterialityHint = shouldShowK2AccrualHint({ amount, currency, exchangeRate })
const materialityHintKey = accrualHintKey(entityType)
return (
<div className="rounded-lg border bg-muted/30 p-3 space-y-3">
@@ -177,8 +185,8 @@ export default function AccrualPeriodControl({
{previewInvalid ?? preview}
</p>
)}
{showK2Hint && (
<p className="text-xs text-muted-foreground">{t('k2_hint')}</p>
{showMaterialityHint && (
<p className="text-xs text-muted-foreground">{t(materialityHintKey)}</p>
)}
</div>
)
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
K2_ACCRUAL_THRESHOLD_SEK,
accrualHintKey,
resolveAccrualAmountSek,
shouldShowK2AccrualHint,
} from '@/components/bookkeeping/accrual-k2-hint'
@@ -103,3 +104,18 @@ describe('shouldShowK2AccrualHint', () => {
).toBe(false)
})
})
describe('accrualHintKey', () => {
it('cites K1 (BFNAR 2006:1) for enskild firma', () => {
expect(accrualHintKey('enskild_firma')).toBe('k1_hint')
})
it('cites K2 for aktiebolag', () => {
expect(accrualHintKey('aktiebolag')).toBe('k2_hint')
})
it('keeps the K2 default when the entity type is unknown', () => {
expect(accrualHintKey(null)).toBe('k2_hint')
expect(accrualHintKey(undefined)).toBe('k2_hint')
})
})
+18 -1
View File
@@ -63,7 +63,7 @@ export function resolveAccrualAmountSek({
}
/**
* Whether to show the "below 5 000 kr need not be deferred (K2)" hint.
* Whether to show the "below 5 000 kr need not be deferred" hint.
*
* False whenever the SEK value is unknown: no hint beats a hint measured in
* the wrong currency.
@@ -73,3 +73,20 @@ export function shouldShowK2AccrualHint(input: AccrualAmountInput): boolean {
if (amountSek === null) return false
return amountSek > 0 && amountSek < K2_ACCRUAL_THRESHOLD_SEK
}
/**
* Which `accruals.*` message key carries the materiality hint for the given
* entity type. An enskild firma normally closes under K1 (BFNAR 2006:1,
* förenklat årsbokslut), which relieves posts below 5 000 kr; citing K2
* (BFNAR 2016:10) at a sole trader names a regelverk that does not apply to
* it. There is no stored förenklat-vs-full-årsbokslut flag, so entity_type
* is a proxy and the copy stays advisory ("behöver normalt inte").
*
* Unknown/missing entity keeps the K2 wording: it is the historical default
* and correct for every aktiebolag.
*/
export function accrualHintKey(
entityType?: 'enskild_firma' | 'aktiebolag' | null,
): 'k1_hint' | 'k2_hint' {
return entityType === 'enskild_firma' ? 'k1_hint' : 'k2_hint'
}
@@ -11,6 +11,7 @@ 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'
@@ -41,6 +42,11 @@ function makeId() {
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)
@@ -73,21 +79,25 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
}
}, [periodId])
const addManual = useCallback((kind: ManualEntry['kind']) => {
setManual((prev) => [
...prev,
{
id: makeId(),
kind,
amount: '',
description: '',
expenseAccount: kind === 'audit_fee' ? '6420' : '',
prepaidAccount: '',
accruedAccount: '',
liabilityAccount: '2992',
},
])
}, [])
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))
@@ -192,6 +202,12 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
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>
@@ -236,8 +252,9 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
<CardHeader>
<CardTitle className="text-base">Manuella periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
Lägg till revisionsarvode, hyra som löper över årsskiftet, förutbetalda
försäkringar m.m.
{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">
@@ -248,13 +265,15 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
<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" /> Revisions-/bokslutsarvode
<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
@@ -294,10 +313,12 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
function ManualEntryEditor({
entry,
isEF,
onChange,
onRemove,
}: {
entry: ManualEntry
isEF: boolean
onChange: (patch: Partial<ManualEntry>) => void
onRemove: () => void
}) {
@@ -305,7 +326,7 @@ function ManualEntryEditor({
<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' && 'Revisions-/bokslutsarvode'}
{entry.kind === 'audit_fee' && (isEF ? 'Bokslutsarvode' : 'Revisions-/bokslutsarvode')}
{entry.kind === 'manual_prepaid_expense' && 'Förutbetald kostnad'}
{entry.kind === 'manual_accrued_expense' && 'Upplupen kostnad'}
</p>