Files
accounted/lib/bokslut/arsredovisning/__tests__/asset-note-figures.test.ts
T
MattssonandClaude Fable 5 5ca64bde30 feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* feat(bokslut): IL 18 kap pooled tax depreciation with method election

Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25
as a company-level annual pool separate from per-asset book depreciation.
Method election persisted with immutable snapshots and book-conformity
confirmation for rakenskapsenlig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): move tax depreciation migrations to coordinated versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokslut): keep tax depreciation view loadable when a saved election goes stale

A predecessor's changed closing value can push a saved elected deduction
above the new statutory maximum; the view now falls back to the statutory
recomputation so the snapshot is flagged stale instead of crashing.
Ratchet naive-ore-round baseline down by 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokslut): resolve tax-depreciation period selects statically

The no-phantom-columns guard counts every select it cannot resolve
toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo
4 over (364 > 360). Inline the literal column list at the four call
sites so the guard verifies these columns instead of skipping them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokslut): address review findings on tax depreciation election

- DepreciationPanel: gate the saving flag on a dedicated save sequence
  so a successful save (which refreshes the view and bumps the request
  version) no longer leaves the card permanently busy
- computeTaxDepreciation: refuse kompletteringsregel_20 with a positive
  basis and no acquisition cohorts instead of degenerating to a full
  write-off the cohort evidence does not support (IL 18 kap. 17 §)
- migration 227000: judge the asset-method guards on NEW.disposed_at so
  reversing a disposal cannot reactivate a grandfathered non-linear row
- migration 227200: require snapshot column completeness in the CHECK;
  SQL NULL semantics let partially populated snapshots pass the pure
  arithmetic comparisons
- depreciation route: use the string issue code 'custom' like the rest
  of the codebase instead of the Zod 3 compat enum

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:43:58 +02:00

211 lines
7.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
computeAssetNoteFigures,
type PostedScheduleRow,
type PeriodLike,
} from '../asset-note-figures'
import type { Asset } from '@/types'
const FP2023: PeriodLike = { id: 'fp2023', period_start: '2023-01-01', period_end: '2023-12-31' }
const FP2024: PeriodLike = { id: 'fp2024', period_start: '2024-01-01', period_end: '2024-12-31' }
const FP2025: PeriodLike = { id: 'fp2025', period_start: '2025-01-01', period_end: '2025-12-31' }
const ALL_PERIODS = [FP2023, FP2024, FP2025]
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Testinventarie',
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 146_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
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: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
} as Asset
}
function posted(
assetId: string,
periodId: string,
amount: number | string,
): PostedScheduleRow {
return {
asset_id: assetId,
fiscal_period_id: periodId,
planned_depreciation: amount,
journal_entry_id: `je-${assetId}-${periodId}`,
}
}
function figuresFor(
asset: Asset,
schedules: PostedScheduleRow[],
periods: PeriodLike[] = ALL_PERIODS,
currentPeriodId = 'fp2025',
) {
const map = computeAssetNoteFigures({
assets: [asset],
postedSchedules: schedules,
fiscalPeriods: periods,
currentPeriodId,
})
return map.get(asset.id)
}
describe('computeAssetNoteFigures', () => {
it('uses posted schedule amounts verbatim (regression: 20 kr note drift)', () => {
// 146,000 kr over 60 months books round(146000 * 12/60) = 29,200/year.
// The old theoretical formula (base * 365 / (60 * 30.4375)) gave 29,180:
// the exact 20 kr inconsistency reported against the ledger-driven RR/BR.
const asset = makeAsset()
const f = figuresFor(asset, [
posted('asset-1', 'fp2024', 29_200),
posted('asset-1', 'fp2025', 29_200),
])
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('falls back to the booking engine for the current year when nothing is posted', () => {
const asset = makeAsset()
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 29_200)])
// Engine full-year linear: round(146000 * 12/60) = 29,200, not 29,180.
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('iterates synthetic prior years through the engine for pre-onboarding assets', () => {
// Acquired 2023-01-01 but only the current fiscal period exists in the
// DB and nothing was ever posted: two synthetic 12-month windows.
const asset = makeAsset({ acquisition_date: '2023-01-01' })
const f = figuresFor(asset, [], [FP2025])
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('caps the engine-iterated fallback at the depreciable base after useful life', () => {
const asset = makeAsset({ acquisition_date: '2010-01-01' })
const f = figuresFor(asset, [], [FP2025])
expect(f).toEqual({ ibAck: 146_000, aretsAvskrivning: 0, avgaendeAck: 0 })
})
it('sums K3 component depreciation via the engine fallback', () => {
const asset = makeAsset({
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
k3_components: [
{ name: 'Stomme', cost: 80_000, useful_life_months: 120 },
{ name: 'Tak', cost: 20_000, useful_life_months: 60, salvage_value: 0 },
],
})
const f = figuresFor(asset, [])
// round(80000 * 12/120) + round(20000 * 12/60) = 8,000 + 4,000
expect(f).toEqual({ ibAck: 0, aretsAvskrivning: 12_000, avgaendeAck: 0 })
})
it('ignores draft schedules (journal_entry_id null) everywhere', () => {
const asset = makeAsset()
const drafts: PostedScheduleRow[] = [
{ asset_id: 'asset-1', fiscal_period_id: 'fp2024', planned_depreciation: 999, journal_entry_id: null },
{ asset_id: 'asset-1', fiscal_period_id: 'fp2025', planned_depreciation: 999, journal_entry_id: null },
]
const f = figuresFor(asset, drafts)
// Both fall back to the engine: 2024 via prior-year iteration, 2025 live.
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('mirrors disposeAsset for in-period disposals: all posted rows reversed, nothing new charged', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
})
const f = figuresFor(asset, [
posted('asset-1', 'fp2023', 29_200),
posted('asset-1', 'fp2024', 29_200),
])
// disposeAsset reversed sumPostedDepreciation() = 58,400; no current-year
// charge was ever booked, so the note must not invent one.
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 0, avgaendeAck: 58_400 })
})
it('includes a posted current-year row in the disposal reversal so the asset nets to zero', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
})
const f = figuresFor(asset, [
posted('asset-1', 'fp2023', 29_200),
posted('asset-1', 'fp2024', 29_200),
posted('asset-1', 'fp2025', 29_200),
])
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 29_200, avgaendeAck: 87_600 })
expect(f!.ibAck + f!.aretsAvskrivning - f!.avgaendeAck).toBe(0)
})
it('previews engine amounts for disposals with no posted schedules at all', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
})
const f = figuresFor(asset, [])
// Fallback ibAck = 2 full engine years = 58,400. Current year clamps at
// the disposal date: round(29200 * 181/365) = 14,480. The reversal takes
// the asset's accumulated depreciation back to zero.
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 14_480, avgaendeAck: 72_880 })
})
it('coerces NUMERIC string amounts from PostgREST', () => {
const asset = makeAsset()
const f = figuresFor(asset, [
posted('asset-1', 'fp2024', '29200.00'),
posted('asset-1', 'fp2025', '29200.00'),
])
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('omits assets disposed before the period', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2024-12-31',
disposed_proceeds: 0,
})
const map = computeAssetNoteFigures({
assets: [asset],
postedSchedules: [],
fiscalPeriods: ALL_PERIODS,
currentPeriodId: 'fp2025',
})
expect(map.has('asset-1')).toBe(false)
})
it('throws when the current period is missing from the period list', () => {
expect(() =>
computeAssetNoteFigures({
assets: [makeAsset()],
postedSchedules: [],
fiscalPeriods: [FP2024],
currentPeriodId: 'fp2025',
}),
).toThrow('Current fiscal period not found')
})
})