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:
@@ -1087,3 +1087,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -234,7 +234,9 @@ describe('detectPeriodisering', () => {
|
||||
id: 'sup-high',
|
||||
supplier_invoice_number: 'LF-B',
|
||||
invoice_date: '2025-12-01',
|
||||
subtotal: 3000, // smaller, but high confidence
|
||||
// Smaller, but high confidence. Kept above the 5 000 kr materiality
|
||||
// floor so the floor's low-confidence downgrade doesn't apply here.
|
||||
subtotal: 6000,
|
||||
notes: 'Mjukvara perioden 2026-01-01 till 2026-12-31',
|
||||
suppliers: { name: 'B' },
|
||||
supplier_invoice_items: [{ description: 'License', account_number: '5800' }],
|
||||
@@ -288,4 +290,281 @@ describe('detectPeriodisering', () => {
|
||||
// Suggesting it again would periodisera the same belopp twice.
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('tags a suggestion under 5 000 kr as low confidence citing K2 by default', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({ data: [], error: null }) // invoices
|
||||
// 1 200 kr domain renewal fully in next year: below the materiality floor.
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sup-small',
|
||||
supplier_invoice_number: 'LF-500',
|
||||
invoice_date: '2025-12-15',
|
||||
subtotal: 1200,
|
||||
currency: 'SEK',
|
||||
subtotal_sek: 1200,
|
||||
notes: 'Domänförnyelse period 2026-01-01 till 2026-12-31',
|
||||
suppliers: { name: 'Registrar AB' },
|
||||
supplier_invoice_items: [{ description: 'Domän', account_number: '6540' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
// Downgraded so the wizard does NOT pre-tick it (only 'high' is pre-ticked).
|
||||
expect(result[0].confidence).toBe('low')
|
||||
expect(result[0].reason).toContain('Under 5 000 kr: behöver normalt inte periodiseras (K2).')
|
||||
})
|
||||
|
||||
it('cites K1 in the under-floor reason for an enskild firma', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({ data: [], error: null }) // invoices
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sup-small-ef',
|
||||
supplier_invoice_number: 'LF-501',
|
||||
invoice_date: '2025-12-15',
|
||||
subtotal: 1200,
|
||||
notes: 'Domänförnyelse period 2026-01-01 till 2026-12-31',
|
||||
suppliers: { name: 'Registrar AB' },
|
||||
supplier_invoice_items: [{ description: 'Domän', account_number: '6540' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
{ entityType: 'enskild_firma' },
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].confidence).toBe('low')
|
||||
expect(result[0].reason).toContain('Under 5 000 kr: behöver normalt inte periodiseras (K1).')
|
||||
})
|
||||
|
||||
it('keeps a suggestion at or above 5 000 kr at its original confidence', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({ data: [], error: null }) // invoices
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sup-large',
|
||||
supplier_invoice_number: 'LF-502',
|
||||
invoice_date: '2025-07-01',
|
||||
subtotal: 12000,
|
||||
notes: 'Mjukvarulicens period: 2025-07-01 till 2026-06-30',
|
||||
suppliers: { name: 'Acme SaaS AB' },
|
||||
supplier_invoice_items: [{ description: 'Årslicens', account_number: '5800' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
{ entityType: 'enskild_firma' },
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
// 12000 * 181/365 = 5950.68: above the floor, stays high with no K1 note.
|
||||
expect(result[0].confidence).toBe('high')
|
||||
expect(result[0].reason).not.toContain('Under 5 000 kr')
|
||||
})
|
||||
|
||||
it('compares the floor against the SEK amount for a foreign-currency invoice', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({ data: [], error: null }) // invoices
|
||||
// 460 EUR is numerically under 5 000, but its SEK equivalent (5 200 kr)
|
||||
// is ABOVE the floor: comparing the raw EUR number would wrongly tag it.
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sup-eur',
|
||||
supplier_invoice_number: 'LF-510',
|
||||
invoice_date: '2025-12-15',
|
||||
subtotal: 460,
|
||||
currency: 'EUR',
|
||||
subtotal_sek: 5200,
|
||||
notes: 'SaaS-licens period 2026-01-01 till 2026-12-31',
|
||||
suppliers: { name: 'Euro SaaS GmbH' },
|
||||
supplier_invoice_items: [{ description: 'License', account_number: '5800' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].confidence).toBe('high')
|
||||
expect(result[0].reason).not.toContain('Under 5 000 kr')
|
||||
})
|
||||
|
||||
it('skips the floor entirely for a foreign-currency invoice without subtotal_sek', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({ data: [], error: null }) // invoices
|
||||
// No SEK amount is resolvable, so the floor must not tag on the raw EUR
|
||||
// number (wrong currency): the suggestion keeps its confidence.
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sup-eur-nosek',
|
||||
supplier_invoice_number: 'LF-511',
|
||||
invoice_date: '2025-12-15',
|
||||
subtotal: 120,
|
||||
currency: 'EUR',
|
||||
subtotal_sek: null,
|
||||
notes: 'SaaS-licens period 2026-01-01 till 2026-12-31',
|
||||
suppliers: { name: 'Euro SaaS GmbH' },
|
||||
supplier_invoice_items: [{ description: 'License', account_number: '5800' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].confidence).toBe('high')
|
||||
expect(result[0].reason).not.toContain('Under 5 000 kr')
|
||||
})
|
||||
|
||||
it('never applies the floor to personnel-cost (70xx-76xx) lines', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({ data: [], error: null }) // invoices
|
||||
// Personnel costs must ALWAYS be accrued regardless of amount, so a
|
||||
// 1 500 kr post on a 7xxx account keeps its confidence.
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sup-personnel',
|
||||
supplier_invoice_number: 'LF-503',
|
||||
invoice_date: '2025-12-15',
|
||||
subtotal: 1500,
|
||||
notes: 'Utbildning personal period 2026-01-01 till 2026-03-31',
|
||||
suppliers: { name: 'Kursbolaget AB' },
|
||||
supplier_invoice_items: [{ description: 'Kurs', account_number: '7610' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
{ entityType: 'enskild_firma' },
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].confidence).toBe('high')
|
||||
expect(result[0].reason).not.toContain('Under 5 000 kr')
|
||||
})
|
||||
|
||||
it('applies the floor to a 79xx line: not a personnel cost', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({ data: [], error: null }) // invoices
|
||||
// 7990 (övriga rörelsekostnader) is in the 7xxx class but is NOT a
|
||||
// personnel cost: the exemption is BAS 70xx-76xx only, so a small 7990
|
||||
// post gets the normal under-floor downgrade.
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sup-7990',
|
||||
supplier_invoice_number: 'LF-504',
|
||||
invoice_date: '2025-12-15',
|
||||
subtotal: 1500,
|
||||
notes: 'Diverse kostnad period 2026-01-01 till 2026-03-31',
|
||||
suppliers: { name: 'Diverse AB' },
|
||||
supplier_invoice_items: [{ description: 'Övrigt', account_number: '7990' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].confidence).toBe('low')
|
||||
expect(result[0].reason).toContain('Under 5 000 kr')
|
||||
})
|
||||
|
||||
it('applies the floor to a small customer-invoice revenue deferral', async () => {
|
||||
mock.enqueue({
|
||||
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // accrual_schedules
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'inv-small',
|
||||
invoice_number: 'F-3001',
|
||||
invoice_date: '2025-12-01',
|
||||
subtotal: 2400,
|
||||
notes: 'Supportavtal för period 2026-01-01 till 2026-12-31',
|
||||
customers: { name: 'Kund AB' },
|
||||
invoice_items: [{ description: 'Support' }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
mock.enqueue({ data: [], error: null }) // supplier_invoices
|
||||
|
||||
const result = await detectPeriodisering(
|
||||
mock.supabase as never,
|
||||
'company-1',
|
||||
'period-1',
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].confidence).toBe('low')
|
||||
expect(result[0].reason).toContain('Under 5 000 kr')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,28 @@ import { parseInvoiceDateRange } from './date-range-parser'
|
||||
export type PeriodiseringSource = 'invoice' | 'supplier_invoice'
|
||||
export type PeriodiseringConfidence = 'high' | 'medium' | 'low'
|
||||
|
||||
/** The entity types the materiality wording distinguishes between. Mirrors
|
||||
* `EntityType` in `@/types` without importing app types into lib/. */
|
||||
export type PeriodiseringEntityType = 'enskild_firma' | 'aktiebolag'
|
||||
|
||||
/**
|
||||
* Materiality floor for auto-detected periodiseringar, in SEK.
|
||||
*
|
||||
* Both simplification tracks land on the same number: K1 (BFNAR 2006:1,
|
||||
* förenklat årsbokslut for enskild firma) has no requirement to accrue posts
|
||||
* below 5 000 kr, and K2 (BFNAR 2016:10) lets a company skip accruing
|
||||
* individual recurring costs below 5 000 kr. Suggestions under the floor are
|
||||
* TAGGED as low confidence rather than dropped: the relief is a MAY, never a
|
||||
* MUST, so the user can still accept them. Personnel costs (BAS 70xx-76xx)
|
||||
* must always be accrued regardless of amount, so the floor never applies
|
||||
* there. The floor is a SEK threshold: for foreign-currency invoices it is
|
||||
* compared against the subtotal_sek-derived amount, and when no SEK amount
|
||||
* can be resolved the floor is skipped entirely rather than compared against
|
||||
* a number in the wrong currency (mirrors the accrual-k2-hint decision,
|
||||
* DECISIONS.md 2026-07-26).
|
||||
*/
|
||||
export const PERIODISERING_MATERIALITY_FLOOR_SEK = 5000
|
||||
|
||||
export interface PeriodiseringSuggestion {
|
||||
/** Underlying source invoice id (invoices.id or supplier_invoices.id). */
|
||||
source_invoice_id: string
|
||||
@@ -38,6 +60,8 @@ interface InvoiceRow {
|
||||
invoice_number: string | null
|
||||
invoice_date: string
|
||||
subtotal: number
|
||||
currency: string | null
|
||||
subtotal_sek: number | null
|
||||
notes: string | null
|
||||
customers: { name: string } | null
|
||||
invoice_items: { description: string }[] | null
|
||||
@@ -48,6 +72,8 @@ interface SupplierInvoiceRow {
|
||||
supplier_invoice_number: string
|
||||
invoice_date: string
|
||||
subtotal: number
|
||||
currency: string | null
|
||||
subtotal_sek: number | null
|
||||
notes: string | null
|
||||
suppliers: { name: string } | null
|
||||
supplier_invoice_items: { description: string; account_number: string }[] | null
|
||||
@@ -84,17 +110,26 @@ function buildSuggestion(args: {
|
||||
sourceId: string
|
||||
sourceType: PeriodiseringSource
|
||||
netAmount: number
|
||||
/** Invoice currency (ISO code). Null/undefined is treated as SEK. */
|
||||
currency: string | null
|
||||
/** SEK-converted net amount (subtotal_sek). Null when the invoice predates
|
||||
* the SEK columns or no exchange rate was captured. */
|
||||
netAmountSek: number | null
|
||||
description: string | null
|
||||
itemDescriptions: string[]
|
||||
/** Default expense account from the first supplier-invoice line. Reserved
|
||||
* for a future enhancement where the wizard can pre-fill the manual-entry
|
||||
* form with the actual account rather than the 5800 fallback. Not used
|
||||
* yet but kept on the buildSuggestion args to keep the call sites stable. */
|
||||
_itemDefaultAccount: string | null
|
||||
/** Account numbers of the source lines (supplier invoices only; customer
|
||||
* invoices carry no expense accounts). The first entry doubles as the
|
||||
* default expense account for a future manual-entry pre-fill; today the
|
||||
* list only drives the personnel-cost (70xx-76xx) exemption from the
|
||||
* materiality floor. */
|
||||
itemAccounts: string[]
|
||||
sourceLabel: string
|
||||
periodEnd: string
|
||||
/** Drives the regelverk cited in the materiality wording: K1 (BFNAR
|
||||
* 2006:1) for enskild firma, K2 (BFNAR 2016:10) otherwise. */
|
||||
entityType?: PeriodiseringEntityType | null
|
||||
}): PeriodiseringSuggestion | null {
|
||||
const { sourceId, sourceType, netAmount, description, itemDescriptions, sourceLabel, periodEnd } = args
|
||||
const { sourceId, sourceType, netAmount, currency, netAmountSek, description, itemDescriptions, itemAccounts, sourceLabel, periodEnd, entityType } = args
|
||||
if (!Number.isFinite(netAmount) || netAmount <= 0) return null
|
||||
|
||||
// Try the head text first, then each item: first hit wins.
|
||||
@@ -129,15 +164,44 @@ function buildSuggestion(args: {
|
||||
|
||||
// Confidence policy: parsed from the head description wins "high"; parsed
|
||||
// from a line item lands at "medium" since the head text is the canonical
|
||||
// location. "low" is reserved for future heuristics that catch e.g. a
|
||||
// single date + interpretation rules.
|
||||
const confidence: PeriodiseringConfidence = parsedFromItem ? 'medium' : 'high'
|
||||
// location.
|
||||
let confidence: PeriodiseringConfidence = parsedFromItem ? 'medium' : 'high'
|
||||
|
||||
const isSupplier = sourceType === 'supplier_invoice'
|
||||
const reason = isSupplier
|
||||
let reason = isSupplier
|
||||
? `Leverantörsfakturan löper ${parsed.startDate}: ${parsed.endDate}. ${daysAfterPeriodEnd} av ${totalDays} dagar avser nästa räkenskapsår.`
|
||||
: `Kundfakturan löper ${parsed.startDate}: ${parsed.endDate}. ${daysAfterPeriodEnd} av ${totalDays} dagar avser nästa räkenskapsår.`
|
||||
|
||||
// Materiality floor: below 5 000 kr the K1/K2 simplifications say the post
|
||||
// normally need not be accrued, so downgrade to "low" (the wizard only
|
||||
// pre-ticks "high") and say why. Personnel costs (BAS 70xx-76xx) are exempt
|
||||
// from the relief and keep their confidence: they must always be accrued.
|
||||
// 78xx (avskrivningar) and 79xx (övriga rörelsekostnader) are NOT personnel
|
||||
// costs, so they get the relief like any other cost.
|
||||
//
|
||||
// The floor is a SEK threshold. For a foreign-currency invoice the
|
||||
// comparison uses the periodisation share of subtotal_sek; when no SEK
|
||||
// amount is available the floor is SKIPPED entirely, because tagging on an
|
||||
// amount in the wrong currency is strictly worse than not tagging
|
||||
// (accrual-k2-hint precedent, DECISIONS.md 2026-07-26).
|
||||
const touchesPersonnelCost = itemAccounts.some((a) => /^7[0-6]/.test(a ?? ''))
|
||||
const isSek = !currency || currency === 'SEK'
|
||||
let periodisationAmountSek: number | null = null
|
||||
if (isSek) {
|
||||
periodisationAmountSek = periodisationAmount
|
||||
} else if (netAmountSek != null && Number.isFinite(netAmountSek) && netAmountSek > 0) {
|
||||
periodisationAmountSek = roundOre(netAmountSek * ratio)
|
||||
}
|
||||
if (
|
||||
periodisationAmountSek !== null &&
|
||||
periodisationAmountSek < PERIODISERING_MATERIALITY_FLOOR_SEK &&
|
||||
!touchesPersonnelCost
|
||||
) {
|
||||
confidence = 'low'
|
||||
const regelverk = entityType === 'enskild_firma' ? 'K1' : 'K2'
|
||||
reason = `${reason} Under 5 000 kr: behöver normalt inte periodiseras (${regelverk}).`
|
||||
}
|
||||
|
||||
return {
|
||||
source_invoice_id: sourceId,
|
||||
source_type: sourceType,
|
||||
@@ -167,7 +231,14 @@ export async function detectPeriodisering(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
options?: {
|
||||
/** Company entity type: chooses the regelverk the materiality wording
|
||||
* cites (K1 for enskild_firma, K2 otherwise). Optional so callers that
|
||||
* cannot resolve it still get suggestions with the K2 default. */
|
||||
entityType?: PeriodiseringEntityType | null
|
||||
},
|
||||
): Promise<PeriodiseringSuggestion[]> {
|
||||
const entityType = options?.entityType ?? null
|
||||
// Resolve the fiscal period window. We scope candidate invoices to those
|
||||
// dated within the period: anything outside is either an opening-balance
|
||||
// carryover (its own concern) or a future invoice (no period to detect).
|
||||
@@ -208,7 +279,7 @@ export async function detectPeriodisering(
|
||||
// status label that overlaps with sent here.
|
||||
const { data: invoiceRows } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, subtotal, notes, customers(name), invoice_items(description)')
|
||||
.select('id, invoice_number, invoice_date, subtotal, currency, subtotal_sek, notes, customers(name), invoice_items(description)')
|
||||
.eq('company_id', companyId)
|
||||
.gte('invoice_date', periodStart)
|
||||
.lte('invoice_date', periodEnd)
|
||||
@@ -218,7 +289,7 @@ export async function detectPeriodisering(
|
||||
const { data: supplierRows } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select(
|
||||
'id, supplier_invoice_number, invoice_date, subtotal, notes, suppliers(name), supplier_invoice_items(description, account_number)',
|
||||
'id, supplier_invoice_number, invoice_date, subtotal, currency, subtotal_sek, notes, suppliers(name), supplier_invoice_items(description, account_number)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.gte('invoice_date', periodStart)
|
||||
@@ -238,11 +309,14 @@ export async function detectPeriodisering(
|
||||
sourceId: row.id,
|
||||
sourceType: 'invoice',
|
||||
netAmount: Number(row.subtotal ?? 0),
|
||||
currency: row.currency ?? null,
|
||||
netAmountSek: row.subtotal_sek != null ? Number(row.subtotal_sek) : null,
|
||||
description: row.notes,
|
||||
itemDescriptions: itemDescs,
|
||||
_itemDefaultAccount: null,
|
||||
itemAccounts: [],
|
||||
sourceLabel,
|
||||
periodEnd,
|
||||
entityType,
|
||||
})
|
||||
if (s) suggestions.push(s)
|
||||
}
|
||||
@@ -250,18 +324,23 @@ export async function detectPeriodisering(
|
||||
for (const row of (supplierRows ?? []) as unknown as SupplierInvoiceRow[]) {
|
||||
if (coveredSupplierInvoices.has(row.id)) continue
|
||||
const itemDescs = (row.supplier_invoice_items ?? []).map((i) => i.description).filter(Boolean)
|
||||
const firstAccount = row.supplier_invoice_items?.[0]?.account_number ?? null
|
||||
const itemAccounts = (row.supplier_invoice_items ?? [])
|
||||
.map((i) => i.account_number)
|
||||
.filter(Boolean)
|
||||
const supplierName = row.suppliers?.name ?? 'Okänd leverantör'
|
||||
const sourceLabel = `${supplierName} (lev.faktura ${row.supplier_invoice_number})`
|
||||
const s = buildSuggestion({
|
||||
sourceId: row.id,
|
||||
sourceType: 'supplier_invoice',
|
||||
netAmount: Number(row.subtotal ?? 0),
|
||||
currency: row.currency ?? null,
|
||||
netAmountSek: row.subtotal_sek != null ? Number(row.subtotal_sek) : null,
|
||||
description: row.notes,
|
||||
itemDescriptions: itemDescs,
|
||||
_itemDefaultAccount: firstAccount,
|
||||
itemAccounts,
|
||||
sourceLabel,
|
||||
periodEnd,
|
||||
entityType,
|
||||
})
|
||||
if (s) suggestions.push(s)
|
||||
}
|
||||
|
||||
+5
-1
@@ -1047,6 +1047,7 @@
|
||||
"preview_invalid_period": "The period end must be after its start",
|
||||
"preview_min_months": "Deferral requires at least 2 calendar months",
|
||||
"k2_hint": "Amounts under SEK 5,000 normally do not need to be deferred (K2).",
|
||||
"k1_hint": "Amounts under SEK 5,000 normally do not need to be deferred (K1, simplified annual accounts).",
|
||||
"validation_period": "Enter a period of at least 2 calendar months",
|
||||
"incomplete_toast_title": "Incomplete deferral",
|
||||
"incomplete_toast_description": "Set the period start and end (at least 2 months) or remove the deferral from the line."
|
||||
@@ -1949,7 +1950,10 @@
|
||||
"fy_unlock_success": "Fiscal year unlocked",
|
||||
"fy_action_error": "The action could not be completed",
|
||||
"related_heading": "Related",
|
||||
"related_chart_of_accounts": "Chart of accounts (BAS)"
|
||||
"related_chart_of_accounts": "Chart of accounts (BAS)",
|
||||
"periodisering_label": "Accruals",
|
||||
"periodisering_help": "The year-end wizard scans invoices for date ranges extending into the next fiscal year and suggests accruals. Nothing is booked without your review.",
|
||||
"periodisering_open_wizard": "Open the accruals wizard"
|
||||
},
|
||||
"settings_tax": {},
|
||||
"settings_team": {},
|
||||
|
||||
+5
-1
@@ -1047,6 +1047,7 @@
|
||||
"preview_invalid_period": "Periodens slut måste vara efter dess start",
|
||||
"preview_min_months": "Periodisering kräver minst 2 kalendermånader",
|
||||
"k2_hint": "Belopp under 5 000 kr behöver normalt inte periodiseras (K2).",
|
||||
"k1_hint": "Belopp under 5 000 kr behöver normalt inte periodiseras (K1, förenklat årsbokslut).",
|
||||
"validation_period": "Ange en period på minst 2 kalendermånader",
|
||||
"incomplete_toast_title": "Ofullständig periodisering",
|
||||
"incomplete_toast_description": "Ange periodens start och slut (minst 2 månader) eller ta bort periodiseringen från raden."
|
||||
@@ -1949,7 +1950,10 @@
|
||||
"fy_unlock_success": "Räkenskapsåret är upplåst",
|
||||
"fy_action_error": "Åtgärden kunde inte slutföras",
|
||||
"related_heading": "Relaterat",
|
||||
"related_chart_of_accounts": "Kontoplan (BAS)"
|
||||
"related_chart_of_accounts": "Kontoplan (BAS)",
|
||||
"periodisering_label": "Periodisering",
|
||||
"periodisering_help": "I bokslut-wizarden skannas fakturor efter datumintervall som sträcker sig in i nästa räkenskapsår och periodiseringar föreslås. Inget bokförs utan din granskning.",
|
||||
"periodisering_open_wizard": "Öppna periodiserings-wizarden"
|
||||
},
|
||||
"settings_tax": {},
|
||||
"settings_team": {},
|
||||
|
||||
Reference in New Issue
Block a user