* 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>
220 lines
7.4 KiB
TypeScript
220 lines
7.4 KiB
TypeScript
import type { AssetCategory } from '@/types'
|
|
import { roundOre } from '@/lib/money'
|
|
|
|
export type TaxDepreciationMethod = 'rakenskapsenlig' | 'restvarde'
|
|
export type TaxDepreciationRule = 'huvudregel_30' | 'kompletteringsregel_20'
|
|
|
|
export const TAX_DEPRECIATION_CATEGORIES: readonly AssetCategory[] = [
|
|
'machinery',
|
|
'equipment',
|
|
'vehicle',
|
|
'computer',
|
|
] as const
|
|
|
|
export interface TaxDepreciationCohort {
|
|
label: string
|
|
acquisitionCost: number
|
|
/**
|
|
* Sum of the fiscal-period lengths from the acquisition period through the
|
|
* current period. The 20 percent rule applies a full period's adjusted rate
|
|
* regardless of the acquisition date inside that period.
|
|
*/
|
|
elapsedMonths: number
|
|
}
|
|
|
|
export interface TaxDepreciationInput {
|
|
method: TaxDepreciationMethod
|
|
selectedRule?: TaxDepreciationRule
|
|
openingTaxValue: number
|
|
additions: number
|
|
disposals: number
|
|
periodMonths: number
|
|
cohorts: TaxDepreciationCohort[]
|
|
/** Actual deduction elected for the period. Omit when calculating the
|
|
* statutory maximum before the user has reconciled the election. */
|
|
electedDeduction?: number
|
|
}
|
|
|
|
export interface TaxDepreciationAlternative {
|
|
rule: TaxDepreciationRule | 'restvarde_25'
|
|
rate: number | null
|
|
deduction: number
|
|
closingTaxValue: number
|
|
}
|
|
|
|
export interface TaxDepreciationResult {
|
|
method: TaxDepreciationMethod
|
|
selectedRule: TaxDepreciationRule | null
|
|
openingTaxValue: number
|
|
additions: number
|
|
disposals: number
|
|
basis: number
|
|
periodMonths: number
|
|
maximumDeduction: number
|
|
deduction: number
|
|
closingTaxValue: number
|
|
excessDisposals: number
|
|
alternatives: TaxDepreciationAlternative[]
|
|
cohorts: Array<TaxDepreciationCohort & { remainingRate: number; closingValue: number }>
|
|
}
|
|
|
|
/**
|
|
* Calculate the annual tax value for the pooled machinery and inventory
|
|
* population under IL 18 kap. This is deliberately separate from ordinary
|
|
* per-asset depreciation: the 30, 20 and 25 percent rules are tax valuation
|
|
* rules for a pool, not book-depreciation methods on an individual asset.
|
|
*/
|
|
export function computeTaxDepreciation(input: TaxDepreciationInput): TaxDepreciationResult {
|
|
assertNonNegative('openingTaxValue', input.openingTaxValue)
|
|
assertNonNegative('additions', input.additions)
|
|
assertNonNegative('disposals', input.disposals)
|
|
if (!Number.isInteger(input.periodMonths) || input.periodMonths < 1 || input.periodMonths > 18) {
|
|
throw new Error('periodMonths must be an integer between 1 and 18')
|
|
}
|
|
|
|
const openingTaxValue = roundOre(input.openingTaxValue)
|
|
const additions = roundOre(input.additions)
|
|
const disposals = roundOre(input.disposals)
|
|
const grossBasis = roundOre(openingTaxValue + additions)
|
|
const basis = roundOre(Math.max(0, grossBasis - disposals))
|
|
const excessDisposals = roundOre(Math.max(0, disposals - grossBasis))
|
|
|
|
const cohorts = input.cohorts.map((cohort) => {
|
|
assertNonNegative('cohort acquisitionCost', cohort.acquisitionCost)
|
|
if (!Number.isInteger(cohort.elapsedMonths) || cohort.elapsedMonths < 1) {
|
|
throw new Error('cohort elapsedMonths must be a positive integer')
|
|
}
|
|
const remainingRate = clampRate(1 - 0.2 * (cohort.elapsedMonths / 12))
|
|
return {
|
|
...cohort,
|
|
acquisitionCost: roundOre(cohort.acquisitionCost),
|
|
remainingRate,
|
|
closingValue: roundOre(cohort.acquisitionCost * remainingRate),
|
|
}
|
|
})
|
|
|
|
if (input.method === 'restvarde') {
|
|
if (input.selectedRule !== undefined) {
|
|
throw new Error('selectedRule is only valid for rakenskapsenlig depreciation')
|
|
}
|
|
const rate = 0.25 * (input.periodMonths / 12)
|
|
const maximumDeduction = roundOre(Math.min(basis, basis * rate))
|
|
const deduction = resolveElectedDeduction(input.electedDeduction, maximumDeduction)
|
|
const closingTaxValue = roundOre(basis - deduction)
|
|
return {
|
|
method: input.method,
|
|
selectedRule: null,
|
|
openingTaxValue,
|
|
additions,
|
|
disposals,
|
|
basis,
|
|
periodMonths: input.periodMonths,
|
|
maximumDeduction,
|
|
deduction,
|
|
closingTaxValue,
|
|
excessDisposals,
|
|
alternatives: [{
|
|
rule: 'restvarde_25',
|
|
rate,
|
|
deduction: maximumDeduction,
|
|
closingTaxValue: roundOre(basis - maximumDeduction),
|
|
}],
|
|
cohorts,
|
|
}
|
|
}
|
|
|
|
if (!input.selectedRule) {
|
|
throw new Error('selectedRule is required for rakenskapsenlig depreciation')
|
|
}
|
|
|
|
// IL 18 kap. 17 §: the 20-rule's lowest permitted closing value is derived
|
|
// from acquisition-year cohorts. With a positive basis but no cohort at all,
|
|
// the reduce degenerates to a full write-off, which is not a computation the
|
|
// cohort evidence supports; refuse instead of silently deducting everything.
|
|
if (input.selectedRule === 'kompletteringsregel_20' && cohorts.length === 0 && basis > 0) {
|
|
throw new Error(
|
|
'kompletteringsregel_20 requires at least one acquisition cohort for a non-zero basis',
|
|
)
|
|
}
|
|
|
|
const mainRate = 0.3 * (input.periodMonths / 12)
|
|
const mainDeduction = roundOre(Math.min(basis, basis * mainRate))
|
|
const mainClosing = roundOre(basis - mainDeduction)
|
|
const complementaryMinimum = roundOre(
|
|
cohorts.reduce((sum, cohort) => sum + cohort.closingValue, 0),
|
|
)
|
|
const complementaryClosing = roundOre(Math.min(basis, complementaryMinimum))
|
|
const complementaryDeduction = roundOre(basis - complementaryClosing)
|
|
|
|
const alternatives: TaxDepreciationAlternative[] = [
|
|
{
|
|
rule: 'huvudregel_30',
|
|
rate: mainRate,
|
|
deduction: mainDeduction,
|
|
closingTaxValue: mainClosing,
|
|
},
|
|
{
|
|
rule: 'kompletteringsregel_20',
|
|
rate: null,
|
|
deduction: complementaryDeduction,
|
|
closingTaxValue: complementaryClosing,
|
|
},
|
|
]
|
|
const selected = alternatives.find((alternative) => alternative.rule === input.selectedRule)
|
|
if (!selected) throw new Error('Unsupported tax depreciation rule')
|
|
const deduction = resolveElectedDeduction(input.electedDeduction, selected.deduction)
|
|
|
|
return {
|
|
method: input.method,
|
|
selectedRule: input.selectedRule,
|
|
openingTaxValue,
|
|
additions,
|
|
disposals,
|
|
basis,
|
|
periodMonths: input.periodMonths,
|
|
maximumDeduction: selected.deduction,
|
|
deduction,
|
|
closingTaxValue: roundOre(basis - deduction),
|
|
excessDisposals,
|
|
alternatives,
|
|
cohorts,
|
|
}
|
|
}
|
|
|
|
export function fiscalPeriodMonths(periodStart: string, periodEnd: string): number {
|
|
const start = parseIsoDate(periodStart)
|
|
const end = parseIsoDate(periodEnd)
|
|
if (end < start) throw new Error('Fiscal period end must not precede its start')
|
|
return (
|
|
(end.getUTCFullYear() - start.getUTCFullYear()) * 12
|
|
+ end.getUTCMonth()
|
|
- start.getUTCMonth()
|
|
+ 1
|
|
)
|
|
}
|
|
|
|
function parseIsoDate(value: string): Date {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`Invalid ISO date: ${value}`)
|
|
const date = new Date(`${value}T00:00:00Z`)
|
|
if (Number.isNaN(date.getTime())) throw new Error(`Invalid ISO date: ${value}`)
|
|
return date
|
|
}
|
|
|
|
function assertNonNegative(label: string, value: number): void {
|
|
if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be non-negative`)
|
|
}
|
|
|
|
function clampRate(value: number): number {
|
|
return Math.max(0, Math.min(1, value))
|
|
}
|
|
|
|
function resolveElectedDeduction(value: number | undefined, maximumDeduction: number): number {
|
|
if (value === undefined) return maximumDeduction
|
|
assertNonNegative('electedDeduction', value)
|
|
const deduction = roundOre(value)
|
|
if (deduction > maximumDeduction) {
|
|
throw new Error('electedDeduction must not exceed the statutory maximum')
|
|
}
|
|
return deduction
|
|
}
|