* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
199 lines
5.5 KiB
TypeScript
199 lines
5.5 KiB
TypeScript
/**
|
|
* Pure helpers for adjustment of input VAT on investment goods under
|
|
* Mervardesskattelagen (2023:200), chapter 15.
|
|
*
|
|
* The acquisition year and disposal year both count in the adjustment
|
|
* period. The basis is total original input VAT, not only the amount that was
|
|
* deducted at acquisition.
|
|
*/
|
|
|
|
import type { AssetCategory, AssetJamkningDirection, VatTreatment } from '@/types'
|
|
|
|
export type JamkningDirection = AssetJamkningDirection
|
|
|
|
export interface JamkningAssessmentInput {
|
|
acquisitionDate: string
|
|
disposalDate: string
|
|
category?: AssetCategory
|
|
basAssetAccount?: string
|
|
originalInputVat: number
|
|
originalDeductionPercent: number
|
|
disposalType: 'sale' | 'scrap' | 'business_transfer'
|
|
vatTreatment?: VatTreatment
|
|
netProceeds?: number
|
|
}
|
|
|
|
export interface JamkningAssessment {
|
|
isInvestmentGood: boolean
|
|
threshold: number
|
|
totalYears: number
|
|
remainingYears: number
|
|
originalDeductionPercent: number
|
|
newDeductionPercent: number | null
|
|
direction: JamkningDirection
|
|
amount: number
|
|
capped: boolean
|
|
reason:
|
|
| 'below_threshold'
|
|
| 'outside_adjustment_period'
|
|
| 'scrap'
|
|
| 'transferred'
|
|
| 'change_below_five_points'
|
|
| 'adjustment'
|
|
}
|
|
|
|
/**
|
|
* Assess a disposal using the one-time adjustment formula in ML 15:13-18.
|
|
* A taxable sale of movable property is capped at 25 percent of net proceeds.
|
|
*/
|
|
export function assessJamkning(input: JamkningAssessmentInput): JamkningAssessment {
|
|
const realProperty = isRealProperty(input)
|
|
const totalYears = realProperty ? 10 : 5
|
|
const threshold = realProperty ? 100_000 : 50_000
|
|
const remainingYears = yearsRemaining(
|
|
input.acquisitionDate,
|
|
input.disposalDate,
|
|
totalYears,
|
|
)
|
|
const originalDeductionPercent = clampPercent(input.originalDeductionPercent)
|
|
const base = {
|
|
isInvestmentGood: input.originalInputVat >= threshold,
|
|
threshold,
|
|
totalYears,
|
|
remainingYears,
|
|
originalDeductionPercent,
|
|
}
|
|
|
|
if (!base.isInvestmentGood) {
|
|
return noAdjustment(base, 'below_threshold', originalDeductionPercent)
|
|
}
|
|
if (remainingYears === 0) {
|
|
return noAdjustment(base, 'outside_adjustment_period', originalDeductionPercent)
|
|
}
|
|
if (input.disposalType === 'scrap') {
|
|
return noAdjustment(base, 'scrap', originalDeductionPercent)
|
|
}
|
|
if (input.disposalType === 'business_transfer') {
|
|
return {
|
|
...base,
|
|
newDeductionPercent: null,
|
|
direction: 'transferred',
|
|
amount: 0,
|
|
capped: false,
|
|
reason: 'transferred',
|
|
}
|
|
}
|
|
|
|
const newDeductionPercent = treatmentRetainsDeduction(input.vatTreatment) ? 100 : 0
|
|
const change = newDeductionPercent - originalDeductionPercent
|
|
if (Math.abs(change) < 5) {
|
|
return noAdjustment(base, 'change_below_five_points', newDeductionPercent)
|
|
}
|
|
|
|
const rawAmount = round2(
|
|
input.originalInputVat * (Math.abs(change) / 100) * (remainingYears / totalYears),
|
|
)
|
|
let amount = rawAmount
|
|
let capped = false
|
|
|
|
if (!realProperty && change > 0 && treatmentRetainsDeduction(input.vatTreatment)) {
|
|
const cap = round2(Math.max(0, Number(input.netProceeds ?? 0)) * 0.25)
|
|
if (amount > cap) {
|
|
amount = cap
|
|
capped = true
|
|
}
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
newDeductionPercent,
|
|
direction: change > 0 ? 'increase' : 'decrease',
|
|
amount,
|
|
capped,
|
|
reason: 'adjustment',
|
|
}
|
|
}
|
|
|
|
export interface JamkningEligibility {
|
|
totalYears: number
|
|
elapsedYears: number
|
|
remainingYears: number
|
|
withinAdjustmentPeriod: boolean
|
|
threshold: number
|
|
}
|
|
|
|
export function assessJamkningEligibility(args: {
|
|
basAssetAccount?: string
|
|
category?: AssetCategory
|
|
acquisitionDate: string
|
|
disposalDate: string
|
|
}): JamkningEligibility {
|
|
const realProperty = isRealProperty(args)
|
|
const totalYears = realProperty ? 10 : 5
|
|
const acquisitionYear = isoYear(args.acquisitionDate)
|
|
const disposalYear = isoYear(args.disposalDate)
|
|
const elapsedYears = Math.max(0, disposalYear - acquisitionYear)
|
|
const remainingYears = yearsRemaining(args.acquisitionDate, args.disposalDate, totalYears)
|
|
|
|
return {
|
|
totalYears,
|
|
elapsedYears,
|
|
remainingYears,
|
|
withinAdjustmentPeriod: remainingYears > 0,
|
|
threshold: realProperty ? 100_000 : 50_000,
|
|
}
|
|
}
|
|
|
|
function noAdjustment(
|
|
base: Pick<
|
|
JamkningAssessment,
|
|
| 'isInvestmentGood'
|
|
| 'threshold'
|
|
| 'totalYears'
|
|
| 'remainingYears'
|
|
| 'originalDeductionPercent'
|
|
>,
|
|
reason: Exclude<JamkningAssessment['reason'], 'transferred' | 'adjustment'>,
|
|
newDeductionPercent: number,
|
|
): JamkningAssessment {
|
|
return {
|
|
...base,
|
|
newDeductionPercent,
|
|
direction: 'none',
|
|
amount: 0,
|
|
capped: false,
|
|
reason,
|
|
}
|
|
}
|
|
|
|
function treatmentRetainsDeduction(treatment: VatTreatment | undefined): boolean {
|
|
return treatment !== undefined && treatment !== 'exempt'
|
|
}
|
|
|
|
function isRealProperty(args: {
|
|
basAssetAccount?: string
|
|
category?: AssetCategory
|
|
}): boolean {
|
|
if (args.basAssetAccount && /^11\d{2}$/.test(args.basAssetAccount)) return true
|
|
return args.category === 'building' || args.category === 'land_improvement'
|
|
}
|
|
|
|
function yearsRemaining(acquisitionDate: string, disposalDate: string, totalYears: number): number {
|
|
const acquisitionYear = isoYear(acquisitionDate)
|
|
const disposalYear = isoYear(disposalDate)
|
|
return Math.max(0, Math.min(totalYears, totalYears - (disposalYear - acquisitionYear)))
|
|
}
|
|
|
|
function isoYear(value: string): number {
|
|
const match = /^(\d{4})-\d{2}-\d{2}$/.exec(value)
|
|
return match ? Number(match[1]) : 0
|
|
}
|
|
|
|
function clampPercent(value: number): number {
|
|
return Math.min(100, Math.max(0, value))
|
|
}
|
|
|
|
function round2(value: number): number {
|
|
return Math.round(value * 100) / 100
|
|
}
|