* feat(invoices): per-line percentage discount and separate fakturamarkning User request: rabatt i procent per artikelrad, and a marking field separate from Er referens. - invoice_items.discount_percent (0-100, default 0): line_total and vat_amount are stored NET of the discount. Shared exact-ore math in lib/invoices/line-amounts.ts (gross, discount, net) used by the web builder, staged-operation commit, editor preview, PDF, and Peppol. Undiscounted lines keep the legacy unrounded qty*price byte-identical. - ROT/RUT deduction computes on the discounted net line total. - invoices.invoice_marking: printed on the PDF next to the references and mapped to Peppol BT-10 BuyerReference (marking wins over your_reference; either satisfies the BT-10 requirement). - Peppol renders the discount as a BG-27 line AllowanceCharge (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount). - Editor: "Lagg till rabatt" in the row menu (same reveal pattern as ROT/RUT), Markning row next to Er referens, forval chip, review dialog shows discounts and marking. - Plumbed through v1 REST projections, MCP create/get/update invoice tools, pending-operations update path, and copy-invoice (discount copied; marking deliberately not, it is recipient-specific). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 * fix(invoices): carry discount_percent through every deduction, credit, convert and preview path Skeptic + CI findings on the discount/marking feature, one pass: - generateRotRutLines and propose-send-lines now pass discount_percent into computeDeduction: the send/credit/cash verifikat booked 1513 on the GROSS line while deduction_total, the PDF and the Skatteverket claim carried the net, stranding the difference on 1513 and pushing 1510 negative once the customer paid. Test pins 1513=3000/1510=7000 for a 20%-discounted 10 000 kr ROT line. - preview-pdf route accepts discount_percent (net totals + net-based deduction) and invoice_marking; the editor now sends the marking, so the preview equals the invoice it becomes. - Credit notes carry discount_percent (buildCreditNoteItem, v1 credit route select+insert, MCP credit executor) and invoice_marking, so the kreditfaktura face arithmetic multiplies out and shows the Rabatt column (ML 17 kap 24 §). - Proforma->invoice convert copies discount_percent + invoice_marking: the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH and lost the rebate on the next builder pass. - Editor hides the discount menu in self-billed mode (the self-billed wire shape has no discount; previewed net would book gross). - MCP staging and commitCreateInvoice reject a non-number discount_percent (a string coerced past the range check but was ignored by the totals math and still stored). - Regenerated skills/accounted-api (apiskill:check CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
482 lines
20 KiB
TypeScript
482 lines
20 KiB
TypeScript
import { roundOre } from '@/lib/money'
|
||
import { computeLineNet } from '@/lib/invoices/line-amounts'
|
||
|
||
/**
|
||
* 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
|
||
|
||
/**
|
||
* ROT and RUT share one yearly ceiling per person: 75 000 kr in total, with
|
||
* ROT capped at 50 000 kr inside it (the 2024 H2 separation was temporary).
|
||
* SEK, same caveat as ROT_MAX.
|
||
*/
|
||
export const COMBINED_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
|
||
}
|
||
|
||
/** Human label for a Skatteverket work-type code, or null for unknown codes. */
|
||
export function workTypeLabel(code: string | null | undefined): string | null {
|
||
if (!code) return null
|
||
const hit = [...ROT_WORK_TYPES, ...RUT_WORK_TYPES].find((w) => w.code === code)
|
||
return hit ? hit.label : null
|
||
}
|
||
|
||
/**
|
||
* The two vocabularies `articles.housework_type` has been written in:
|
||
* - a Skatteverket work-type code (`BYGG`, `STAD`, ...): the intended value,
|
||
* decides both the deduction kind and the line's arbetstyp;
|
||
* - the bare kind `ROT` / `RUT`: what the article form stored before it
|
||
* offered real work types (legacy rows), decides the kind only.
|
||
* Anything else (free text, `0`/`1` from a mis-mapped CSV column) is not a
|
||
* housework flag at all and normalizes to null.
|
||
*/
|
||
export interface ArticleHousework {
|
||
deductionType: DeductionType | null
|
||
/** Skatteverket work-type code, or null when only the kind is known. */
|
||
workType: string | null
|
||
}
|
||
|
||
export function parseArticleHouseworkType(value: string | null | undefined): ArticleHousework {
|
||
const raw = value?.trim().toUpperCase() ?? ''
|
||
if (!raw) return { deductionType: null, workType: null }
|
||
const kindFromCode = deductionTypeForWorkType(raw)
|
||
if (kindFromCode) return { deductionType: kindFromCode, workType: raw }
|
||
if (raw === 'ROT' || raw === 'RUT') return { deductionType: raw.toLowerCase() as DeductionType, workType: null }
|
||
return { deductionType: null, workType: null }
|
||
}
|
||
|
||
/**
|
||
* Canonical stored form of a housework_type input: the work-type code, the
|
||
* bare kind (`ROT`/`RUT`), or null. Case-insensitive; unknown values are
|
||
* null so the column never accumulates a third vocabulary again.
|
||
*/
|
||
export function normalizeHouseworkType(value: string | null | undefined): string | null {
|
||
const parsed = parseArticleHouseworkType(value)
|
||
if (parsed.workType) return parsed.workType
|
||
if (parsed.deductionType) return parsed.deductionType.toUpperCase()
|
||
return null
|
||
}
|
||
|
||
/** Accepted housework_type values: every work-type code plus the bare kinds. */
|
||
export const HOUSEWORK_TYPE_VALUES: readonly string[] = [
|
||
'ROT',
|
||
'RUT',
|
||
...ROT_WORK_TYPES.map((w) => w.code),
|
||
...RUT_WORK_TYPES.map((w) => w.code),
|
||
]
|
||
|
||
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
|
||
/**
|
||
* Percentage discount on the line (0-100), invoice_items.discount_percent.
|
||
* The deduction base is the amount the customer actually pays, so a
|
||
* discounted line deducts on the NET line total. Omitted/null = 0.
|
||
*/
|
||
discount_percent?: number | null
|
||
/** '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
|
||
// Net of any line discount: the deduction follows what the customer pays.
|
||
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
|
||
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
|
||
/** Skatteverket arbetstypskod (ROT_WORK_TYPES / RUT_WORK_TYPES). */
|
||
work_type?: string | null
|
||
}
|
||
|
||
/**
|
||
* Schablontjänster are reported to Skatteverket as utförd/ej utförd, never
|
||
* with hours, so they are the one case where labor_hours is not required.
|
||
*/
|
||
export const SCHABLON_WORK_TYPES: readonly string[] = ['TRANSPORT', 'TVATT']
|
||
|
||
export const DEDUCTION_LINE_ERRORS = {
|
||
workTypeMissing: 'Arbetstyp krävs på alla ROT/RUT-rader.',
|
||
workTypeMismatch: 'Arbetstypen på raden hör inte till vald skattereduktion (ROT/RUT).',
|
||
hoursMissing: 'Antal arbetstimmar krävs på ROT/RUT-rader (schablontjänster undantagna).',
|
||
} as const
|
||
|
||
/**
|
||
* Per-line claim completeness: what the begäran om utbetalning to Skatteverket
|
||
* needs from every deduction line (HUSFL 2009:194: art av arbete och antal
|
||
* arbetstimmar). Checked at invoice creation because that is the last moment
|
||
* the line is editable: once the invoice is numbered, booked and paid, a
|
||
* missing arbetstyp used to surface only as a file-generation blocker with
|
||
* no repair path short of a credit note. Returns each message at most once.
|
||
*/
|
||
export function validateDeductionLines(items: ValidateInvoiceItem[]): string[] {
|
||
const errors = new Set<string>()
|
||
for (const item of items) {
|
||
if (!item.deduction_type) continue
|
||
const workType = item.work_type?.trim() || null
|
||
if (!workType) {
|
||
errors.add(DEDUCTION_LINE_ERRORS.workTypeMissing)
|
||
} else if (deductionTypeForWorkType(workType) !== item.deduction_type) {
|
||
errors.add(DEDUCTION_LINE_ERRORS.workTypeMismatch)
|
||
}
|
||
const isSchablon = workType != null && SCHABLON_WORK_TYPES.includes(workType)
|
||
const hours = item.labor_hours
|
||
if (!isSchablon && !(typeof hours === 'number' && Number.isFinite(hours) && hours > 0)) {
|
||
errors.add(DEDUCTION_LINE_ERRORS.hoursMissing)
|
||
}
|
||
}
|
||
return [...errors]
|
||
}
|
||
|
||
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,
|
||
priorYear?: PriorYearDeductions | null,
|
||
): 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.')
|
||
}
|
||
|
||
// Arbetstyp + arbetstimmar per line: required by the Skatteverket claim,
|
||
// and only fixable while the invoice is still a draft.
|
||
errors.push(...validateDeductionLines(items))
|
||
|
||
// 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.')
|
||
}
|
||
|
||
warnings.push(...deductionCapWarnings(computeDeductionTotalsByKind(items), money, priorYear))
|
||
|
||
return { errors, warnings }
|
||
}
|
||
|
||
/** Deductions already claimed for the same person earlier in the year, in SEK. */
|
||
export interface PriorYearDeductions {
|
||
rot: number
|
||
rut: number
|
||
}
|
||
|
||
/**
|
||
* Yearly-ceiling warnings for one invoice's deductions (invoice currency),
|
||
* optionally on top of what the same person has already been granted this
|
||
* year (SEK). Three ceilings: ROT 50 000, RUT 75 000, and the shared 75 000
|
||
* (COMBINED_MAX) that ROT + RUT together must not exceed. Warnings, never
|
||
* errors: we cannot see claims made through other providers, so the customer
|
||
* still has to check their own remaining headroom.
|
||
*
|
||
* `totals` works in invoice currency; the ceilings are kronor. Convert before
|
||
* comparing, and never label a foreign figure "kr".
|
||
*/
|
||
export function deductionCapWarnings(
|
||
totals: { rot: number; rut: number },
|
||
money?: DeductionCurrencyContext,
|
||
priorYear?: PriorYearDeductions | null,
|
||
): string[] {
|
||
const warnings: string[] = []
|
||
const currencyLabel = (money?.currency ?? 'SEK').toUpperCase()
|
||
const toSek = deductionSekConverter(money)
|
||
const advice = 'Kunden behöver kontrollera sitt återstående utrymme själv.'
|
||
const priorRot = Math.max(0, priorYear?.rot ?? 0)
|
||
const priorRut = Math.max(0, priorYear?.rut ?? 0)
|
||
|
||
// 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 maxTextOf = (max: number): string => `${max.toLocaleString('sv-SE')} kr`
|
||
|
||
const rotSek = toSek ? toSek(totals.rot) : null
|
||
const rutSek = toSek ? toSek(totals.rut) : null
|
||
|
||
const pushCapWarning = (kind: 'ROT' | 'RUT', amount: number, amountSek: number | null, prior: number, max: number): void => {
|
||
if (amount <= 0) return
|
||
const maxText = maxTextOf(max)
|
||
const priorText = prior > 0 ? ` plus tidigare avdrag i år (${svAmount(prior)} kr)` : ''
|
||
|
||
if (amountSek === null) {
|
||
// 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
|
||
}
|
||
if (amountSek + prior <= max) return
|
||
const figure = currencyLabel === 'SEK'
|
||
? `${svAmount(amount)} kr`
|
||
: `${svAmount(amount)} ${currencyLabel} = ${svAmount(amountSek)} kr`
|
||
warnings.push(
|
||
`${kind}-avdraget på denna faktura (${figure})${priorText} överstiger årsmaximum ${maxText}. ` + advice,
|
||
)
|
||
}
|
||
|
||
pushCapWarning('ROT', totals.rot, rotSek, priorRot, ROT_MAX)
|
||
pushCapWarning('RUT', totals.rut, rutSek, priorRut, RUT_MAX)
|
||
|
||
// The shared ceiling: only worth its own line when neither kind already
|
||
// tripped its own (a RUT breach of 75 000 implies the combined breach), and
|
||
// only when both kinds are in play across the year, otherwise the per-kind
|
||
// ceiling is the binding one (ROT alone caps at 50 000 anyway).
|
||
if (rotSek !== null && rutSek !== null) {
|
||
const rotYear = rotSek + priorRot
|
||
const rutYear = rutSek + priorRut
|
||
const combined = rotYear + rutYear
|
||
const bothKinds = rotYear > 0 && rutYear > 0
|
||
if (bothKinds && combined > COMBINED_MAX && rotYear <= ROT_MAX && rutYear <= RUT_MAX) {
|
||
const thisInvoice = roundOre(rotSek + rutSek)
|
||
const priorSum = priorRot + priorRut
|
||
const priorText = priorSum > 0 ? ` plus tidigare avdrag i år (${svAmount(priorSum)} kr)` : ''
|
||
warnings.push(
|
||
`ROT- och RUT-avdragen på denna faktura (${svAmount(thisInvoice)} kr)${priorText} överstiger tillsammans ` +
|
||
`det gemensamma årsmaximum ${maxTextOf(COMBINED_MAX)}. ` + advice,
|
||
)
|
||
}
|
||
}
|
||
|
||
return warnings
|
||
}
|