5ca64bde30
* 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>
193 lines
6.0 KiB
TypeScript
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)
|
|
})
|
|
|
|
})
|