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
@@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import {
|
||||
PERIODISERING_TEMPLATES,
|
||||
@@ -31,12 +32,16 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
|
||||
type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review'
|
||||
|
||||
const STEP_ORDER: Step[] = ['vacation', 'audit', 'auto', 'manual', 'review']
|
||||
const STEP_LABELS: Record<Step, string> = {
|
||||
vacation: 'Semester',
|
||||
audit: 'Revisionsarvode',
|
||||
auto: 'Auto-detektering',
|
||||
manual: 'Manuella tillägg',
|
||||
review: 'Granska & posta',
|
||||
|
||||
/** An enskild firma has no revisor, so its step 2 is the bokslutsarvode. */
|
||||
function stepLabels(isEnskildFirma: boolean): Record<Step, string> {
|
||||
return {
|
||||
vacation: 'Semester',
|
||||
audit: isEnskildFirma ? 'Bokslutsarvode' : 'Revisionsarvode',
|
||||
auto: 'Auto-detektering',
|
||||
manual: 'Manuella tillägg',
|
||||
review: 'Granska & posta',
|
||||
}
|
||||
}
|
||||
|
||||
interface PeriodOption {
|
||||
@@ -93,6 +98,12 @@ export default function PeriodiseringWizardPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const { company } = useCompany()
|
||||
// Entity-aware copy: an enskild firma has no revision and normally closes
|
||||
// under K1 (BFNAR 2006:1, förenklat årsbokslut). entity_type is a proxy:
|
||||
// no stored flag distinguishes förenklat from full årsbokslut, so all
|
||||
// K1 wording stays advisory ("behöver normalt inte").
|
||||
const isEF = company?.entity_type === 'enskild_firma'
|
||||
|
||||
const [periods, setPeriods] = useState<PeriodOption[] | null>(null)
|
||||
const [periodsError, setPeriodsError] = useState<string | null>(null)
|
||||
@@ -111,6 +122,16 @@ export default function PeriodiseringWizardPage() {
|
||||
amount: '',
|
||||
liabilityAccount: '2992',
|
||||
})
|
||||
|
||||
// Default the liability account per entity: 2991 (bokslut) for enskild
|
||||
// firma, 2992 (revision) for aktiebolag. The company row loads async, so
|
||||
// adjust once it arrives, but never override a state the user has touched.
|
||||
useEffect(() => {
|
||||
if (!isEF) return
|
||||
setAuditState((prev) =>
|
||||
prev.enabled || prev.amount !== '' ? prev : { ...prev, liabilityAccount: '2991' },
|
||||
)
|
||||
}, [isEF])
|
||||
const [autoState, setAutoState] = useState<AutoState>({ selections: {} })
|
||||
const [manualEntries, setManualEntries] = useState<ManualEntry[]>([])
|
||||
|
||||
@@ -190,6 +211,7 @@ export default function PeriodiseringWizardPage() {
|
||||
)
|
||||
|
||||
const currentStepIndex = STEP_ORDER.indexOf(step)
|
||||
const labels = stepLabels(isEF)
|
||||
const progressValue = ((currentStepIndex + 1) / STEP_ORDER.length) * 100
|
||||
const showWizard = selectedPeriodId !== null && (periods?.length ?? 0) > 0 && !loading && !loadError
|
||||
|
||||
@@ -418,7 +440,7 @@ export default function PeriodiseringWizardPage() {
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="sm:hidden text-primary font-medium">
|
||||
Steg {currentStepIndex + 1}/{STEP_ORDER.length}: {STEP_LABELS[step]}
|
||||
Steg {currentStepIndex + 1}/{STEP_ORDER.length}: {labels[step]}
|
||||
</span>
|
||||
{STEP_ORDER.map((s, i) => (
|
||||
<span
|
||||
@@ -428,7 +450,7 @@ export default function PeriodiseringWizardPage() {
|
||||
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
{labels[s]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -446,6 +468,7 @@ export default function PeriodiseringWizardPage() {
|
||||
)}
|
||||
{step === 'audit' && (
|
||||
<AuditStep
|
||||
isEF={isEF}
|
||||
state={auditState}
|
||||
onChange={setAuditState}
|
||||
onBack={() => setStep('vacation')}
|
||||
@@ -454,6 +477,7 @@ export default function PeriodiseringWizardPage() {
|
||||
)}
|
||||
{step === 'auto' && (
|
||||
<AutoStep
|
||||
isEF={isEF}
|
||||
suggestions={proposal.autoDetected ?? []}
|
||||
selections={autoState.selections}
|
||||
onToggle={(key, val) =>
|
||||
@@ -558,11 +582,13 @@ function VacationStep({
|
||||
}
|
||||
|
||||
function AuditStep({
|
||||
isEF,
|
||||
state,
|
||||
onChange,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
isEF: boolean
|
||||
state: AuditState
|
||||
onChange: (s: AuditState) => void
|
||||
onBack: () => void
|
||||
@@ -572,10 +598,13 @@ function AuditStep({
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Steg 2: Revisions- / bokslutsarvode</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{isEF ? 'Steg 2: Bokslutsarvode' : 'Steg 2: Revisions- / bokslutsarvode'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Periodisera arvode för revision (2992) eller bokslut (2991). Posten
|
||||
vänds första dagen i nästa räkenskapsår när fakturan kommer.
|
||||
{isEF
|
||||
? 'Periodisera arvode för bokslut (2991). Posten vänds första dagen i nästa räkenskapsår när fakturan kommer.'
|
||||
: 'Periodisera arvode för revision (2992) eller bokslut (2991). Posten vänds första dagen i nästa räkenskapsår när fakturan kommer.'}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -632,12 +661,14 @@ function AuditStep({
|
||||
}
|
||||
|
||||
function AutoStep({
|
||||
isEF,
|
||||
suggestions,
|
||||
selections,
|
||||
onToggle,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
isEF: boolean
|
||||
suggestions: PeriodiseringSuggestion[]
|
||||
selections: Record<string, boolean>
|
||||
onToggle: (key: string, val: boolean) => void
|
||||
@@ -654,6 +685,12 @@ function AutoStep({
|
||||
innehåller en datumintervall som sträcker sig in i nästa räkenskapsår.
|
||||
Granska och bekräfta: högst säkra förslag är förvalda.
|
||||
</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. Förslag under gränsen är avmarkerade.
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{suggestions.length === 0 && (
|
||||
|
||||
@@ -10,9 +10,11 @@ vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetCompanyEntityType = vi.fn()
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getCompanyEntityType: (...args: unknown[]) => mockGetCompanyEntityType(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
@@ -44,11 +46,25 @@ beforeAll(async () => {
|
||||
;({ GET } = await import('../route'))
|
||||
}, 30_000)
|
||||
|
||||
/** Minimal supabase mock: auth only. The entity_type resolution is mocked at
|
||||
* the getCompanyEntityType boundary (company_settings-primary with a
|
||||
* companies fallback lives inside lib/company/context, tested there). */
|
||||
function mockSupabase() {
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({ data: null, error: { message: 'not found' } }),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(mockSupabase())
|
||||
mockGetCompanyEntityType.mockResolvedValue('aktiebolag')
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/fiscal-periods/[id]/accruals', () => {
|
||||
@@ -90,6 +106,54 @@ describe('GET /api/bookkeeping/fiscal-periods/[id]/accruals', () => {
|
||||
const { status, body } = await parseJsonResponse<{ data: { autoDetected: unknown[] } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.autoDetected).toHaveLength(1)
|
||||
// The route resolves the company's entity_type and threads it through so
|
||||
// the materiality wording cites the right regelverk (K1 vs K2).
|
||||
expect(mockDetectPeriodisering).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'period-1',
|
||||
{ entityType: 'aktiebolag' },
|
||||
)
|
||||
})
|
||||
|
||||
it('threads entity_type enskild_firma to the detector', async () => {
|
||||
mockGetCompanyEntityType.mockResolvedValue('enskild_firma')
|
||||
mockBuildAccrualsProposal.mockResolvedValue({
|
||||
fiscalPeriod: { id: 'period-1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
proposals: [],
|
||||
})
|
||||
mockDetectPeriodisering.mockResolvedValue([])
|
||||
const res = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
|
||||
createMockRouteParams({ id: 'period-1' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockDetectPeriodisering).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'period-1',
|
||||
{ entityType: 'enskild_firma' },
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to null entityType when the entity type cannot be resolved', async () => {
|
||||
mockGetCompanyEntityType.mockResolvedValue(null)
|
||||
mockBuildAccrualsProposal.mockResolvedValue({
|
||||
fiscalPeriod: { id: 'period-1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
proposals: [],
|
||||
})
|
||||
mockDetectPeriodisering.mockResolvedValue([])
|
||||
const res = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
|
||||
createMockRouteParams({ id: 'period-1' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockDetectPeriodisering).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'period-1',
|
||||
{ entityType: null },
|
||||
)
|
||||
})
|
||||
|
||||
it('still returns the snapshot when auto-detect throws', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getCompanyEntityType } from '@/lib/company/context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
proposeVacationLiabilityChange,
|
||||
} from '@/lib/bokslut/accruals/accrual-detector'
|
||||
import { detectPeriodisering } from '@/lib/bokslut/accruals/auto-detect'
|
||||
import type { PeriodiseringEntityType } from '@/lib/bokslut/accruals/auto-detect'
|
||||
import type { AccrualProposal } from '@/lib/bokslut/accruals/types'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
@@ -28,7 +30,17 @@ export const GET = withRouteContext(
|
||||
// paint isn't gated on the slower auto-detect query.
|
||||
const [proposal, autoDetected] = await Promise.all([
|
||||
buildAccrualsProposal(supabase, companyId, id),
|
||||
detectPeriodisering(supabase, companyId, id).catch((err) => {
|
||||
(async () => {
|
||||
// Entity type picks the regelverk the materiality wording cites:
|
||||
// K1 (BFNAR 2006:1) for enskild firma, K2 (BFNAR 2016:10) for AB.
|
||||
// Resolved via getCompanyEntityType: company_settings is the
|
||||
// read-primary source (what the user edits in Settings), with
|
||||
// companies.entity_type as the fallback.
|
||||
const entityType = await getCompanyEntityType(supabase, companyId)
|
||||
return detectPeriodisering(supabase, companyId, id, {
|
||||
entityType: (entityType as PeriodiseringEntityType | null) ?? null,
|
||||
})
|
||||
})().catch((err) => {
|
||||
// Auto-detect is best-effort: a malformed invoice description
|
||||
// shouldn't break the rest of the preflight. Log + return empty.
|
||||
log.warn('auto-detect failed', { error: (err as Error)?.message })
|
||||
|
||||
Reference in New Issue
Block a user