* fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim Two gaps reported by a user invoicing RUT work: - Picking an article with a housework_type (arbetstypskod) left the line's skattereduktion on 'Ingen': the editor never fetched the field. applyArticle now derives deduction_type from the code's Skatteverket list (disjoint ROT/ RUT lists, new deductionTypeForWorkType helper) and sets work_type, with the same overwrite semantics as description/price: an article without a code clears the deduction so a material article never keeps claiming one. 'Spara som artikel' round-trips the code back onto the created article. - The customer card's personnummer was never used for the ROT/RUT claim; the user had to retype it per invoice. The browser only ever sees ciphertext or a mask, so the fix is a server-side fallback in buildInvoiceWriteData: typed > stored draft > kundkort. The kundkort value is decrypted, expanded to 12 digits (new expandPersonnummerTo12, century inference incl. '+' and samordningsnummer), Luhn-validated, and encrypted into the invoice; invalid or unreadable values fall through to the existing 'Personnummer krävs' error. The editor drops the required-mark and hints that the number comes from the kundkort when one exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): gate the kundkort personnummer fallback on individual customers ROT/RUT is a privatperson deduction; customers.personal_number is individual-only in the Zod schemas but not in the DB, so a stray value on a business row must never be claimed on implicitly. Typed values unaffected. Raised by the compliance review bot on #1634. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
/**
|
||
* 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 INCLUDING VAT, max 50 000 kr per person per year.
|
||
* - RUT: 50% of labor cost INCLUDING VAT, max 75 000 kr per person per year.
|
||
*
|
||
* The base is arbetskostnaden inklusive moms per HUSFL (2009:194) 6-9 §§:
|
||
* Skatteverkets own worked example is 18 000 kr arbetskostnad = 22 500 kr
|
||
* inkl. moms (25%), ROT 30% = 6 750 kr. Callers must therefore pass the
|
||
* line's VAT rate; a missing/null rate is treated as 0% (momsfri labor),
|
||
* where inkl. and exkl. coincide.
|
||
*
|
||
* 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. 2026 rule.
|
||
*
|
||
* SEK. The statutory ceiling is a kronor amount, so it may only ever be
|
||
* compared against a SEK figure: an invoice-currency total must go through
|
||
* `deductionToSek()` first.
|
||
*/
|
||
export const ROT_MAX = 50000
|
||
|
||
/** Maximum yearly RUT deduction per person. SEK, same caveat as ROT_MAX. 2026 rule. */
|
||
export const RUT_MAX = 75000
|
||
|
||
export type DeductionType = 'rot' | 'rut'
|
||
|
||
/**
|
||
* The invoice's money context: what currency its amounts are denominated in
|
||
* and the booking rate that turns them into kronor.
|
||
*/
|
||
export interface DeductionCurrencyContext {
|
||
/** ISO 4217 code of the invoice. Missing/null is treated as SEK. */
|
||
currency?: string | null
|
||
/** SEK per unit of `currency`. Required as soon as `currency` isn't SEK. */
|
||
exchangeRate?: number | null
|
||
}
|
||
|
||
/**
|
||
* Build the invoice-currency → SEK converter for a deduction context, or
|
||
* null when the invoice is in a foreign currency and carries no usable
|
||
* booking rate.
|
||
*
|
||
* The conversion is the SAME one the ledger leg applies before it debits BAS
|
||
* 1513 (`generateRotRutLines` in lib/bookkeeping/invoice-entries.ts): per
|
||
* amount, `Math.round(amount * rate * 100) / 100`. Sharing it is what keeps
|
||
* the begäran om utbetalning and the 1513 receivable from disagreeing about
|
||
* what the Skatteverket claim is worth.
|
||
*
|
||
* A null return means "cannot be expressed in kronor". Callers must then
|
||
* refuse to compare or emit: substituting the raw foreign number for a kronor
|
||
* amount is how a 625 EUR deduction ends up being asked for as "625 kr"
|
||
* against a 7 125 kr receivable that can never clear.
|
||
*/
|
||
export function deductionSekConverter(
|
||
money?: DeductionCurrencyContext,
|
||
): ((amount: number) => number) | null {
|
||
const currency = (money?.currency ?? 'SEK').toUpperCase()
|
||
if (currency === 'SEK') return (amount) => amount
|
||
const rate = money?.exchangeRate
|
||
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null
|
||
return (amount) => Math.round(amount * rate * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* One-shot form of `deductionSekConverter`: null on the same "foreign
|
||
* currency, no usable booking rate" condition.
|
||
*/
|
||
export function deductionToSek(
|
||
amount: number,
|
||
money?: DeductionCurrencyContext,
|
||
): number | null {
|
||
const toSek = deductionSekConverter(money)
|
||
return toSek ? toSek(amount) : null
|
||
}
|
||
|
||
/** 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
|
||
|
||
/**
|
||
* Which deduction kind a Skatteverket work-type code belongs to. The two code
|
||
* lists are disjoint, so the code alone decides ROT vs RUT: this is what lets
|
||
* an article's housework_type pre-fill both the invoice line's work_type and
|
||
* its deduction_type. Unknown or absent codes map to null (no deduction).
|
||
*/
|
||
export function deductionTypeForWorkType(code: string | null | undefined): DeductionType | null {
|
||
if (!code) return null
|
||
if (ROT_WORK_TYPES.some((w) => w.code === code)) return 'rot'
|
||
if (RUT_WORK_TYPES.some((w) => w.code === code)) return 'rut'
|
||
return null
|
||
}
|
||
|
||
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
|
||
/**
|
||
* The line's VAT rate in percent (25, 12, 6, 0). The statutory deduction
|
||
* base is the labor cost INCLUDING VAT (HUSFL 6-9 §§), so every caller
|
||
* that knows the rate must pass it. null/undefined means 0% (momsfri
|
||
* labor), where inkl. and exkl. moms coincide.
|
||
*/
|
||
vat_rate?: number | 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 base is the line total INCLUDING VAT
|
||
* (HUSFL 6-9 §§: 30% av arbetskostnaden inklusive moms for ROT, 50% for
|
||
* RUT). The per-line VAT is reproduced with the exact rounding the write
|
||
* path stores on invoice_items.vat_amount (Math.round(lineTotal * rate /
|
||
* 100 * 100) / 100 in build-invoice-write.ts), so the deduction and the
|
||
* stored VAT can never disagree by an öre. The result is always >= 0 and
|
||
* <= line total incl. VAT (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 rate = item.vat_rate ?? 0
|
||
const lineVat = rate > 0 ? Math.round(lineTotal * rate / 100 * 100) / 100 : 0
|
||
const lineTotalInclVat = lineTotal + lineVat
|
||
const percent = item.deduction_type === 'rot' ? ROT_PERCENT : RUT_PERCENT
|
||
const raw = lineTotalInclVat * percent
|
||
// Cap at line total incl. VAT: defensive against future rule changes that
|
||
// would push percent past 1.0.
|
||
const capped = Math.min(raw, lineTotalInclVat)
|
||
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.
|
||
*
|
||
* `money` carries the invoice's currency (and, when known, its booking rate).
|
||
* ROT_MAX / RUT_MAX are kronor ceilings, so the comparison is only meaningful
|
||
* against a SEK figure. Omitting the argument means "SEK", which is what
|
||
* every pre-existing caller was implicitly asserting.
|
||
*/
|
||
export function validateInvoice(
|
||
items: ValidateInvoiceItem[],
|
||
personnummerProvided: boolean,
|
||
housingDesignationProvided: boolean,
|
||
money?: DeductionCurrencyContext,
|
||
): 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)
|
||
|
||
// computeDeductionTotalsByKind works in invoice currency; the ceilings are
|
||
// kronor. Convert before comparing, and never label a foreign figure "kr".
|
||
const currencyLabel = (money?.currency ?? 'SEK').toUpperCase()
|
||
const toSek = deductionSekConverter(money)
|
||
const advice = 'Kunden behöver kontrollera sitt återstående utrymme själv.'
|
||
|
||
// Warning-text amounts: sv-SE digits, always two decimals, same convention
|
||
// as maxText below.
|
||
const svAmount = (n: number): string =>
|
||
n.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
|
||
const pushCapWarning = (kind: 'ROT' | 'RUT', amount: number, max: number): void => {
|
||
if (amount <= 0) return
|
||
const maxText = `${max.toLocaleString('sv-SE')} kr`
|
||
|
||
if (currencyLabel === 'SEK') {
|
||
if (amount <= max) return
|
||
warnings.push(
|
||
`${kind}-avdraget på denna faktura (${svAmount(amount)} kr) överstiger årsmaximum ${maxText}. ` + advice,
|
||
)
|
||
return
|
||
}
|
||
|
||
if (!toSek) {
|
||
// No booking rate: we cannot know whether the ceiling is breached.
|
||
// Saying so beats both silence and a fabricated kronor comparison.
|
||
warnings.push(
|
||
`${kind}-avdraget på denna faktura (${svAmount(amount)} ${currencyLabel}) kan inte stämmas av mot ` +
|
||
`årsmaximum ${maxText}: fakturan saknar växelkurs. ` + advice,
|
||
)
|
||
return
|
||
}
|
||
|
||
const amountSek = toSek(amount)
|
||
if (amountSek <= max) return
|
||
warnings.push(
|
||
`${kind}-avdraget på denna faktura (${svAmount(amount)} ${currencyLabel} = ${svAmount(amountSek)} kr) ` +
|
||
`överstiger årsmaximum ${maxText}. ` + advice,
|
||
)
|
||
}
|
||
|
||
pushCapWarning('ROT', rot, ROT_MAX)
|
||
pushCapWarning('RUT', rut, RUT_MAX)
|
||
|
||
return { errors, warnings }
|
||
}
|