diff --git a/DECISIONS.md b/DECISIONS.md index c2c510ee..6b3d826f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1087,3 +1087,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] Import mapping step gets a bulk "Bekräfta alla föreslagna" for the VAT-treatment review gate, batching the per-row confirm semantics unchanged (defaults kept, rows marked reviewed): a Fortnox chart routinely puts 70+ class 3/4 accounts behind the gate and the one-click-per-row flow across 50-row pages was an observed live migration dead end (Boltonshield 2026-08-18, stuck at "50 kvar"). Rejected: auto-skipping review for accounts unused by the imported vouchers, because the chart rows are still created with the suggested treatment and a silently wrong default on a soon-used account is exactly what the review gate exists to catch. [2026-08-19] Dialog overflow hardening: Dialog/Sheet titles and descriptions get break-words at the primitive; AccountCombobox's non-flat dropdown is portaled to document.body with viewport-clamped geometry (same rationale as info-tooltip's TooltipContent portal, since DialogContent's overflow-y-auto otherwise grows a horizontal scrollbar around the 34rem panel); the four rattelse-family dialog explainers are unified behind one RattelseExplainer HelpPopover (convention 7, MatchVoucherDialog precedent) instead of four near-duplicate inline paragraphs; a dialog-overflow-risk ratchet in no-new-antipatterns.mjs keeps bare-1fr tracks, dialog whitespace-nowrap and unportaled wide overlays from coming back. [2026-08-19] Trial-expired visibility: the 2026-07-11 "no trial-expired nag" call is narrowed at the founder's direction (user-reported discoverability failure 2026-08-18): one on-entry dialog with persisted acknowledgement (user_preferences.ui_state.trial_expired_ack, per user AND company, never localStorage) plus a non-dismissable quiet chrome link (SubscriptionTouchpoint replaces the vanishing countdown pill, stays visible when the rail is collapsed, and adds the first mobile touchpoint). Free tier stays a legitimate resting state: no recurring nag, muted chrome tone, no ochre. getCompanyEntitlements also reads company_subscriptions.status inside its existing Promise.all (zero extra round-trips) so churned payers get "abonnemang" copy instead of "provperiod". Hem AttnLine variant skipped: the page already carries the otherAccountHint AttnLine and design.md allows max one per page. +[2026-08-19] Removed PeriodiseringAutoDetectToggle (settings > Automatik) instead of wiring it: the localStorage key it wrote (periodisering_autodetect_enabled) had no reader anywhere, so it advertised "automatisk periodiseringsdetektering" while changing nothing; auto-detect is already best-effort and review-gated in the wizard, so the row is now a plain link to the periodisering wizard. Deleting the key is safe: it was write-only. +[2026-08-19] Periodisering auto-detect materiality floor uses entity_type as a K1 proxy: no stored flag distinguishes förenklat årsbokslut (K1, BFNAR 2006:1) from full årsbokslut (BFNAR 2017:3) for enskild firma, so every EF gets K1 wording and every AB gets K2, always advisory ("behöver normalt inte"), never prohibitive. Suggestions under 5 000 kr are tagged low-confidence (unticked) rather than dropped because the relief is a MAY, not a MUST; personnel-cost lines (7xxx) are exempt from the floor since K1/K2 require personnel costs to always be accrued. diff --git a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx index d7896b08..c603e4e5 100644 --- a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx @@ -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 = { - 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 { + 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(null) const [periodsError, setPeriodsError] = useState(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({ selections: {} }) const [manualEntries, setManualEntries] = useState([]) @@ -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() {
- Steg {currentStepIndex + 1}/{STEP_ORDER.length}: {STEP_LABELS[step]} + Steg {currentStepIndex + 1}/{STEP_ORDER.length}: {labels[step]} {STEP_ORDER.map((s, i) => ( - {STEP_LABELS[s]} + {labels[s]} ))}
@@ -446,6 +468,7 @@ export default function PeriodiseringWizardPage() { )} {step === 'audit' && ( setStep('vacation')} @@ -454,6 +477,7 @@ export default function PeriodiseringWizardPage() { )} {step === 'auto' && ( @@ -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({
- Steg 2: Revisions- / bokslutsarvode + + {isEF ? 'Steg 2: Bokslutsarvode' : 'Steg 2: Revisions- / bokslutsarvode'} +

- 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.'}

@@ -632,12 +661,14 @@ function AuditStep({ } function AutoStep({ + isEF, suggestions, selections, onToggle, onBack, onNext, }: { + isEF: boolean suggestions: PeriodiseringSuggestion[] selections: Record 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.

+ {isEF && ( +

+ Enskild firma med förenklat årsbokslut (K1) behöver normalt inte + periodisera poster under 5 000 kr. Förslag under gränsen är avmarkerade. +

+ )} {suggestions.length === 0 && ( diff --git a/app/api/bookkeeping/fiscal-periods/[id]/accruals/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/accruals/__tests__/route.test.ts index ad2b6850..1d825b16 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/accruals/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/accruals/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts index 33ebfae6..9c7c706e 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts @@ -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 }) diff --git a/components/bookkeeping/AccrualPeriodControl.tsx b/components/bookkeeping/AccrualPeriodControl.tsx index 334c6553..71e12f92 100644 --- a/components/bookkeeping/AccrualPeriodControl.tsx +++ b/components/bookkeeping/AccrualPeriodControl.tsx @@ -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 (
@@ -177,8 +185,8 @@ export default function AccrualPeriodControl({ {previewInvalid ?? preview}

)} - {showK2Hint && ( -

{t('k2_hint')}

+ {showMaterialityHint && ( +

{t(materialityHintKey)}

)}
) diff --git a/components/bookkeeping/__tests__/accrual-k2-hint.test.ts b/components/bookkeeping/__tests__/accrual-k2-hint.test.ts index 7bab262a..207859f9 100644 --- a/components/bookkeeping/__tests__/accrual-k2-hint.test.ts +++ b/components/bookkeeping/__tests__/accrual-k2-hint.test.ts @@ -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') + }) +}) diff --git a/components/bookkeeping/accrual-k2-hint.ts b/components/bookkeeping/accrual-k2-hint.ts index db0553c3..3a5d1cb7 100644 --- a/components/bookkeeping/accrual-k2-hint.ts +++ b/components/bookkeeping/accrual-k2-hint.ts @@ -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' +} diff --git a/components/bookkeeping/year-end/AccrualsStep.tsx b/components/bookkeeping/year-end/AccrualsStep.tsx index 78a0108f..1bac8b1b 100644 --- a/components/bookkeeping/year-end/AccrualsStep.tsx +++ b/components/bookkeeping/year-end/AccrualsStep.tsx @@ -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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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.

+ {isEF && ( +

+ Enskild firma med förenklat årsbokslut (K1) behöver normalt inte + periodisera poster under 5 000 kr. +

+ )}
@@ -236,8 +252,9 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps Manuella periodiseringar

- 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.'}

@@ -248,13 +265,15 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps updateManual(m.id, patch)} onRemove={() => removeManual(m.id)} /> ))}