Files
accounted/tests/pg/tax-depreciation-elections.pg.test.ts
Mattsson 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

193 lines
6.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getPool } from './setup'
import { insertFiscalPeriod, seedCompany } from './fixtures'
interface SnapshotInput {
method: 'rakenskapsenlig' | 'restvarde'
rule: 'huvudregel_30' | 'kompletteringsregel_20' | null
opening: number
basis: number
deduction: number
closing: number
}
const SNAPSHOT: SnapshotInput = {
method: 'rakenskapsenlig',
rule: 'huvudregel_30',
opening: 100_000,
basis: 130_000,
deduction: 39_000,
closing: 91_000,
}
async function saveSnapshot(
periodId: string,
overrides: Partial<SnapshotInput> = {},
): Promise<void> {
const snapshot = { ...SNAPSHOT, ...overrides }
await getPool().query(
`UPDATE public.fiscal_periods
SET tax_depreciation_method = $2,
tax_depreciation_rule = $3,
tax_depreciation_opening_value = $4,
tax_depreciation_base = $5,
tax_depreciation_deduction = $6,
tax_depreciation_closing_value = $7,
tax_depreciation_calculation = $8::jsonb
WHERE id = $1`,
[
periodId,
snapshot.method,
snapshot.rule,
snapshot.opening,
snapshot.basis,
snapshot.deduction,
snapshot.closing,
JSON.stringify({
version: 2,
elected_deduction: snapshot.deduction,
maximum_deduction: Math.max(snapshot.deduction, SNAPSHOT.deduction),
book_conformity_confirmed: snapshot.method === 'rakenskapsenlig' ? true : null,
}),
],
)
}
describe('tax depreciation election constraints', () => {
it('accepts a complete räkenskapsenlig annual snapshot', async () => {
const owner = await seedCompany()
await expect(saveSnapshot(owner.fiscalPeriodId)).resolves.toBeUndefined()
})
it('rejects an incomplete or incoherent annual snapshot', async () => {
const owner = await seedCompany()
await expect(
getPool().query(
`UPDATE public.fiscal_periods
SET tax_depreciation_method = 'restvarde',
tax_depreciation_rule = 'huvudregel_30',
tax_depreciation_opening_value = 100000,
tax_depreciation_base = 100000,
tax_depreciation_deduction = 25000,
tax_depreciation_closing_value = 75000,
tax_depreciation_calculation = '{
"version": 2,
"elected_deduction": 25000,
"maximum_deduction": 25000,
"book_conformity_confirmed": null
}'::jsonb
WHERE id = $1`,
[owner.fiscalPeriodId],
),
).rejects.toThrow()
})
it('rejects deduction above the base or a closing value that does not reconcile', async () => {
const owner = await seedCompany()
await expect(saveSnapshot(owner.fiscalPeriodId, {
basis: 10_000,
deduction: 11_000,
closing: 0,
})).rejects.toThrow()
await expect(saveSnapshot(owner.fiscalPeriodId, {
basis: 100_000,
deduction: 30_000,
closing: 60_000,
})).rejects.toThrow()
})
it('enforces predecessor method and opening value for direct writes', async () => {
const owner = await seedCompany()
const previousPeriodId = await insertFiscalPeriod({
userId: owner.userId,
companyId: owner.companyId,
name: '2025',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
})
await getPool().query(
'UPDATE public.fiscal_periods SET previous_period_id = $2 WHERE id = $1',
[owner.fiscalPeriodId, previousPeriodId],
)
await saveSnapshot(previousPeriodId)
await expect(saveSnapshot(owner.fiscalPeriodId, {
opening: 90_000,
basis: 100_000,
deduction: 30_000,
closing: 70_000,
})).rejects.toThrow(/opening value must equal/i)
await expect(saveSnapshot(owner.fiscalPeriodId, {
method: 'restvarde',
rule: null,
opening: SNAPSHOT.closing,
basis: 100_000,
deduction: 25_000,
closing: 75_000,
})).rejects.toThrow(/method must match/i)
})
it('blocks an earlier snapshot change after a successor snapshot is saved', async () => {
const owner = await seedCompany()
const previousPeriodId = await insertFiscalPeriod({
userId: owner.userId,
companyId: owner.companyId,
name: '2025',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
})
await getPool().query(
'UPDATE public.fiscal_periods SET previous_period_id = $2 WHERE id = $1',
[owner.fiscalPeriodId, previousPeriodId],
)
await saveSnapshot(previousPeriodId)
await saveSnapshot(owner.fiscalPeriodId, {
opening: SNAPSHOT.closing,
basis: SNAPSHOT.closing,
deduction: 20_000,
closing: 71_000,
})
await expect(saveSnapshot(previousPeriodId, {
deduction: 38_000,
closing: 92_000,
})).rejects.toThrow(/later fiscal period already has/i)
})
it('blocks snapshot changes after the fiscal period is locked', async () => {
const owner = await seedCompany()
await saveSnapshot(owner.fiscalPeriodId)
await getPool().query(
'UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1',
[owner.fiscalPeriodId],
)
await expect(
getPool().query(
`UPDATE public.fiscal_periods
SET tax_depreciation_deduction = 38000,
tax_depreciation_closing_value = 92000
WHERE id = $1`,
[owner.fiscalPeriodId],
),
).rejects.toThrow(/locked for tax depreciation elections/i)
})
it('rejects new per-asset tax depreciation methods', async () => {
const owner = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.assets
(user_id, company_id, name, category, acquisition_date,
acquisition_cost, salvage_value, useful_life_months,
depreciation_method, bas_asset_account,
bas_accumulated_account, bas_expense_account)
VALUES ($1, $2, 'Legacy tax method', 'equipment', CURRENT_DATE,
100000, 0, 60, 'declining_balance_30', '1220', '1229', '7832')`,
[owner.userId, owner.companyId],
),
).rejects.toThrow(/must be linear/i)
})
})