Files
accounted/lib/invoices/rot-rut-rules.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

198 lines
7.5 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.
/**
* ROT/RUT-avdrag rules.
*
* Implements the calculation and validation logic for Sweden's tax deduction
* for household services (RUT) and home renovation (ROT). As of 2026:
* - ROT: 30% of labor cost, max 50 000 kr per person per year.
* - RUT: 50% of labor cost, max 75 000 kr per person per year.
*
* The deduction applies to labor only: material costs and travel time are
* NOT eligible. In this v1 we treat the entire invoice item amount as labor
* when the user flags it ROT/RUT; the user is expected to either invoice
* labor on its own row or split materials onto a non-flagged row. A future
* iteration can add per-line "labor portion" handling if needed.
*
* We CAN'T verify that the customer has remaining yearly headroom (they may
* have claimed elsewhere). We surface a warning when the per-invoice total
* already exceeds the statutory max: the customer must then handle the
* excess outside of fakturamodellen.
*
* All functions are pure and deterministic. No I/O, no DB calls: easy to
* unit-test and easy to embed in the API validator and the live total
* preview in the invoice editor.
*/
/** Percentage of eligible amount deducted for ROT (renovation). 2026 rule. */
export const ROT_PERCENT = 0.30
/** Percentage of eligible amount deducted for RUT (household services). 2026 rule. */
export const RUT_PERCENT = 0.50
/** Maximum yearly ROT deduction per person, in kr. 2026 rule. */
export const ROT_MAX = 50000
/** Maximum yearly RUT deduction per person, in kr. 2026 rule. */
export const RUT_MAX = 75000
export type DeductionType = 'rot' | 'rut'
/** Skatteverket work codes used by Husavdragstjänsten. Maps a free-text */
/** "what the worker did" label to the official code. The code drives which */
/** element the begäran-om-utbetalning file (Begaran.xsd V6) reports the */
/** hours under: see WORK_TYPE_ELEMENTS in lib/invoices/rot-rut-file.ts. */
/** The lists mirror the XSD exactly: rot work types are the seven */
/** ArendeUtfortArbeteRotTYPE elements (IT-tjänster is a RUT service and was */
/** removed from the rot list 2026-07); rut covers all thirteen */
/** ArendeUtfortArbeteRutTYPE elements incl. the two schablontjänster. */
export const ROT_WORK_TYPES = [
{ code: 'BYGG', label: 'Byggnadsarbete' },
{ code: 'EL', label: 'Elarbete' },
{ code: 'GLAS_PLAT', label: 'Glas- och plåtarbete' },
{ code: 'MARK_DRAN', label: 'Mark- och dräneringsarbete' },
{ code: 'MURNING', label: 'Murnings- och putsarbete' },
{ code: 'MALNING', label: 'Mål- och tapetseringsarbete' },
{ code: 'VVS', label: 'VVS-arbete' },
] as const
export const RUT_WORK_TYPES = [
{ code: 'STAD', label: 'Städning' },
{ code: 'KLAD', label: 'Kläd- och textilvård' },
{ code: 'SNOSKOTTNING', label: 'Snöskottning' },
{ code: 'TRADGARD', label: 'Trädgårdsarbete' },
{ code: 'BARNPASS', label: 'Barnpassning' },
{ code: 'PERSONLIG_OMS', label: 'Personlig omsorg' },
{ code: 'FLYTT', label: 'Flyttjänster' },
{ code: 'IT', label: 'IT-tjänster i hemmet' },
{ code: 'REPARATION', label: 'Reparation av vitvaror' },
{ code: 'MOBLERING', label: 'Möblering' },
{ code: 'TILLSYN', label: 'Tillsyn av bostad' },
// Schablontjänster: reported as utförd/ej utförd in the Skatteverket file,
// never with hours or material.
{ code: 'TRANSPORT', label: 'Transport till försäljning (schablon)' },
{ code: 'TVATT', label: 'Tvätt vid tvättinrättning (schablon)' },
] as const
export interface ItemForDeduction {
/** Unit price (per `quantity`). Same field as invoice_items.unit_price. */
unit_price: number
/** Quantity. Same field as invoice_items.quantity. */
quantity: number
/** 'rot' | 'rut' | null. Drives whether the deduction kicks in at all. */
deduction_type?: DeductionType | null
/**
* Optional. Reserved for a future iteration where the eligible portion of
* the row is just the labor hours × hourly rate. v1 ignores this and
* deducts on the full line total; we still take the field so the API
* schema accepts it without rejecting future-shaped payloads.
*/
labor_hours?: number | null
}
/**
* Compute the deduction amount for a single invoice item. Returns 0 when
* the item has no deduction_type. The result is always >= 0 and <= line
* total (no over-deduction even if percentages are tweaked).
*/
export function computeDeduction(item: ItemForDeduction): number {
if (!item.deduction_type) return 0
const lineTotal = item.unit_price * item.quantity
if (lineTotal <= 0) return 0
const percent = item.deduction_type === 'rot' ? ROT_PERCENT : RUT_PERCENT
const raw = lineTotal * percent
// Cap at line total: defensive against future rule changes that would
// push percent past 1.0.
const capped = Math.min(raw, lineTotal)
return Math.round(capped * 100) / 100
}
/**
* Sum the per-item deduction over an invoice. Returns the total to store
* on invoices.deduction_total and to use as the 1513 debit amount.
*/
export function computeInvoiceDeductionTotal(items: ItemForDeduction[]): number {
let total = 0
for (const item of items) {
total += computeDeduction(item)
}
return Math.round(total * 100) / 100
}
/**
* Sum per deduction kind. Used to surface separate cap warnings.
*/
export function computeDeductionTotalsByKind(items: ItemForDeduction[]): {
rot: number
rut: number
} {
let rot = 0
let rut = 0
for (const item of items) {
const amount = computeDeduction(item)
if (item.deduction_type === 'rot') rot += amount
else if (item.deduction_type === 'rut') rut += amount
}
return {
rot: Math.round(rot * 100) / 100,
rut: Math.round(rut * 100) / 100,
}
}
export interface ValidateInvoiceItem extends ItemForDeduction {
housing_designation?: string | null
}
export interface ValidationResult {
errors: string[]
warnings: string[]
}
/**
* Validate ROT/RUT prerequisites against a draft invoice.
*
* Errors block invoice creation; warnings surface in the UI but don't
* block (we can't verify a customer's yearly headroom across providers,
* but we can surface a "this invoice alone exceeds the cap" warning).
*
* The function takes invoice-level metadata as separate arguments rather
* than reading them off the items array so callers can compose it from
* either a HTTP request body or the form state without restructuring.
*/
export function validateInvoice(
items: ValidateInvoiceItem[],
personnummerProvided: boolean,
housingDesignationProvided: boolean,
): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
const hasAnyDeduction = items.some((item) => item.deduction_type)
const hasAnyRot = items.some((item) => item.deduction_type === 'rot')
if (hasAnyDeduction && !personnummerProvided) {
errors.push('Personnummer krävs för ROT/RUT-avdrag.')
}
// ROT requires fastighetsbeteckning per Skatteverket's Husavdragstjänst.
// RUT does not (in 2026 the Skatteverket file accepts RUT without it).
if (hasAnyRot && !housingDesignationProvided) {
errors.push('Fastighetsbeteckning krävs för ROT-avdrag.')
}
const { rot, rut } = computeDeductionTotalsByKind(items)
if (rot > ROT_MAX) {
warnings.push(
`ROT-avdraget på denna faktura (${rot.toFixed(2)} kr) överstiger årsmaximum ${ROT_MAX.toLocaleString('sv-SE')} kr. ` +
'Kunden behöver kontrollera sitt återstående utrymme själv.',
)
}
if (rut > RUT_MAX) {
warnings.push(
`RUT-avdraget på denna faktura (${rut.toFixed(2)} kr) överstiger årsmaximum ${RUT_MAX.toLocaleString('sv-SE')} kr. ` +
'Kunden behöver kontrollera sitt återstående utrymme själv.',
)
}
return { errors, warnings }
}