Files
accounted/components/bookkeeping/accrual-k2-hint.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

93 lines
3.5 KiB
TypeScript

/**
* K2 vasentlighetsgrans for periodisering, extracted as a pure helper so it
* can be unit-tested (this repo's Vitest runs in a node environment and does
* not render components).
*
* K2 (BFNAR 2016:10) lets a smaller company skip accruing individual
* recurring costs below 5 000 kr that do not fluctuate more than 20 % year
* over year; personnel costs must always be accrued. It is a simplification
* the company MAY use, never an obligation, so the UI copy it drives is
* advisory only. That is exactly why it must not fire on an amount we cannot
* express in kronor: an advisory nudge based on the wrong unit is worse than
* no nudge at all.
*
* The invoice editors hold line amounts in the invoice's currency. A 500 EUR
* line is roughly 5 750 kr and therefore ABOVE the threshold, but a naive
* `amount < 5000` reads it as below (and a 400 USD line reads the reverse
* way once the rate moves). Converting needs an FX rate. The supplier-invoice
* form has one (Riksbanken-fetched or hand-typed); the customer-invoice
* editor has none. When no rate is available we return null and the caller
* shows nothing, rather than guessing a branch.
*/
/** BFNAR 2016:10: the K2 accrual simplification ceiling, in SEK. */
export const K2_ACCRUAL_THRESHOLD_SEK = 5000
export interface AccrualAmountInput {
/** Net line amount, expressed in `currency`. */
amount: number
/** ISO currency code of `amount`. Missing/empty is treated as SEK. */
currency?: string | null
/** SEK per one unit of `currency`. Required to convert anything non-SEK. */
exchangeRate?: number | null
}
/**
* The line amount in SEK, or null when it cannot be determined.
*
* Deliberately NOT `resolveSekAmount()` from `@/lib/bookkeeping/currency-utils`:
* that helper falls back to returning the foreign amount untouched, which is
* the silent wrong-unit branch this helper exists to avoid.
*/
export function resolveAccrualAmountSek({
amount,
currency,
exchangeRate,
}: AccrualAmountInput): number | null {
if (typeof amount !== 'number' || !Number.isFinite(amount)) return null
const code = (currency ?? '').trim().toUpperCase()
if (code === '' || code === 'SEK') {
return Math.round(amount * 100) / 100
}
if (
typeof exchangeRate !== 'number' ||
!Number.isFinite(exchangeRate) ||
exchangeRate <= 0
) {
return null
}
return Math.round(amount * exchangeRate * 100) / 100
}
/**
* 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.
*/
export function shouldShowK2AccrualHint(input: AccrualAmountInput): boolean {
const amountSek = resolveAccrualAmountSek(input)
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'
}