Files
accounted/lib/bokslut/assets/jamkning.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

191 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Jämkning helpers: input-VAT correction on disposal of investeringsvara
* within the korrigeringstid (ML 8a kap 4-7 §§).
*
* When an asset that had input VAT deducted at acquisition is disposed of
* within the correction period, part of the original deducted input VAT
* must be paid back. The amount is the portion that corresponds to the
* remaining months of the correction period.
*
* Correction periods per ML 8a kap 6 §:
* - 60 months (5 years) for lös egendom / movable property
* - 120 months (10 years) for fastighet / markanläggning (real property)
*
* The two functions in this file are PURE (no I/O, no Supabase, no clock
* read) so they can be tested with simple input/output cases.
*
* Caller responsibility:
* - Decide whether a disposal event triggers jämkning. The most common
* trigger is a sale within korrigeringstid, but ML 8a kap also lists
* "ändrad användning" and "utträde ur skattskyldighet". The caller
* passes the boolean so this helper stays domain-agnostic.
* - Source `originalInputVat`. For new assets this comes from the
* supplier invoice that booked the acquisition; for legacy assets the
* user has to enter it manually.
*/
import type { AssetCategory } from '@/types'
/**
* Inputs to compute the jämkning amount on disposal.
*/
export interface JamkningInput {
/** Original input VAT deducted at acquisition (BAS 2641 debit). */
originalInputVat: number
/**
* Total correction period in months. 60 for movable property,
* 120 for fastighet / markanläggning. Caller decides which.
*/
totalCorrectionMonths: number
/**
* Months remaining in the correction period as of the disposal date.
* Caller computes this so the helper avoids any clock / calendar
* dependency.
*/
remainingMonths: number
/**
* Whether the disposal event triggers jämkning at all. Most disposals
* within the korrigeringstid trigger it, but the caller may opt out
* (e.g. the buyer continues to use the asset in a fully taxable
* verksamhet and assumes the jämkning obligation via avtal: ML 8a kap
* 12 §).
*/
disposalEvent: 'triggers_jamkning' | 'no_jamkning'
}
/**
* Compute the jämkning amount per ML 8a kap 7 §. Returns a positive number
* representing the amount to be paid back to the state (i.e. reverse the
* input-VAT deduction). When disposal happens AFTER the correction period
* (remainingMonths <= 0) the formula returns 0: caller can simply skip
* the line.
*
* Formula: (remaining / total) × originalInputVat
*
* Edge cases:
* - disposalEvent = 'no_jamkning' → 0
* - totalCorrectionMonths <= 0 → 0 (defensive: caller bug)
* - remainingMonths <= 0 → 0 (asset is past the correction period)
* - remainingMonths > totalCorrectionMonths → caps at originalInputVat
* (sold immediately, before any correction period has elapsed)
*/
export function computeJamkningAmount(input: JamkningInput): number {
if (input.disposalEvent === 'no_jamkning') return 0
if (input.totalCorrectionMonths <= 0) return 0
if (input.remainingMonths <= 0) return 0
const remaining = Math.min(input.remainingMonths, input.totalCorrectionMonths)
const raw = (remaining / input.totalCorrectionMonths) * input.originalInputVat
return Math.round(raw * 100) / 100
}
/**
* Suggested eligibility check for an asset disposal. Returns the
* totalCorrectionMonths the caller should pass to computeJamkningAmount,
* along with the remainingMonths derived from acquisitionDate and
* disposalDate.
*
* The threshold lives here (not in the asset row) because it's a property
* of the asset CATEGORY / BAS account class, not user-editable per-asset:
*
* - Fastighet (BAS 1100-1199) → 120 months
* - Markanläggning (BAS 1150-1159): also 120 months
* - All other movable property → 60 months
*
* Pure: takes only dates and the asset's BAS account, returns numbers.
* Caller decides whether to surface the suggestion in the UI.
*/
export interface JamkningEligibility {
/** Suggested total correction period (60 or 120 months). */
totalCorrectionMonths: number
/** Months elapsed between acquisitionDate and disposalDate (clamped at 0). */
elapsedMonths: number
/** Remaining months in the correction period (clamped at 0). */
remainingMonths: number
/**
* Whether the disposal falls WITHIN the correction period. Convenience
* boolean: equivalent to `remainingMonths > 0`. Caller uses this to
* show / hide the jämkning UI.
*/
withinCorrectionPeriod: boolean
}
export function assessJamkningEligibility(args: {
basExpenseAccount?: string
basAssetAccount?: string
category?: AssetCategory
acquisitionDate: string
disposalDate: string
}): JamkningEligibility {
const totalCorrectionMonths = isRealProperty(args) ? 120 : 60
const elapsed = monthsBetween(args.acquisitionDate, args.disposalDate)
const elapsedClamped = Math.max(0, elapsed)
const remaining = Math.max(0, totalCorrectionMonths - elapsedClamped)
return {
totalCorrectionMonths,
elapsedMonths: elapsedClamped,
remainingMonths: remaining,
withinCorrectionPeriod: remaining > 0,
}
}
/**
* Real property (fastighet / markanläggning) per BAS 1100-1199 lives on
* the 10-year (120 mån) correction period. Everything else uses 5 years.
*
* The plan's contract is that this resolves off the asset's BAS account
* range, with category as a secondary signal. Two reasons we prefer
* account-driven over category-driven:
* 1. The account is what BAS reports / SIE / INK2R actually read; the
* category is just a UI label.
* 2. Users who override the BAS account to something outside the
* category's default range get a consistent answer with what their
* reports show.
*/
function isRealProperty(args: {
basExpenseAccount?: string
basAssetAccount?: string
category?: AssetCategory
}): boolean {
// Prefer the asset (anskaffning) account when supplied: it's the most
// direct mapping to the BAS class.
const assetAccount = args.basAssetAccount
if (assetAccount && /^1[1][0-9]{2}$/.test(assetAccount)) return true
// Expense account check: 7820-7829 = byggnader/markanläggning.
const expense = args.basExpenseAccount
if (expense && /^782[0-9]$/.test(expense)) return true
// Category fallback for callers who only have the asset row's category
// (e.g. UI that hasn't loaded the full asset yet).
if (args.category === 'building' || args.category === 'land_improvement') {
return true
}
return false
}
/**
* Calendar months between two ISO date strings, rounded toward zero.
* Counts complete months only: partial months don't tick the clock.
*
* The Swedish tax authorities count months, not days, for jämkning
* (ML 8a kap 6 §). Example: acquired 2023-01-15, sold 2026-01-14 →
* 35 months elapsed (the 36th month hasn't completed yet).
*/
function monthsBetween(fromIso: string, toIso: string): number {
const from = parseIsoDate(fromIso)
const to = parseIsoDate(toIso)
if (!from || !to) return 0
let months = (to.year - from.year) * 12 + (to.month - from.month)
if (to.day < from.day) months -= 1
return months
}
function parseIsoDate(iso: string): { year: number; month: number; day: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
if (!m) return null
return {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
}
}