Files
accounted/lib/invoices/__tests__/rot-rut-rules.test.ts
T
e030393fe6 fix(rot-rut): payment-side booking, reminders and claim completeness (#1652)
* fix(rot-rut): payment-side booking, reminders and claim completeness

Follow-ups from the 2026-08-17 ROT/RUT audit (dev_docs/rot_rut_audit_2026_08_17.md).

Payment side (fakturamodellen: the customer pays total minus avdraget, the
rest is a 1513 receivable on Skatteverket):
- createInvoicePaymentJournalEntry without an explicit paymentAmount used to
  book invoice.total on 1930/1510. Every no-lines mark-paid path (MCP
  mark_invoice_as_paid, v1 API, no-body dashboard route, Stripe) settles the
  outstanding amount, so on a ROT/RUT invoice 1510 went negative by the
  deduction and 1930 was overstated; same defect for any previously part-paid
  invoice. It now books the outstanding amount (remaining_amount, else total
  minus paid_amount); a fully outstanding invoice keeps the total_sek path.
- proposePaymentLines had no deduction awareness: the payment dialog
  pre-filled D1930 total / K1510 total, which the settlement plan rejected as
  an overpayment, so a ROT/RUT invoice could not be marked paid from the UI.
  Accrual: bank + 1510 carry total minus avdrag; cash method: bank gets the
  customer share, 1513 the avdrag, revenue + moms in full. Foreign invoices
  without a booking rate refuse (1513 is a kronor receivable). Dialog passes
  deduction_total.
- Reminders and dröjsmålsränta were computed on invoice.total: a privatperson
  was dunned for the Skatteverket share and charged interest on it. New
  reminderPrincipal() = the invoice's "Att betala" (öre-rounded total minus
  avdrag) drives the processor's interest base and all three templates.

Claim completeness (HUSFL 2009:194: art av arbete + antal arbetstimmar):
- work_type and labor_hours were optional at creation but hard blockers at
  begäran-file time, when the invoice is numbered, booked and paid and cannot
  be edited. validateDeductionLines() now requires a same-kind arbetstyp and
  hours > 0 (schablontjänster exempt) on every deduction line; wired into
  validateInvoice, CreateInvoiceItemSchema (field-level issues) and the
  editor schema with inline errors under the ROT/RUT strip. Fixed the
  labor_hours register (valueAsNumber overrode setValueAs: an emptied field
  became NaN and failed validation with no visible error). The Underlag card
  now shows whenever any row is flagged, matching the payload/server predicate.

Yearly ceilings:
- COMBINED_MAX 75 000 kr: ROT + RUT share one ceiling per person (ROT capped
  at 50 000 inside it). deductionCapWarnings() carries the per-kind and the
  combined check plus optional prior-year totals; validateInvoice forwards
  them; the editor uses the same helper and fetches what the customer has
  already been granted in the invoice year (per customer, warning only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): treat remaining_amount left at DEFAULT 0 as unmaintained when booking a payment

Rows written by paths that bypass buildInvoiceWriteData (imports, sandbox
seed, legacy migrations) carry remaining_amount = 0 while unpaid; prod has
~330 such open invoices. Booking 0 would have failed the engine's positive-
amount rule, so the outstanding helper derives total - paid - deduction when
the stored value is not positive. Test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): review follow-ups on #1652

- ROT/RUT completeness moves to the invoice-level schema (CreateInvoiceSchema /
  UpdateInvoiceSchema share one refine) so it only applies to real invoices
  and skips text rows; the editor gates its mirror on the document type via
  a ref. Tests moved accordingly (CodeRabbit).
- Prior-year deduction lookup follows the PAYMENT year (paid_at, else
  invoice_date for open invoices), paginates via fetchAllRows, and clears the
  total on a failed request instead of leaving a stale one.
- rot-rut-file derives its schablon flags from SCHABLON_WORK_TYPES so the
  validator and the generator cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): pick the prior-year deductions client-side (phantom-columns ceiling)

The runtime-built .or() filter counted as an unresolvable query expression
for the no-phantom-columns guard. A customer has few deduction invoices, so
fetch them all and select the payment year in code.

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>
2026-08-17 20:49:27 +02:00

539 lines
21 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.
import { describe, it, expect } from 'vitest'
import {
ROT_PERCENT,
RUT_PERCENT,
ROT_MAX,
RUT_MAX,
computeDeduction,
computeInvoiceDeductionTotal,
computeDeductionTotalsByKind,
validateInvoice,
deductionSekConverter,
deductionToSek,
deductionTypeForWorkType,
parseArticleHouseworkType,
normalizeHouseworkType,
workTypeLabel,
HOUSEWORK_TYPE_VALUES,
ROT_WORK_TYPES,
RUT_WORK_TYPES,
validateDeductionLines,
deductionCapWarnings,
COMBINED_MAX,
DEDUCTION_LINE_ERRORS,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
// Mirrors the warning-text formatter in rot-rut-rules.ts, so the expected
// strings stay correct regardless of which group separator the ICU build picks.
const sv = (n: number): string =>
n.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
describe('rot-rut-rules: constants', () => {
it('uses the 2026 statutory rates', () => {
expect(ROT_PERCENT).toBe(0.30)
expect(RUT_PERCENT).toBe(0.50)
expect(ROT_MAX).toBe(50000)
expect(RUT_MAX).toBe(75000)
})
})
describe('computeDeduction', () => {
it('standard ROT: 10 000 kr labor → 3 000 kr deduction', () => {
const item: ItemForDeduction = {
unit_price: 10000,
quantity: 1,
deduction_type: 'rot',
}
expect(computeDeduction(item)).toBe(3000)
})
it('standard RUT: 5 000 kr labor → 2 500 kr deduction', () => {
const item: ItemForDeduction = {
unit_price: 5000,
quantity: 1,
deduction_type: 'rut',
}
expect(computeDeduction(item)).toBe(2500)
})
it('no deduction_type → 0', () => {
const item: ItemForDeduction = {
unit_price: 10000,
quantity: 1,
}
expect(computeDeduction(item)).toBe(0)
})
it('null deduction_type → 0', () => {
const item: ItemForDeduction = {
unit_price: 10000,
quantity: 1,
deduction_type: null,
}
expect(computeDeduction(item)).toBe(0)
})
it('negative or zero amount → 0', () => {
expect(computeDeduction({ unit_price: 0, quantity: 1, deduction_type: 'rot' })).toBe(0)
expect(computeDeduction({ unit_price: -100, quantity: 1, deduction_type: 'rut' })).toBe(0)
})
it('quantity > 1 with ROT', () => {
const item: ItemForDeduction = {
unit_price: 500,
quantity: 20, // 10 000 total
deduction_type: 'rot',
}
expect(computeDeduction(item)).toBe(3000)
})
it('rounds to two decimals', () => {
const item: ItemForDeduction = {
unit_price: 333.33,
quantity: 1,
deduction_type: 'rut', // 333.33 * 0.5 = 166.665 → 166.67 (banker's rounding off)
}
expect(computeDeduction(item)).toBe(166.67)
})
it('caps at line total even if percent goes off (defensive)', () => {
// The percent is < 1.0 so this is hypothetical, but the cap is part
// of the contract: assert it via a synthetic case where unit_price ×
// quantity happens to be tiny but the rounding step could overshoot.
const item: ItemForDeduction = {
unit_price: 0.01,
quantity: 1,
deduction_type: 'rut',
}
// 0.01 * 0.5 = 0.005 → rounds to 0.01 = line_total. OK, capped.
expect(computeDeduction(item)).toBe(0.01)
})
})
describe('computeDeduction: base is labor cost INCLUDING VAT (HUSFL 6-9 §§)', () => {
it('Skatteverket worked example: 18 000 kr arbetskostnad @ 25% = 22 500 inkl. moms, ROT 30% = 6 750', () => {
const item: ItemForDeduction = {
unit_price: 18000,
quantity: 1,
deduction_type: 'rot',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(6750)
})
it('RUT 50% of labor incl. moms: 1 000 kr @ 25% = 1 250 → 625', () => {
const item: ItemForDeduction = {
unit_price: 1000,
quantity: 1,
deduction_type: 'rut',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(625)
})
it('respects the line rate: 1 000 kr @ 12% = 1 120 → ROT 336', () => {
const item: ItemForDeduction = {
unit_price: 1000,
quantity: 1,
deduction_type: 'rot',
vat_rate: 12,
}
expect(computeDeduction(item)).toBe(336)
})
it('vat_rate 0, null and undefined all mean momsfri labor (base = line total)', () => {
const base: ItemForDeduction = { unit_price: 10000, quantity: 1, deduction_type: 'rot' }
expect(computeDeduction({ ...base, vat_rate: 0 })).toBe(3000)
expect(computeDeduction({ ...base, vat_rate: null })).toBe(3000)
expect(computeDeduction(base)).toBe(3000)
})
it('reproduces the stored per-line vat_amount rounding before applying the percent', () => {
// 333.33 @ 25%: stored vat_amount = round2(83.3325) = 83.33, so the base
// is 416.66 (not 416.6625) and RUT 50% = 208.33.
const item: ItemForDeduction = {
unit_price: 333.33,
quantity: 1,
deduction_type: 'rut',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(208.33)
})
it('quantity > 1 with VAT: 20 × 500 kr @ 25% = 12 500 inkl. → ROT 3 750', () => {
const item: ItemForDeduction = {
unit_price: 500,
quantity: 20,
deduction_type: 'rot',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(3750)
})
})
describe('computeInvoiceDeductionTotal', () => {
it('sums on the inkl.-moms base when rates are present', () => {
const items: ItemForDeduction[] = [
{ unit_price: 18000, quantity: 1, deduction_type: 'rot', vat_rate: 25 }, // 6 750
{ unit_price: 1000, quantity: 1, deduction_type: 'rut', vat_rate: 25 }, // 625
{ unit_price: 2000, quantity: 1, vat_rate: 25 }, // not flagged
]
expect(computeInvoiceDeductionTotal(items)).toBe(7375)
})
it('mixed: ROT line + non-eligible line: only ROT generates deduction', () => {
const items: ItemForDeduction[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' },
{ unit_price: 2000, quantity: 1 }, // not flagged
]
expect(computeInvoiceDeductionTotal(items)).toBe(3000)
})
it('mixed ROT + RUT lines sum independently', () => {
const items: ItemForDeduction[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
{ unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
]
expect(computeInvoiceDeductionTotal(items)).toBe(5000)
})
it('all non-eligible → 0', () => {
const items: ItemForDeduction[] = [
{ unit_price: 1000, quantity: 1 },
{ unit_price: 2000, quantity: 1 },
]
expect(computeInvoiceDeductionTotal(items)).toBe(0)
})
})
describe('computeDeductionTotalsByKind', () => {
it('separates ROT and RUT', () => {
const items: ItemForDeduction[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
{ unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
{ unit_price: 2000, quantity: 1, deduction_type: 'rot' }, // 600
]
expect(computeDeductionTotalsByKind(items)).toEqual({ rot: 3600, rut: 2000 })
})
})
describe('deductionSekConverter / deductionToSek', () => {
it('SEK (or no context) is an identity conversion', () => {
expect(deductionToSek(3000)).toBe(3000)
expect(deductionToSek(3000, { currency: 'SEK' })).toBe(3000)
expect(deductionToSek(3000, { currency: null })).toBe(3000)
expect(deductionToSek(3000, { currency: 'sek', exchangeRate: 99 })).toBe(3000)
})
it('converts with the booking rate, öre-rounded like the 1513 debit', () => {
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: 11.4 })).toBe(7125)
expect(deductionToSek(2083.33, { currency: 'EUR', exchangeRate: 11.4 })).toBe(23749.96)
expect(deductionToSek(100, { currency: 'eur', exchangeRate: 11.4 })).toBe(1140)
})
it('returns null for a foreign currency with no usable rate', () => {
expect(deductionToSek(625, { currency: 'EUR' })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: null })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: 0 })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: -1 })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: Number.NaN })).toBeNull()
expect(deductionSekConverter({ currency: 'EUR' })).toBeNull()
})
})
describe('validateInvoice', () => {
it('errors when ROT/RUT but personnummer missing', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rut' },
]
const result = validateInvoice(items, false, true)
expect(result.errors).toContain('Personnummer krävs för ROT/RUT-avdrag.')
})
it('errors when ROT but housing_designation missing', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rot' },
]
const result = validateInvoice(items, true, false)
expect(result.errors).toContain('Fastighetsbeteckning krävs för ROT-avdrag.')
})
it('RUT without housing_designation → no error (RUT does not require it)', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rut', work_type: 'STAD', labor_hours: 4 },
]
const result = validateInvoice(items, true, false)
expect(result.errors).toHaveLength(0)
})
it('no deduction lines → no errors regardless of metadata', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1 },
]
expect(validateInvoice(items, false, false).errors).toHaveLength(0)
})
it('warns about ROT cap when invoice alone exceeds 50 000', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 100 }, // 60 000 deduction
]
const result = validateInvoice(items, true, true)
expect(result.errors).toHaveLength(0)
expect(result.warnings.length).toBeGreaterThan(0)
expect(result.warnings[0]).toMatch(/ROT/)
expect(result.warnings[0]).toMatch(/50/)
})
it('warns about RUT cap when invoice alone exceeds 75 000', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rut' }, // 100 000 deduction
]
const result = validateInvoice(items, true, true)
expect(result.warnings.length).toBeGreaterThan(0)
expect(result.warnings[0]).toMatch(/RUT/)
expect(result.warnings[0]).toMatch(/75/)
})
it('no warning when total under cap', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000: well under cap
]
const result = validateInvoice(items, true, true)
expect(result.warnings).toHaveLength(0)
})
it('SEK cap warning wording is unchanged', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rot' }, // 60 000 deduction
]
const expected =
`ROT-avdraget på denna faktura (${sv(60000)} kr) överstiger årsmaximum ${ROT_MAX.toLocaleString('sv-SE')} kr. ` +
'Kunden behöver kontrollera sitt återstående utrymme själv.'
expect(validateInvoice(items, true, true).warnings).toEqual([expected])
// Passing the currency explicitly must not change a character.
expect(validateInvoice(items, true, true, { currency: 'SEK' }).warnings).toEqual([expected])
})
})
describe('validateInvoice: foreign currency vs the kronor ceilings', () => {
it('warns when the SEK value breaches the cap even though the foreign figure does not', () => {
// 6 000 EUR avdrag looks tiny next to 50 000, but is 68 400 kr.
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 10 },
]
const result = validateInvoice(items, true, true, { currency: 'EUR', exchangeRate: 11.4 })
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain(`${sv(6000)} EUR = ${sv(68400)} kr`)
expect(result.warnings[0]).toContain('överstiger årsmaximum')
})
it('stays quiet when the SEK value is under the cap although the foreign figure is not', () => {
// 55 000 NOK avdrag is 49 500 kr: under the 50 000 kr ceiling.
const items: ValidateInvoiceItem[] = [
{ unit_price: 183333.33, quantity: 1, deduction_type: 'rot' },
]
const result = validateInvoice(items, true, true, { currency: 'NOK', exchangeRate: 0.9 })
expect(result.warnings).toHaveLength(0)
})
it('never labels a foreign amount "kr"', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 250000, quantity: 1, deduction_type: 'rut' }, // 125 000 EUR
]
const result = validateInvoice(items, true, true, { currency: 'EUR', exchangeRate: 11.4 })
expect(result.warnings[0]).toContain(`${sv(125000)} EUR`)
expect(result.warnings[0]).not.toContain(`(${sv(125000)} kr)`)
})
it('says the cap could not be checked when the invoice has no rate', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 10 },
]
const result = validateInvoice(items, true, true, { currency: 'EUR' })
expect(result.errors).toHaveLength(0)
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain(`${sv(6000)} EUR`)
expect(result.warnings[0]).toContain('kan inte stämmas av')
expect(result.warnings[0]).toContain('saknar växelkurs')
expect(result.warnings[0]).not.toContain('överstiger')
})
it('reports ROT and RUT separately on the same foreign invoice', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot' }, // 6 000 EUR = 68 400 kr
{ unit_price: 20000, quantity: 1, deduction_type: 'rut' }, // 10 000 EUR = 114 000 kr
]
const result = validateInvoice(items, true, true, { currency: 'EUR', exchangeRate: 11.4 })
expect(result.warnings).toHaveLength(2)
expect(result.warnings[0]).toContain('ROT-avdraget')
expect(result.warnings[1]).toContain('RUT-avdraget')
})
})
describe('deductionTypeForWorkType', () => {
it('maps ROT codes to rot and RUT codes to rut', () => {
expect(deductionTypeForWorkType('BYGG')).toBe('rot')
expect(deductionTypeForWorkType('VVS')).toBe('rot')
expect(deductionTypeForWorkType('STAD')).toBe('rut')
// IT-tjänster moved from the rot list to rut 2026-07: the mapping must
// follow the lists, never a hardcoded copy.
expect(deductionTypeForWorkType('IT')).toBe('rut')
expect(deductionTypeForWorkType('TVATT')).toBe('rut')
})
it('maps unknown or absent codes to null', () => {
expect(deductionTypeForWorkType(null)).toBeNull()
expect(deductionTypeForWorkType(undefined)).toBeNull()
expect(deductionTypeForWorkType('')).toBeNull()
expect(deductionTypeForWorkType('SNICKERI')).toBeNull()
})
})
describe('parseArticleHouseworkType (articles.housework_type vocabularies)', () => {
it('a Skatteverket code decides both kind and work type', () => {
expect(parseArticleHouseworkType('STAD')).toEqual({ deductionType: 'rut', workType: 'STAD' })
expect(parseArticleHouseworkType('BYGG')).toEqual({ deductionType: 'rot', workType: 'BYGG' })
// Case-insensitive: the API stores upper-case, but older writers did not.
expect(parseArticleHouseworkType(' malning ')).toEqual({ deductionType: 'rot', workType: 'MALNING' })
})
it('the bare kind (what the article form stored before it offered work types) decides the kind only', () => {
// This is the exact prod value behind the 2026-08-17 report: picking a
// "RUT" article pre-filled nothing because only codes were recognised.
expect(parseArticleHouseworkType('RUT')).toEqual({ deductionType: 'rut', workType: null })
expect(parseArticleHouseworkType('rot')).toEqual({ deductionType: 'rot', workType: null })
})
it('anything else is not a housework flag', () => {
// '0'/'1' are what a boolean "Rot" CSV column produced on prod.
for (const v of ['0', '1', 'Ja', 'SNICKERI', '', null, undefined]) {
expect(parseArticleHouseworkType(v)).toEqual({ deductionType: null, workType: null })
}
})
})
describe('normalizeHouseworkType / HOUSEWORK_TYPE_VALUES', () => {
it('canonicalises to the code, the bare kind, or null', () => {
expect(normalizeHouseworkType('stad')).toBe('STAD')
expect(normalizeHouseworkType('Rut')).toBe('RUT')
expect(normalizeHouseworkType('1')).toBeNull()
expect(normalizeHouseworkType('')).toBeNull()
expect(normalizeHouseworkType(null)).toBeNull()
})
it('accepts exactly the two kinds plus every code in both lists', () => {
expect(HOUSEWORK_TYPE_VALUES).toHaveLength(2 + ROT_WORK_TYPES.length + RUT_WORK_TYPES.length)
for (const v of HOUSEWORK_TYPE_VALUES) expect(normalizeHouseworkType(v)).toBe(v)
})
})
describe('workTypeLabel', () => {
it('returns the Skatteverket label for known codes and null otherwise', () => {
expect(workTypeLabel('STAD')).toBe('Städning')
expect(workTypeLabel('VVS')).toBe('VVS-arbete')
expect(workTypeLabel('RUT')).toBeNull()
expect(workTypeLabel(null)).toBeNull()
})
})
describe('validateDeductionLines (claim completeness at creation)', () => {
it('requires an arbetstyp and hours on every deduction line, once per message', () => {
const errors = validateDeductionLines([
{ unit_price: 500, quantity: 2, deduction_type: 'rut' },
{ unit_price: 500, quantity: 2, deduction_type: 'rot' },
])
expect(errors).toEqual([DEDUCTION_LINE_ERRORS.workTypeMissing, DEDUCTION_LINE_ERRORS.hoursMissing])
})
it('a work type from the other list is a mismatch, not a pass', () => {
const errors = validateDeductionLines([
{ unit_price: 500, quantity: 2, deduction_type: 'rut', work_type: 'BYGG', labor_hours: 2 },
])
expect(errors).toEqual([DEDUCTION_LINE_ERRORS.workTypeMismatch])
})
it('schablontjänster need no hours; everything else needs hours > 0', () => {
expect(validateDeductionLines([
{ unit_price: 500, quantity: 1, deduction_type: 'rut', work_type: 'TVATT' },
])).toEqual([])
expect(validateDeductionLines([
{ unit_price: 500, quantity: 1, deduction_type: 'rut', work_type: 'STAD', labor_hours: 0 },
])).toEqual([DEDUCTION_LINE_ERRORS.hoursMissing])
expect(validateDeductionLines([
{ unit_price: 500, quantity: 1, deduction_type: 'rut', work_type: 'STAD', labor_hours: 2.5 },
])).toEqual([])
})
it('non-deduction lines are ignored', () => {
expect(validateDeductionLines([{ unit_price: 500, quantity: 1 }])).toEqual([])
})
it('validateInvoice folds the line errors in', () => {
const result = validateInvoice(
[{ unit_price: 5000, quantity: 1, deduction_type: 'rut' }],
true,
false,
)
expect(result.errors).toContain(DEDUCTION_LINE_ERRORS.workTypeMissing)
expect(result.errors).toContain(DEDUCTION_LINE_ERRORS.hoursMissing)
})
})
describe('deductionCapWarnings: combined ceiling and prior-year accumulation', () => {
it('COMBINED_MAX is the shared 75 000 kr ceiling', () => {
expect(COMBINED_MAX).toBe(75000)
})
it('ROT 40 000 + RUT 40 000 on one invoice: neither per-kind cap trips, the combined one does', () => {
const warnings = deductionCapWarnings({ rot: 40000, rut: 40000 })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain(`gemensamma årsmaximum ${COMBINED_MAX.toLocaleString('sv-SE')} kr`)
expect(warnings[0]).toContain(`${sv(80000)} kr`)
})
it('a RUT breach alone does not add a redundant combined warning', () => {
const warnings = deductionCapWarnings({ rot: 1000, rut: 80000 })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toMatch(/^RUT-avdraget/)
})
it('ROT alone above 50 000 trips ROT only (combined not applicable to one kind)', () => {
const warnings = deductionCapWarnings({ rot: 60000, rut: 0 })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toMatch(/^ROT-avdraget/)
})
it('prior deductions this year push a modest invoice over the ceiling', () => {
// 3 x 20 000 kr ROT to the same person: the third invoice is the one that breaks 50 000.
expect(deductionCapWarnings({ rot: 20000, rut: 0 }, undefined, { rot: 40000, rut: 0 })).toEqual([
expect.stringContaining(`plus tidigare avdrag i år (${sv(40000)} kr)`),
])
expect(deductionCapWarnings({ rot: 20000, rut: 0 }, undefined, { rot: 20000, rut: 0 })).toEqual([])
// Prior RUT + this ROT: the combined ceiling binds across kinds.
expect(deductionCapWarnings({ rot: 20000, rut: 0 }, undefined, { rot: 0, rut: 60000 })).toEqual([
expect.stringContaining('gemensamma årsmaximum'),
])
})
it('foreign currency without a rate says so and never fabricates a combined check', () => {
const warnings = deductionCapWarnings({ rot: 4000, rut: 4000 }, { currency: 'EUR' })
expect(warnings).toHaveLength(2)
expect(warnings.every((w) => w.includes('saknar växelkurs'))).toBe(true)
})
it('validateInvoice forwards prior-year totals', () => {
const result = validateInvoice(
[{ unit_price: 20000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 10 }],
true,
true,
undefined,
{ rot: 48000, rut: 0 },
)
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain('plus tidigare avdrag')
})
})