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>
This commit is contained in:
Jakob Wennberg
2026-08-17 20:49:27 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 79240cb2ed
commit e030393fe6
19 changed files with 740 additions and 66 deletions
+27
View File
@@ -278,6 +278,33 @@ describe('Enum schemas', () => {
// Invoice schemas
// ============================================================
describe('CreateInvoiceSchema: ROT/RUT line completeness', () => {
const paths = (r: { success: boolean; error?: { issues: Array<{ path: PropertyKey[] }> } }) =>
r.success ? [] : r.error!.issues.map((i) => i.path.join('.'))
const rutLine = (extra: Record<string, unknown>) => validInvoiceItem({ deduction_type: 'rut', ...extra })
it('requires a same-kind arbetstyp and hours > 0 on invoice documents', () => {
expect(paths(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({})] })))).toEqual(
expect.arrayContaining(['items.0.work_type', 'items.0.labor_hours']),
)
expect(paths(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'BYGG', labor_hours: 2 })] })))).toEqual(['items.0.work_type'])
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'STAD', labor_hours: 2 })] })).success).toBe(true)
// Schablontjänst: no hours needed
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'TVATT' })] })).success).toBe(true)
})
it('does not apply to proformas / delivery notes (server nulls the fields) nor to text rows', () => {
expect(CreateInvoiceSchema.safeParse(validInvoice({ document_type: 'proforma', items: [rutLine({})] })).success).toBe(true)
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [validInvoiceItem({ line_type: 'text', description: '', deduction_type: 'rut' })] })).success).toBe(true)
})
it('applies to UpdateInvoiceSchema too', () => {
const { save_as_draft: _s, ...body } = validInvoice({ items: [rutLine({})] }) as Record<string, unknown>
expect(UpdateInvoiceSchema.safeParse(body).success).toBe(false)
expect(UpdateInvoiceSchema.safeParse({ ...body, items: [rutLine({ work_type: 'STAD', labor_hours: 1 })] }).success).toBe(true)
})
})
describe('CreateInvoiceSchema', () => {
it('accepts a valid invoice', () => {
const result = CreateInvoiceSchema.safeParse(validInvoice())
+41 -3
View File
@@ -13,7 +13,13 @@ import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { HOUSEWORK_TYPE_VALUES, normalizeHouseworkType } from '@/lib/invoices/rot-rut-rules'
import {
DEDUCTION_LINE_ERRORS,
HOUSEWORK_TYPE_VALUES,
SCHABLON_WORK_TYPES,
deductionTypeForWorkType,
normalizeHouseworkType,
} from '@/lib/invoices/rot-rut-rules'
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
import type { AuditAction } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
@@ -469,7 +475,35 @@ export const CreateInvoiceItemSchema = z
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
export const CreateInvoiceSchema = z.object({
/**
* ROT/RUT claim completeness (HUSFL: art av arbete + antal arbetstimmar) at
* the invoice level, where document_type is known: only real invoices book a
* deduction (buildInvoiceWriteData nulls the fields for proformas, delivery
* notes and quotes), and free-text rows carry no claim. Field-level paths so
* the editor can point at the row; validateInvoice re-runs the same rules for
* callers that bypass this schema.
*/
function refineRotRutLineCompleteness(
data: { document_type?: string; items: Array<{ line_type?: string; deduction_type?: 'rot' | 'rut' | null; work_type?: string | null; labor_hours?: number | null }> },
ctx: z.RefinementCtx,
): void {
if (data.document_type && data.document_type !== 'invoice') return
data.items.forEach((item, index) => {
if (!item.deduction_type || item.line_type === 'text') return
const workType = item.work_type?.trim() || null
if (!workType) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['items', index, 'work_type'], message: DEDUCTION_LINE_ERRORS.workTypeMissing })
} else if (deductionTypeForWorkType(workType) !== item.deduction_type) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['items', index, 'work_type'], message: DEDUCTION_LINE_ERRORS.workTypeMismatch })
}
const isSchablon = workType != null && SCHABLON_WORK_TYPES.includes(workType)
if (!isSchablon && !(typeof item.labor_hours === 'number' && item.labor_hours > 0)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['items', index, 'labor_hours'], message: DEDUCTION_LINE_ERRORS.hoursMissing })
}
})
}
const CreateInvoiceBaseSchema = z.object({
customer_id: uuid,
invoice_date: isoDate,
due_date: isoDate,
@@ -563,11 +597,15 @@ export const CreateInvoiceSchema = z.object({
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const CreateInvoiceSchema = CreateInvoiceBaseSchema.superRefine(refineRotRutLineCompleteness)
// Update (edit) an existing DRAFT invoice in place. Same shape as create minus
// `save_as_draft`: editing never (re)creates a draft or allocates a number, it
// only rewrites the draft's header + line items. The PATCH route guards that the
// target is still a draft (status='draft', no journal entry, not self-billed).
export const UpdateInvoiceSchema = CreateInvoiceSchema.omit({ save_as_draft: true })
export const UpdateInvoiceSchema = CreateInvoiceBaseSchema
.omit({ save_as_draft: true })
.superRefine(refineRotRutLineCompleteness)
export const CreateCreditNoteSchema = z.object({
credited_invoice_id: uuid,
@@ -1868,3 +1868,79 @@ describe('createInvoiceCashEntry: ROT/RUT-avdrag', () => {
expect(totalDebit).toBe(totalCredit)
})
})
describe('createInvoicePaymentJournalEntry: settles the outstanding amount, not the total', () => {
beforeEach(() => {
mockedCreateEntry.mockClear()
})
it('ROT/RUT invoice without paymentAmount clears 1510 by total minus the deduction (1513 keeps the rest)', async () => {
// 10 000 + 25 % = 12 500; ROT 30 % of labor incl. moms = 3 750 on 1513 at
// issue, so 1510 only ever carried 8 750. The no-lines mark-paid path used
// to book 12 500 here: 1510 went to -3 750 and 1930 was overstated.
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
deduction_total: 3750,
paid_amount: 0,
remaining_amount: 8750,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
const bank = input.lines.find((l) => l.account_number === '1930')!
const ar = input.lines.find((l) => l.account_number === '1510')!
expect(bank.debit_amount).toBe(8750)
expect(ar.credit_amount).toBe(8750)
expect(input.description).toMatch(/^Inbetalning kundfaktura/)
})
it('previously part-paid invoice without paymentAmount clears only the remainder', async () => {
const invoice = makeInvoice({
total: 1250,
paid_amount: 500,
remaining_amount: 750,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1930')!.debit_amount).toBe(750)
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(750)
})
it('remaining_amount left at the DEFAULT 0 (import / seed paths) is treated as unmaintained', async () => {
const invoice = makeInvoice({
total: 12500,
deduction_total: 3750,
paid_amount: null,
remaining_amount: 0,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(8750)
})
it('legacy row without remaining_amount derives it from total minus paid_amount', async () => {
const invoice = makeInvoice({ total: 1250, paid_amount: 250 } as Partial<Invoice>)
;(invoice as unknown as { remaining_amount?: number }).remaining_amount = undefined
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(1000)
})
it('fully outstanding invoice still books the full total (unchanged path)', async () => {
const invoice = makeInvoice({ total: 1250, paid_amount: 0, remaining_amount: 1250 } as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(1250)
})
})
@@ -491,3 +491,68 @@ describe('proposePaymentLines: foreign currency without an exchange rate', () =>
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('14375')
})
})
describe('proposePaymentLines: ROT/RUT-avdrag (fakturamodellen)', () => {
// 10 000 labor + 25 % = 12 500; ROT 30 % of labor incl. moms = 3 750. The
// customer pays 8 750; 3 750 is a receivable on Skatteverket (1513).
const rotInvoice = () => ({ ...makeInvoiceInput(), deduction_total: 3750 })
it('accrual: bank and 1510 legs carry the customer share (total minus avdrag)', () => {
const lines = proposePaymentLines({
invoice: rotInvoice(),
accountingMethod: 'accrual',
entityType: 'aktiebolag',
})
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '8750' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '8750' })
})
it('accrual + öresavrundning: bank leg is the rounded "Att betala", 3740 carries the öre', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput({ total: 12500.4 }), deduction_total: 3750 },
accountingMethod: 'accrual',
entityType: 'aktiebolag',
companyOreRounding: true,
})
// Att betala on the PDF: round(12 500,40) - 3 750 = 8 750; 1510 clears
// 8 750,40; the 0,40 lands on 3740 (same polarity as the bank match).
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('8750')
expect(lines.find((l) => l.account_number === '1510')?.credit_amount).toBe('8750.4')
expect(lines.find((l) => l.account_number === '3740')?.debit_amount).toBe('0.4')
})
it('cash method: bank gets the customer share, 1513 the avdrag, revenue + moms in full', () => {
const lines = proposePaymentLines({
invoice: rotInvoice(),
accountingMethod: 'cash',
entityType: 'aktiebolag',
})
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('8750')
expect(lines.find((l) => l.account_number === '1513')?.debit_amount).toBe('3750')
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('10000')
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2500')
const debits = lines.reduce((s, l) => s + (parseFloat(l.debit_amount) || 0), 0)
const credits = lines.reduce((s, l) => s + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round((debits - credits) * 100)).toBe(0)
})
it('foreign invoice with a deduction and no booking rate refuses (1513 is a kronor receivable)', () => {
expect(() =>
proposePaymentLines({
invoice: { ...makeInvoiceInput({ currency: 'EUR', total_sek: 137500, exchange_rate: null }), deduction_total: 375 },
accountingMethod: 'accrual',
entityType: 'aktiebolag',
}),
).toThrow()
})
it('no deduction: unchanged full-total proposal', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput(), deduction_total: 0 },
accountingMethod: 'accrual',
entityType: 'aktiebolag',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
})
})
+43 -6
View File
@@ -548,6 +548,33 @@ export async function createInvoiceJournalEntry(
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* What the customer still owes on an invoice, in invoice currency:
* remaining_amount when the row carries it, else total minus paid_amount.
* remaining_amount is written as total minus the ROT/RUT deduction at
* creation (build-invoice-write.ts) and decremented per payment, so it is the
* one figure that already knows about both partial payments and the 1513
* share. Never falls back to the bare total when paid_amount is present.
*/
function invoiceOutstandingAmount(invoice: Invoice): number {
const inv = invoice as Invoice & {
remaining_amount?: number | null
paid_amount?: number | null
deduction_total?: number | null
}
// A payment is being booked, so a stored 0 cannot mean "settled": rows
// written by paths that bypass buildInvoiceWriteData (imports, sandbox seed,
// legacy migrations) leave the NOT NULL DEFAULT 0 in place. Treat 0 as
// unmaintained and derive: total minus prior payments minus the ROT/RUT
// share that was never the customer's to pay.
if (typeof inv.remaining_amount === 'number' && Number.isFinite(inv.remaining_amount) && inv.remaining_amount > 0) {
return roundOre(inv.remaining_amount)
}
const paid = typeof inv.paid_amount === 'number' ? inv.paid_amount : 0
const deduction = typeof inv.deduction_total === 'number' ? inv.deduction_total : 0
return roundOre(invoice.total - paid - deduction)
}
/**
* Create journal entry when an invoice is marked as paid
*
@@ -589,14 +616,24 @@ export async function createInvoicePaymentJournalEntry(
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
// Otherwise use the full invoice total (backward compatible). Strict
// conversion on both: a rate-less foreign payment would otherwise clear
// 1510 with the raw foreign number relabelled as kronor (balanced against
// an equally wrong 1930 debit, so nothing downstream could catch it).
// Rows carrying total_sek or a usable rate convert exactly as before.
// Otherwise the payment settles what is still outstanding on the invoice:
// remaining_amount (total minus prior partial payments minus any ROT/RUT
// deduction, which sits on 1513 and is never the customer's to pay). Booking
// invoice.total here, as this path did before, credited 1510 for money that
// never arrived: 1510 went negative by the avdrag on every ROT/RUT invoice
// settled through mark-paid without lines, and 1930 was overstated by the
// same amount. Strict conversion on all three: a rate-less foreign payment
// would otherwise clear 1510 with the raw foreign number relabelled as
// kronor (balanced against an equally wrong 1930 debit, so nothing
// downstream could catch it). A fully outstanding invoice still converts via
// total_sek exactly as before, so legacy rows without a rate keep working.
const outstanding = invoiceOutstandingAmount(invoice)
const settlesFullTotal = Math.abs(outstanding - invoice.total) < 0.005
const bookedSekAmount = isPartial
? headerToSekOrThrow(paymentAmount, null, invoice.currency, invoice.exchange_rate)
: headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
: settlesFullTotal
? headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
: headerToSekOrThrow(outstanding, null, invoice.currency, invoice.exchange_rate)
const lines: CreateJournalEntryLineInput[] = []
+45 -7
View File
@@ -5,6 +5,7 @@
* No DB or Supabase dependency: all inputs are plain data.
*/
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
import { roundOre } from '@/lib/money'
import {
getRevenueAccount,
getOutputVatAccount,
@@ -30,6 +31,14 @@ export interface ProposePaymentLinesInput {
items?: InvoiceItem[]
/** Per-invoice öresavrundning override; null = inherit the company setting. */
ore_rounding?: boolean | null
/**
* ROT/RUT-avdrag (fakturamodellen), invoice currency. The customer pays
* total minus this; the rest is a receivable on Skatteverket (1513) that
* was debited at issue (accrual) or is debited at payment (cash method).
* The proposal must therefore never expect the deduction on the bank leg:
* doing so is what made every ROT/RUT invoice fail the overpayment guard.
*/
deduction_total?: number | null
/**
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line: the payment dialog always submits its (editable) lines, so the
@@ -97,9 +106,13 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
input.companyOreRounding === undefined ? undefined : { ore_rounding: input.companyOreRounding },
).roundingDelta
// 1513 is a kronor receivable, so the deduction converts with the invoice's
// booking rate or not at all: same refusal as generateRotRutLines.
const deductionSek = resolveDeductionSek(invoice)
const lines = accountingMethod === 'accrual'
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta)
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta, deductionSek)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta, deductionSek)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
@@ -110,6 +123,14 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
return lines
}
function resolveDeductionSek(invoice: ProposePaymentLinesInput['invoice']): number {
const deduction = invoice.deduction_total ?? 0
if (deduction <= 0) return 0
const sek = resolveSekAmountOrNull(deduction, null, invoice.currency, invoice.exchange_rate)
if (sek === null) throw new InvoiceFxRateMissingError(invoice.currency)
return roundOre(sek)
}
/**
* The 3740 (öres- och kronutjämning) residual line. Customer paid over the
* stored total (rounded up) → credit (vinst); under (rounded down) → debit
@@ -129,14 +150,17 @@ function proposeAccrualLines(
paymentAccount: string,
desc: string,
exchangeRateDifference?: number,
roundingDelta = 0
roundingDelta = 0,
deductionSek = 0
): FormLine[] {
const bookedSekAmount = resolveSekAmount(
// The customer's share only: 1510 was debited total minus the ROT/RUT
// deduction at issue (1513 took the rest), so that is what the payment clears.
const bookedSekAmount = Math.round((resolveSekAmount(
invoice.total,
invoice.total_sek,
invoice.currency,
invoice.exchange_rate
)
) - deductionSek) * 100) / 100
const lines: FormLine[] = []
if (exchangeRateDifference && exchangeRateDifference !== 0) {
@@ -198,7 +222,8 @@ function proposeCashLines(
paymentAccount: string,
desc: string,
entityType: EntityType,
roundingDelta = 0
roundingDelta = 0,
deductionSek = 0
): FormLine[] {
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
@@ -309,12 +334,25 @@ function proposeCashLines(
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
// Cash method: revenue + moms on the full amount, but the bank only ever
// receives the customer's share; the ROT/RUT deduction is debited to 1513
// (Skatteverket pays it later), mirroring createInvoiceCashEntry.
lines.push({
account_number: paymentAccount,
debit_amount: toFormAmount(debitAmount + roundingDelta),
debit_amount: toFormAmount(debitAmount - deductionSek + roundingDelta),
credit_amount: '',
line_description: desc,
})
if (deductionSek > 0) {
lines.push({
account_number: '1513',
debit_amount: toFormAmount(deductionSek),
credit_amount: '',
line_description: invoice.invoice_number
? `ROT/RUT-avdrag faktura ${invoice.invoice_number}`
: 'ROT/RUT-avdrag faktura',
})
}
lines.push(...creditLines)
@@ -6,6 +6,7 @@ import {
calculateReminderAmounts,
formatReminderTotalDue,
REMINDER_FEE_CURRENCY,
reminderPrincipal,
} from '../reminder-templates'
import { formatCurrency } from '@/lib/utils'
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
@@ -253,3 +254,43 @@ describe('reminder email templates: statutory fee currency (Lag 1981:739)', () =
expect(html).toContain(formatCurrency(1_010, 'EUR'))
})
})
describe('reminder email templates: ROT/RUT-avdrag (fakturamodellen)', () => {
// 12 500 total with a 3 750 ROT deduction: the customer was asked for
// 8 750; the 3 750 is a claim on Skatteverket and must never be dunned.
const rotInvoice = makeInvoice({
invoice_number: 'F2026012',
invoice_date: '2026-04-15',
due_date: '2026-05-01',
currency: 'SEK',
total: 12_500,
deduction_total: 3_750,
} as Parameters<typeof makeInvoice>[0])
const rotData = { ...baseData, invoice: rotInvoice, interestAmount: 0, interestRate: 0, interestFromDate: '2026-05-02', interestDays: 0, reminderFee: 0 }
it('reminderPrincipal is the customer share', () => {
expect(reminderPrincipal(rotInvoice, company)).toBe(8_750)
expect(reminderPrincipal(invoice, company)).toBe(10_000)
})
it('HTML, text and subject never quote the pre-deduction total', () => {
const html = generateReminderEmailHtml(rotData)
const text = generateReminderEmailText(rotData)
const subject = generateReminderEmailSubject({ ...rotData, reminderFee: 60 })
for (const out of [html, text]) {
expect(out).toContain(formatCurrency(8_750, 'SEK'))
expect(out).not.toContain(formatCurrency(12_500, 'SEK'))
}
expect(subject).not.toContain(formatCurrency(12_500, 'SEK'))
})
it('folds interest and fee onto the customer share', () => {
const amounts = calculateReminderAmounts({
invoiceTotal: reminderPrincipal(rotInvoice, company),
interestAmount: 12.5,
reminderFee: 60,
currency: 'SEK',
})
expect(amounts.totalDue).toBe(8_822.5)
})
})
+18 -6
View File
@@ -1,5 +1,17 @@
import type { Invoice, Customer, CompanySettings } from '@/types'
import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
import { getAmountToPay } from '@/lib/invoices/rounding'
/**
* What the customer was asked to pay on the original invoice: the öre-rounded
* "Att betala" minus any ROT/RUT-avdrag (fakturamodellen: the deduction is a
* claim on Skatteverket, never on the customer). Reminders, dröjsmålsränta and
* the amount-to-pay line must all start from this figure, not invoice.total,
* or a privatperson is dunned for the SKV share and charged interest on it.
*/
export function reminderPrincipal(invoice: Invoice, company: CompanySettings): number {
return getAmountToPay(invoice, company).toPay
}
export interface ReminderEmailData {
invoice: Invoice
@@ -137,7 +149,7 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
const hasFee = reminderFee > 0
const hasSurcharges = hasInterest || hasFee
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
invoiceTotal: reminderPrincipal(invoice, company),
interestAmount,
reminderFee,
currency: invoice.currency,
@@ -226,7 +238,7 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Ursprungligt belopp:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(invoice.total, invoice.currency)}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(reminderPrincipal(invoice, company), invoice.currency)}</td>
</tr>
${hasInterest ? `
<tr>
@@ -362,7 +374,7 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
const hasInterest = interestAmount > 0
const hasFee = reminderFee > 0
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
invoiceTotal: reminderPrincipal(invoice, company),
interestAmount,
reminderFee,
currency: invoice.currency,
@@ -391,7 +403,7 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
text += `Fakturanummer: ${invoice.invoice_number}\n`
text += `Fakturadatum: ${formatDate(invoice.invoice_date)}\n`
text += `Förfallodatum: ${formatDate(invoice.due_date)}\n`
text += `Ursprungligt belopp: ${formatCurrency(invoice.total, invoice.currency)}\n`
text += `Ursprungligt belopp: ${formatCurrency(reminderPrincipal(invoice, company), invoice.currency)}\n`
if (hasInterest) {
text += `Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar): ${formatCurrency(interestAmount, invoice.currency)}\n`
}
@@ -438,14 +450,14 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
* the email.
*/
export function generateReminderEmailSubject(data: ReminderEmailData): string {
const { invoice, reminderLevel, interestAmount, reminderFee } = data
const { invoice, company, reminderLevel, interestAmount, reminderFee } = data
const config = REMINDER_CONFIG[reminderLevel]
const hasSurcharges = interestAmount > 0 || reminderFee > 0
// A SEK fee on a foreign-currency invoice renders as "1 010,00 € + 60 kr":
// two amounts in two currencies, never one mixed scalar.
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
invoiceTotal: reminderPrincipal(invoice, company),
interestAmount,
reminderFee,
currency: invoice.currency,
@@ -301,6 +301,7 @@ describe('buildInvoiceWriteData stored ROT/RUT personnummer (edit path)', () =>
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
work_type: 'STAD',
labor_hours: 10,
}
@@ -371,6 +372,7 @@ describe('buildInvoiceWriteData kundkort personnummer fallback', () => {
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
work_type: 'STAD',
labor_hours: 10,
}
@@ -514,6 +516,7 @@ describe('buildInvoiceWriteData kundkort fallback customer-type gate', () => {
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
work_type: 'STAD',
labor_hours: 10,
}],
},
+107 -4
View File
@@ -17,6 +17,11 @@ import {
HOUSEWORK_TYPE_VALUES,
ROT_WORK_TYPES,
RUT_WORK_TYPES,
validateDeductionLines,
deductionCapWarnings,
COMBINED_MAX,
DEDUCTION_LINE_ERRORS,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
@@ -258,7 +263,7 @@ describe('validateInvoice', () => {
it('RUT without housing_designation → no error (RUT does not require it)', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rut' },
{ 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)
@@ -273,7 +278,7 @@ describe('validateInvoice', () => {
it('warns about ROT cap when invoice alone exceeds 50 000', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rot' }, // 60 000 deduction
{ 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)
@@ -317,7 +322,7 @@ 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' },
{ 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)
@@ -345,7 +350,7 @@ describe('validateInvoice: foreign currency vs the kronor ceilings', () => {
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' },
{ 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)
@@ -433,3 +438,101 @@ describe('workTypeLabel', () => {
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')
})
})
+1
View File
@@ -336,6 +336,7 @@ export async function buildInvoiceWriteData(params: {
// below are stored with.
vat_rate: item.vat_rate !== undefined ? item.vat_rate : vatRules.rate,
labor_hours: item.labor_hours ?? null,
work_type: item.work_type ?? null,
housing_designation: item.housing_designation ?? null,
}))
+5 -1
View File
@@ -4,6 +4,7 @@ import {
generateReminderEmailHtml,
generateReminderEmailText,
generateReminderEmailSubject,
reminderPrincipal,
getReminderDaysConfig,
type ReminderDaysConfig,
} from '@/lib/email/reminder-templates'
@@ -291,8 +292,11 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
// Compute statutory late-payment interest (Räntelagen §6) using the
// company override if set, else Riksbankens referensränta + 8 pp.
const asOfDate = new Date().toISOString().split('T')[0]
// Interest accrues on what the customer actually owes: the invoice's
// "Att betala" (öre-rounded total minus any ROT/RUT-avdrag), never on the
// Skatteverket share sitting on 1513.
const interest = calculateLatePaymentInterest({
overdueAmount: invoice.total,
overdueAmount: reminderPrincipal(invoice as Invoice, company as CompanySettings),
dueDate: invoice.due_date,
asOfDate,
overrideRate: company.reminder_interest_rate_override,
+7 -3
View File
@@ -1,6 +1,6 @@
import type { Invoice, InvoiceItem } from '@/types'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { deductionSekConverter, type DeductionType } from './rot-rut-rules'
import { deductionSekConverter, SCHABLON_WORK_TYPES, type DeductionType } from './rot-rut-rules'
/**
* Begäran om utbetalning: rot & rut (Skatteverkets husavdragstjänst).
@@ -44,6 +44,10 @@ const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/ht/komponent/b
* is also the emission order inside UtfortArbete. `schablon` services are
* reported as <Utfort>true</Utfort>: no hours, no material.
*/
// `schablon` is derived from SCHABLON_WORK_TYPES (rot-rut-rules.ts), the one
// place that decides which services report utförd/ej utförd without hours:
// the invoice validator and this generator must never disagree on it.
const schablon = (code: string): boolean => SCHABLON_WORK_TYPES.includes(code)
const WORK_TYPE_ELEMENTS: Record<DeductionType, ReadonlyArray<{
code: string
element: string
@@ -70,8 +74,8 @@ const WORK_TYPE_ELEMENTS: Record<DeductionType, ReadonlyArray<{
{ code: 'REPARATION', element: 'ReparationAvVitvaror' },
{ code: 'MOBLERING', element: 'Moblering' },
{ code: 'TILLSYN', element: 'TillsynAvBostad' },
{ code: 'TRANSPORT', element: 'TransportTillForsaljning', schablon: true },
{ code: 'TVATT', element: 'TvattVidTvattinrattning', schablon: true },
{ code: 'TRANSPORT', element: 'TransportTillForsaljning', schablon: schablon('TRANSPORT') },
{ code: 'TVATT', element: 'TvattVidTvattinrattning', schablon: schablon('TVATT') },
],
}
+120 -22
View File
@@ -1,3 +1,5 @@
import { roundOre } from '@/lib/money'
/**
* ROT/RUT-avdrag rules.
*
@@ -46,6 +48,13 @@ 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'
/**
@@ -280,6 +289,47 @@ export function computeDeductionTotalsByKind(items: ItemForDeduction[]): {
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 {
@@ -308,6 +358,7 @@ export function validateInvoice(
personnummerProvided: boolean,
housingDesignationProvided: boolean,
money?: DeductionCurrencyContext,
priorYear?: PriorYearDeductions | null,
): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
@@ -319,38 +370,65 @@ export function validateInvoice(
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.')
}
const { rot, rut } = computeDeductionTotalsByKind(items)
warnings.push(...deductionCapWarnings(computeDeductionTotalsByKind(items), money, priorYear))
// computeDeductionTotalsByKind works in invoice currency; the ceilings are
// kronor. Convert before comparing, and never label a foreign figure "kr".
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 pushCapWarning = (kind: 'ROT' | 'RUT', amount: number, max: number): void => {
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 = `${max.toLocaleString('sv-SE')} kr`
const maxText = maxTextOf(max)
const priorText = prior > 0 ? ` plus tidigare avdrag i år (${svAmount(prior)} 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) {
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(
@@ -359,17 +437,37 @@ export function validateInvoice(
)
return
}
const amountSek = toSek(amount)
if (amountSek <= max) 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 (${svAmount(amount)} ${currencyLabel} = ${svAmount(amountSek)} kr) ` +
`överstiger årsmaximum ${maxText}. ` + advice,
`${kind}-avdraget på denna faktura (${figure})${priorText} överstiger årsmaximum ${maxText}. ` + advice,
)
}
pushCapWarning('ROT', rot, ROT_MAX)
pushCapWarning('RUT', rut, RUT_MAX)
pushCapWarning('ROT', totals.rot, rotSek, priorRot, ROT_MAX)
pushCapWarning('RUT', totals.rut, rutSek, priorRut, RUT_MAX)
return { errors, warnings }
// 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
}