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