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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
00ae3540db
commit
5ca64bde30
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
})
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user