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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
c402421908
commit
64fc7c783d
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2476,6 +2476,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
<div className="px-2 pb-3">
|
||||
<AccrualPeriodControl
|
||||
direction="revenue"
|
||||
/* Entity type picks the regelverk the
|
||||
5 000 kr hint cites: K1 for enskild
|
||||
firma, K2 for aktiebolag. */
|
||||
entityType={company?.entity_type}
|
||||
amount={lineTotal}
|
||||
/* The customer-invoice editor carries no FX rate
|
||||
(the form has no exchange_rate field), so the
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
/**
|
||||
* Per-company toggle for the periodisering wizard's auto-detection step.
|
||||
*
|
||||
* Backed by localStorage (key: `periodisering_autodetect_enabled:<companyId>`,
|
||||
* with the old unscoped key as a read fallback so existing choices survive:
|
||||
* the unscoped key silently applied one company's choice to every company in
|
||||
* the browser) because
|
||||
* the company_settings table does not yet have a dedicated column for this
|
||||
* preference, and the task description explicitly allows the persistence to
|
||||
* be UI-local. A future migration can promote this to a real
|
||||
* `company_settings.periodisering_autodetect_enabled boolean` column and
|
||||
* the wizard's auto-detect step will read either source.
|
||||
*
|
||||
* Default: enabled. The wizard's auto-detect step renders regardless: the
|
||||
* toggle merely controls whether the GET response includes `autoDetected`
|
||||
* on subsequent fetches. (Today the API always returns it; the wizard step
|
||||
* can early-out based on this setting locally.)
|
||||
*/
|
||||
const STORAGE_KEY = 'periodisering_autodetect_enabled'
|
||||
|
||||
function storageKeyFor(companyId: string | null): string {
|
||||
return companyId ? `${STORAGE_KEY}:${companyId}` : STORAGE_KEY
|
||||
}
|
||||
|
||||
function readStored(companyId: string | null): boolean {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
const stored =
|
||||
window.localStorage.getItem(storageKeyFor(companyId)) ??
|
||||
window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === null ? true : stored !== 'false'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to localStorage changes from OTHER tabs. Same-tab updates are
|
||||
* picked up via the explicit re-render after `setItem`: see
|
||||
* `notifyChange` below. */
|
||||
function subscribe(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === null || e.key === STORAGE_KEY || e.key.startsWith(`${STORAGE_KEY}:`)) callback()
|
||||
}
|
||||
const customHandler = () => callback()
|
||||
window.addEventListener('storage', handler)
|
||||
window.addEventListener('gnubok-periodisering-toggle', customHandler)
|
||||
return () => {
|
||||
window.removeEventListener('storage', handler)
|
||||
window.removeEventListener('gnubok-periodisering-toggle', customHandler)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire a same-tab notification so useSyncExternalStore re-subscribers
|
||||
* see the change without a manual setState. */
|
||||
function notifyChange() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new Event('gnubok-periodisering-toggle'))
|
||||
}
|
||||
|
||||
export function PeriodiseringAutoDetectToggle() {
|
||||
const companyId = useCompanyOptional()?.company?.id ?? null
|
||||
const getSnapshot = useMemo(() => () => readStored(companyId), [companyId])
|
||||
const enabled = useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
// Server snapshot: default to enabled. Matches the client default so
|
||||
// hydration is identical.
|
||||
() => true,
|
||||
)
|
||||
|
||||
const handleChange = useCallback((value: boolean) => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKeyFor(companyId), String(value))
|
||||
} catch {
|
||||
// No-op; if storage is blocked the toggle simply won't persist.
|
||||
}
|
||||
notifyChange()
|
||||
}, [companyId])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
label="Periodisering"
|
||||
help="Skannar fakturor i bokslutet efter datumintervall som sträcker sig in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden."
|
||||
>
|
||||
<Switch
|
||||
id="periodisering-autodetect"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
<label htmlFor="periodisering-autodetect" className="cursor-pointer text-sm">
|
||||
Aktivera automatisk periodiseringsdetektering
|
||||
</label>
|
||||
<SettingsRowEnd>
|
||||
<Link
|
||||
href="/bookkeeping/year-end/periodisering"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Öppna periodiserings-wizarden
|
||||
</Link>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { FiscalYearsManager } from '@/components/settings/FiscalYearsManager'
|
||||
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
|
||||
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
|
||||
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
|
||||
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
|
||||
import { MileageToggle } from '@/components/settings/MileageToggle'
|
||||
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
|
||||
@@ -173,7 +172,18 @@ export function BookkeepingSettingsContent() {
|
||||
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
|
||||
|
||||
<SettingsGroup label={t('group_automation')}>
|
||||
<PeriodiseringAutoDetectToggle />
|
||||
{/* Periodisering is a review-gated wizard step, not an automation
|
||||
that can be switched on or off, so this row is a plain link. The
|
||||
old toggle here wrote a localStorage preference nothing read. */}
|
||||
<SettingsRow label={t('periodisering_label')} help={t('periodisering_help')}>
|
||||
<Link
|
||||
href="/bookkeeping/year-end/periodisering"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('periodisering_open_wizard')}
|
||||
</Link>
|
||||
</SettingsRow>
|
||||
<DimensionsToggle />
|
||||
<MileageToggle />
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1001,6 +1001,9 @@ export default function NewSupplierInvoiceForm({
|
||||
return (
|
||||
<AccrualPeriodControl
|
||||
direction="expense"
|
||||
// Entity type picks the regelverk the 5 000 kr hint cites: K1 for
|
||||
// enskild firma, K2 for aktiebolag.
|
||||
entityType={entityType}
|
||||
amount={item.amount || 0}
|
||||
// Line amounts are in the invoice's currency; the K2 5 000 kr limit is
|
||||
// in SEK. The rate is the Riksbanken/manual one already on the form.
|
||||
|
||||
Reference in New Issue
Block a user