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
@@ -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>