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
@@ -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')
})
})
+94 -15
View File
@@ -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)
}