Files
accounted/components/bookkeeping/__tests__/accrual-k2-hint.test.ts
T
64fc7c783d 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>
2026-08-20 10:06:01 +02:00

122 lines
4.7 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
K2_ACCRUAL_THRESHOLD_SEK,
accrualHintKey,
resolveAccrualAmountSek,
shouldShowK2AccrualHint,
} from '@/components/bookkeeping/accrual-k2-hint'
describe('resolveAccrualAmountSek', () => {
it('returns the amount unchanged for SEK', () => {
expect(resolveAccrualAmountSek({ amount: 4999, currency: 'SEK' })).toBe(4999)
})
it('treats a missing currency as SEK (the editors default to it)', () => {
expect(resolveAccrualAmountSek({ amount: 4999 })).toBe(4999)
expect(resolveAccrualAmountSek({ amount: 4999, currency: null })).toBe(4999)
expect(resolveAccrualAmountSek({ amount: 4999, currency: '' })).toBe(4999)
})
it('converts a foreign amount with the given rate, rounded to ore', () => {
// 500 EUR at 11.5012 = 5750.60 kr, not 5750.5999999999995.
expect(
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: 11.5012 }),
).toBe(5750.6)
})
it('returns null for a foreign amount with no usable rate', () => {
expect(resolveAccrualAmountSek({ amount: 500, currency: 'EUR' })).toBeNull()
expect(
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: null }),
).toBeNull()
// An empty rate field parses to NaN; a cleared one to 0.
expect(
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: NaN }),
).toBeNull()
expect(
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: 0 }),
).toBeNull()
expect(
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: -11.5 }),
).toBeNull()
})
it('returns null for a non-finite amount', () => {
expect(resolveAccrualAmountSek({ amount: NaN, currency: 'SEK' })).toBeNull()
})
it('accepts a lowercase or padded currency code', () => {
expect(resolveAccrualAmountSek({ amount: 100, currency: ' sek ' })).toBe(100)
expect(
resolveAccrualAmountSek({ amount: 100, currency: ' eur ', exchangeRate: 11.5 }),
).toBe(1150)
})
})
describe('shouldShowK2AccrualHint', () => {
it('hides the hint for a foreign line that is above 5 000 kr once converted', () => {
// THE BUG: 500 EUR is ~5 750 kr, i.e. ABOVE the K2 threshold, but the raw
// 500 reads as below it and used to surface "under 5 000 kr" wrongly.
expect(
shouldShowK2AccrualHint({ amount: 500, currency: 'EUR', exchangeRate: 11.5 }),
).toBe(false)
})
it('shows the hint for a foreign line that is genuinely below 5 000 kr', () => {
// 400 EUR = 4 600 kr.
expect(
shouldShowK2AccrualHint({ amount: 400, currency: 'EUR', exchangeRate: 11.5 }),
).toBe(true)
})
it('shows the hint for a foreign line the raw amount would have hidden', () => {
// The reverse direction: 6 000 NOK at 0.98 = 5 880 kr... above. At 0.8 it
// is 4 800 kr, below the threshold, while the raw 6 000 reads as above.
expect(
shouldShowK2AccrualHint({ amount: 6000, currency: 'NOK', exchangeRate: 0.8 }),
).toBe(true)
})
it('hides the hint when the SEK value cannot be determined', () => {
// The customer-invoice editor has no exchange_rate field at all: never
// guess a branch, show nothing.
expect(shouldShowK2AccrualHint({ amount: 500, currency: 'EUR' })).toBe(false)
expect(shouldShowK2AccrualHint({ amount: 50000, currency: 'EUR' })).toBe(false)
})
it('leaves SEK behaviour exactly as it was', () => {
expect(shouldShowK2AccrualHint({ amount: 4999.99, currency: 'SEK' })).toBe(true)
expect(shouldShowK2AccrualHint({ amount: 1, currency: 'SEK' })).toBe(true)
expect(shouldShowK2AccrualHint({ amount: K2_ACCRUAL_THRESHOLD_SEK, currency: 'SEK' })).toBe(
false,
)
expect(shouldShowK2AccrualHint({ amount: 5001, currency: 'SEK' })).toBe(false)
expect(shouldShowK2AccrualHint({ amount: 0, currency: 'SEK' })).toBe(false)
// No currency prop at all is the same as SEK.
expect(shouldShowK2AccrualHint({ amount: 4999 })).toBe(true)
expect(shouldShowK2AccrualHint({ amount: 5000 })).toBe(false)
})
it('does not fire on a negative (discount) line', () => {
expect(shouldShowK2AccrualHint({ amount: -100, currency: 'SEK' })).toBe(false)
expect(
shouldShowK2AccrualHint({ amount: -100, currency: 'EUR', exchangeRate: 11.5 }),
).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')
})
})