Files
accounted/lib/bokslut/assets/__tests__/k3-components.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

394 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest'
import { validateComponents } from '../k3-components'
import { computeComponentDepreciation, computeAnnualDepreciation } from '../depreciation-engine'
import type { Asset, K3Component } from '@/types'
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'co-1',
name: 'Test',
category: 'building',
acquisition_date: '2025-01-01',
acquisition_cost: 1_000_000,
salvage_value: 0,
useful_life_months: 240, // 20 years asset-level (overridden by components when set)
depreciation_method: 'linear',
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
}
}
const FULL_YEAR_2025 = { period_start: '2025-01-01', period_end: '2025-12-31' }
// ============================================================
// validateComponents: pure validator
// ============================================================
describe('validateComponents', () => {
it('null components → no errors', () => {
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: null,
})
expect(errors).toEqual([])
})
it('undefined components → no errors', () => {
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: undefined,
})
expect(errors).toEqual([])
})
it('valid breakdown summing to acquisition_cost → no errors', () => {
const components: K3Component[] = [
{ name: 'Stomme', cost: 600_000, useful_life_months: 600 },
{ name: 'Tak', cost: 300_000, useful_life_months: 360 },
{ name: 'Installationer', cost: 100_000, useful_life_months: 240 },
]
const { errors } = validateComponents({
acquisition_cost: 1_000_000,
k3_components: components,
})
expect(errors).toEqual([])
})
it('cost mismatch: 100 000 asset with components summing to 95 000 → error', () => {
const components: K3Component[] = [
{ name: 'A', cost: 50_000, useful_life_months: 60 },
{ name: 'B', cost: 45_000, useful_life_months: 120 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.includes('summerar'))).toBe(true)
})
it('cost mismatch within 1 kr tolerance → no error', () => {
// 99_999.5 vs 100_000: öre rounding shouldn't fail
const components: K3Component[] = [
{ name: 'A', cost: 50_000, useful_life_months: 60 },
{ name: 'B', cost: 49_999.5, useful_life_months: 60 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
expect(errors).toEqual([])
})
it('negative useful_life_months → error', () => {
const components: K3Component[] = [
{ name: 'A', cost: 50_000, useful_life_months: -60 },
{ name: 'B', cost: 50_000, useful_life_months: 60 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
expect(errors.some((e) => e.toLowerCase().includes('nyttjandeperioden'))).toBe(true)
})
it('non-integer useful_life_months → error', () => {
const components: K3Component[] = [
{ name: 'A', cost: 100_000, useful_life_months: 60.5 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
expect(errors.some((e) => e.toLowerCase().includes('heltal'))).toBe(true)
})
it('component cost ≤ 0 → error', () => {
const components: K3Component[] = [
{ name: 'A', cost: 0, useful_life_months: 60 },
{ name: 'B', cost: 100_000, useful_life_months: 60 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
expect(errors.some((e) => e.toLowerCase().includes('anskaffningsvärdet'))).toBe(true)
})
it('salvage_value > component cost → error', () => {
const components: K3Component[] = [
{ name: 'A', cost: 50_000, useful_life_months: 60, salvage_value: 60_000 },
{ name: 'B', cost: 50_000, useful_life_months: 60 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
expect(errors.some((e) => e.toLowerCase().includes('restvärdet'))).toBe(true)
})
it('salvage_value negative → error', () => {
const components: K3Component[] = [
{ name: 'A', cost: 100_000, useful_life_months: 60, salvage_value: -100 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
expect(errors.some((e) => e.toLowerCase().includes('restvärdet'))).toBe(true)
})
it('empty array but k3_components set to non-null → error', () => {
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: [],
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.toLowerCase().includes('tom'))).toBe(true)
})
it('non-array value → error', () => {
// Defensive: simulate malformed JSONB read from DB.
const { errors } = validateComponents({
acquisition_cost: 100_000,
// @ts-expect-error -- intentional malformed input
k3_components: 'not-an-array',
})
expect(errors.length).toBeGreaterThan(0)
})
it('aggregates multiple errors: does not bail on first', () => {
const components: K3Component[] = [
{ name: 'A', cost: -100, useful_life_months: 0 },
{ name: 'B', cost: 50_000, useful_life_months: 60, salvage_value: 60_000 },
]
const { errors } = validateComponents({
acquisition_cost: 100_000,
k3_components: components,
})
// Should catch: negative cost, zero useful life, salvage > cost, plus sum mismatch.
expect(errors.length).toBeGreaterThanOrEqual(3)
})
})
// ============================================================
// computeComponentDepreciation: engine
// ============================================================
describe('computeComponentDepreciation', () => {
it('1M building: roof 300k/240mo, facade 500k/480mo, installations 200k/120mo', () => {
const asset = makeAsset({
acquisition_cost: 1_000_000,
k3_components: [
{ name: 'Tak', cost: 300_000, useful_life_months: 240 },
{ name: 'Fasad', cost: 500_000, useful_life_months: 480 },
{ name: 'Installationer', cost: 200_000, useful_life_months: 120 },
],
})
// Annual per component:
// Tak: 300_000 × 12/240 = 15_000
// Fasad: 500_000 × 12/480 = 12_500
// Installationer: 200_000 × 12/120 = 20_000
// Sum: 47_500
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
expect(result.amount).toBe(47_500)
expect(result.proRated).toBe(false)
expect(result.perComponent).toHaveLength(3)
expect(result.perComponent[0]).toEqual({ name: 'Tak', amount: 15_000 })
expect(result.perComponent[1]).toEqual({ name: 'Fasad', amount: 12_500 })
expect(result.perComponent[2]).toEqual({ name: 'Installationer', amount: 20_000 })
})
it('half-year pro-ration: acquired July 1 → roughly half the annual sum', () => {
const asset = makeAsset({
acquisition_date: '2025-07-01',
acquisition_cost: 1_000_000,
k3_components: [
{ name: 'Tak', cost: 300_000, useful_life_months: 240 },
{ name: 'Fasad', cost: 500_000, useful_life_months: 480 },
{ name: 'Installationer', cost: 200_000, useful_life_months: 120 },
],
})
// Full-year sum = 47_500. Jul 1 - Dec 31 = 184 days, 184/365 ≈ 0.5041.
// Per-component rounding may slightly drift: accept ~5_900-6_100 per row sum.
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
expect(result.proRated).toBe(true)
expect(result.amount).toBeGreaterThan(23_500)
expect(result.amount).toBeLessThan(24_500)
// perComponent entries each carry roughly half of their full-year amount.
expect(result.perComponent[0].amount).toBeGreaterThan(7_400)
expect(result.perComponent[0].amount).toBeLessThan(7_700)
})
it('mid-year disposal: each component pro-rates to disposal date', () => {
const asset = makeAsset({
acquisition_cost: 1_000_000,
disposed_at: '2025-06-30',
disposed_proceeds: 800_000,
k3_components: [
{ name: 'Tak', cost: 300_000, useful_life_months: 240 },
{ name: 'Fasad', cost: 500_000, useful_life_months: 480 },
{ name: 'Installationer', cost: 200_000, useful_life_months: 120 },
],
})
// Jan 1 - Jun 30 = 181 days / 365 ≈ 0.4959. Full sum 47_500 × 0.4959 ≈ 23_555.
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
expect(result.proRated).toBe(true)
expect(result.amount).toBeGreaterThan(23_200)
expect(result.amount).toBeLessThan(23_900)
})
it('respects per-component salvage_value', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
k3_components: [
// 100_000 cost − 20_000 salvage = 80_000 depreciable / 60 months
// → 80_000 × 12/60 = 16_000 per year
{ name: 'A', cost: 100_000, useful_life_months: 60, salvage_value: 20_000 },
],
})
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
expect(result.amount).toBe(16_000)
})
it('returns zero when components array is empty', () => {
const asset = makeAsset({
k3_components: [],
})
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
expect(result.amount).toBe(0)
expect(result.perComponent).toEqual([])
})
it('skips components fully past end-of-life (window collapses)', () => {
// Component with 12-month life acquired Jan 1, 2023: fully depreciated by
// Jan 1, 2024. For fiscal year 2025 (Jan 1 - Dec 31) the window is empty.
const asset = makeAsset({
acquisition_date: '2023-01-01',
acquisition_cost: 100_000,
k3_components: [
{ name: 'Kort', cost: 30_000, useful_life_months: 12 },
{ name: 'Lång', cost: 70_000, useful_life_months: 240 },
],
})
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
// Kort: 0 (life ended). Lång: 70_000 × 12/240 = 3_500.
expect(result.amount).toBe(3_500)
expect(result.perComponent[0]).toEqual({ name: 'Kort', amount: 0 })
expect(result.perComponent[1]).toEqual({ name: 'Lång', amount: 3_500 })
})
it('treats components with non-positive cost/life as 0 (defensive)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
k3_components: [
{ name: 'Trasig', cost: 0, useful_life_months: 0 },
{ name: 'Ok', cost: 100_000, useful_life_months: 60 },
],
})
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
// Bad row is skipped; good row produces 100_000 × 12/60 = 20_000.
expect(result.amount).toBe(20_000)
expect(result.perComponent[0].amount).toBe(0)
expect(result.perComponent[1].amount).toBe(20_000)
})
it('uses empty-name fallback label', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
k3_components: [
{ name: '', cost: 100_000, useful_life_months: 60 },
],
})
const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
expect(result.perComponent[0].name).toBe('Komponent 1')
})
})
// ============================================================
// computeAnnualDepreciation dispatch: k3_components precedence
// ============================================================
describe('computeAnnualDepreciation: K3 dispatch', () => {
it('routes to component depreciation when k3_components is non-empty', () => {
const asset = makeAsset({
acquisition_cost: 1_000_000,
// method+life on the asset would compute different number: engine should ignore them
depreciation_method: 'declining_balance_30',
useful_life_months: 60,
k3_components: [
{ name: 'Tak', cost: 300_000, useful_life_months: 240 },
{ name: 'Fasad', cost: 500_000, useful_life_months: 480 },
{ name: 'Installationer', cost: 200_000, useful_life_months: 120 },
],
})
const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
// Component sum (see test above) = 47_500.
expect(result.amount).toBe(47_500)
expect(result.proRated).toBe(false)
})
it('K2-path stays byte-equivalent: linear asset without k3_components is unchanged', () => {
// 60_000 / 5yr = 12_000 per year: same as the linear baseline test.
const asset = makeAsset({
acquisition_cost: 60_000,
useful_life_months: 60,
k3_components: null,
depreciation_method: 'linear',
category: 'equipment',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
expect(result.amount).toBe(12_000)
expect(result.proRated).toBe(false)
})
it('K2-path stays byte-equivalent: empty array of components is treated as "no components"', () => {
// Engine guards with .length > 0: an empty array must fall through to
// the method-based dispatch so a tampered DB row doesn't zero out the
// depreciation silently.
const asset = makeAsset({
acquisition_cost: 60_000,
useful_life_months: 60,
k3_components: [],
depreciation_method: 'linear',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
expect(result.amount).toBe(12_000)
})
it('disposal before period start returns 0 even with components', () => {
const asset = makeAsset({
acquisition_cost: 1_000_000,
disposed_at: '2024-12-31',
disposed_proceeds: 500_000,
k3_components: [
{ name: 'Tak', cost: 300_000, useful_life_months: 240 },
{ name: 'Fasad', cost: 500_000, useful_life_months: 480 },
{ name: 'Installationer', cost: 200_000, useful_life_months: 120 },
],
})
const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
expect(result.amount).toBe(0)
})
})