* 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>
297 lines
9.4 KiB
TypeScript
297 lines
9.4 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
generateReminderEmailHtml,
|
|
generateReminderEmailText,
|
|
generateReminderEmailSubject,
|
|
calculateReminderAmounts,
|
|
formatReminderTotalDue,
|
|
REMINDER_FEE_CURRENCY,
|
|
reminderPrincipal,
|
|
} from '../reminder-templates'
|
|
import { formatCurrency } from '@/lib/utils'
|
|
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
|
|
|
|
const company = makeCompanySettings({ company_name: 'Acme AB' })
|
|
const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se' })
|
|
const invoice = makeInvoice({
|
|
invoice_number: 'F2026010',
|
|
invoice_date: '2026-04-15',
|
|
due_date: '2026-05-01',
|
|
currency: 'SEK',
|
|
total: 10_000,
|
|
})
|
|
|
|
const eurInvoice = makeInvoice({
|
|
invoice_number: 'F2026011',
|
|
invoice_date: '2026-04-15',
|
|
due_date: '2026-05-01',
|
|
currency: 'EUR',
|
|
total: 1_000,
|
|
})
|
|
|
|
const baseData = {
|
|
invoice,
|
|
customer,
|
|
company,
|
|
reminderLevel: 1 as const,
|
|
daysOverdue: 25,
|
|
actionUrl: 'https://example.com/invoice-action/abc',
|
|
}
|
|
|
|
const eurBaseData = { ...baseData, invoice: eurInvoice }
|
|
|
|
describe('reminder email templates: surcharges', () => {
|
|
it('renders dröjsmålsränta + påminnelseavgift in HTML when set', () => {
|
|
const html = generateReminderEmailHtml({
|
|
...baseData,
|
|
interestAmount: 86.3,
|
|
interestRate: 0.105,
|
|
interestFromDate: '2026-05-01',
|
|
interestDays: 30,
|
|
reminderFee: 60,
|
|
})
|
|
expect(html).toContain('Ursprungligt belopp:')
|
|
expect(html).toContain('Dröjsmålsränta')
|
|
expect(html).toContain('Påminnelseavgift:')
|
|
expect(html).toContain('Att betala:')
|
|
expect(html).toContain('10,5%') // rate display (sv-SE format)
|
|
expect(html).toContain('30 dagar')
|
|
})
|
|
|
|
it('omits surcharge rows when both are zero', () => {
|
|
const html = generateReminderEmailHtml({
|
|
...baseData,
|
|
interestAmount: 0,
|
|
interestRate: 0.105,
|
|
interestFromDate: '2026-05-01',
|
|
interestDays: 0,
|
|
reminderFee: 0,
|
|
})
|
|
expect(html).not.toContain('Dröjsmålsränta')
|
|
expect(html).not.toContain('Påminnelseavgift:')
|
|
expect(html).toContain('Att betala:')
|
|
})
|
|
|
|
it('renders surcharges in plain text', () => {
|
|
const text = generateReminderEmailText({
|
|
...baseData,
|
|
interestAmount: 86.3,
|
|
interestRate: 0.105,
|
|
interestFromDate: '2026-05-01',
|
|
interestDays: 30,
|
|
reminderFee: 60,
|
|
})
|
|
expect(text).toContain('Ursprungligt belopp')
|
|
expect(text).toContain('Dröjsmålsränta')
|
|
expect(text).toContain('Påminnelseavgift')
|
|
expect(text).toContain('Att betala')
|
|
})
|
|
|
|
it('subject includes surcharge note when surcharges apply', () => {
|
|
const subject = generateReminderEmailSubject({
|
|
...baseData,
|
|
interestAmount: 86.3,
|
|
interestRate: 0.105,
|
|
interestFromDate: '2026-05-01',
|
|
interestDays: 30,
|
|
reminderFee: 60,
|
|
})
|
|
expect(subject).toContain('F2026010')
|
|
expect(subject).toContain('inkl. dröjsmålsränta')
|
|
})
|
|
|
|
it('subject is unchanged when no surcharges apply', () => {
|
|
const subject = generateReminderEmailSubject({
|
|
...baseData,
|
|
interestAmount: 0,
|
|
interestRate: 0,
|
|
interestFromDate: '2026-05-01',
|
|
interestDays: 0,
|
|
reminderFee: 0,
|
|
})
|
|
expect(subject).not.toContain('inkl. dröjsmålsränta')
|
|
expect(subject).toContain('F2026010')
|
|
})
|
|
})
|
|
|
|
describe('calculateReminderAmounts', () => {
|
|
it('folds the fee into the total for a SEK invoice', () => {
|
|
expect(
|
|
calculateReminderAmounts({
|
|
invoiceTotal: 10_000,
|
|
interestAmount: 86.3,
|
|
reminderFee: 60,
|
|
currency: 'SEK',
|
|
}),
|
|
).toEqual({ currency: 'SEK', totalDue: 10_146.3, feeDueSeparately: 0 })
|
|
})
|
|
|
|
it('keeps the SEK fee out of a foreign-currency total', () => {
|
|
expect(
|
|
calculateReminderAmounts({
|
|
invoiceTotal: 1_000,
|
|
interestAmount: 10,
|
|
reminderFee: 60,
|
|
currency: 'EUR',
|
|
}),
|
|
).toEqual({ currency: 'EUR', totalDue: 1_010, feeDueSeparately: 60 })
|
|
})
|
|
|
|
it('treats a missing currency as SEK', () => {
|
|
expect(
|
|
calculateReminderAmounts({
|
|
invoiceTotal: 100,
|
|
interestAmount: 0,
|
|
reminderFee: 60,
|
|
currency: null,
|
|
}),
|
|
).toEqual({ currency: 'SEK', totalDue: 160, feeDueSeparately: 0 })
|
|
})
|
|
|
|
it('rounds with Math.round(x * 100) / 100, not toFixed', () => {
|
|
const amounts = calculateReminderAmounts({
|
|
invoiceTotal: 1_000.005,
|
|
interestAmount: 0.011,
|
|
reminderFee: 0,
|
|
currency: 'SEK',
|
|
})
|
|
expect(amounts.totalDue).toBe(1_000.02)
|
|
})
|
|
|
|
it('renders a split total as two amounts, never one mixed scalar', () => {
|
|
const amounts = calculateReminderAmounts({
|
|
invoiceTotal: 1_000,
|
|
interestAmount: 10,
|
|
reminderFee: 60,
|
|
currency: 'EUR',
|
|
})
|
|
expect(formatReminderTotalDue(amounts)).toBe(
|
|
`${formatCurrency(1_010, 'EUR')} + ${formatCurrency(60, 'SEK')}`,
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('reminder email templates: statutory fee currency (Lag 1981:739)', () => {
|
|
const eurSurcharges = {
|
|
interestAmount: 10,
|
|
interestRate: 0.105,
|
|
interestFromDate: '2026-05-01',
|
|
interestDays: 30,
|
|
reminderFee: 60,
|
|
}
|
|
|
|
it('the fee is a SEK statute, not the invoice currency', () => {
|
|
expect(REMINDER_FEE_CURRENCY).toBe('SEK')
|
|
})
|
|
|
|
it('never labels the 60 kr fee with the invoice currency in HTML', () => {
|
|
const html = generateReminderEmailHtml({ ...eurBaseData, ...eurSurcharges })
|
|
|
|
expect(
|
|
html,
|
|
'the påminnelseavgift is capped at 60 kr by Lag 1981:739; rendering it as 60 EUR demands roughly 690 kr',
|
|
).not.toContain(formatCurrency(60, 'EUR'))
|
|
expect(html).toContain(formatCurrency(60, 'SEK'))
|
|
})
|
|
|
|
it('never sums a SEK fee into a EUR total in HTML', () => {
|
|
const html = generateReminderEmailHtml({ ...eurBaseData, ...eurSurcharges })
|
|
|
|
// 1000 EUR invoice + 10 EUR interest = 1010 EUR, plus 60 kr alongside.
|
|
expect(html).toContain(formatCurrency(1_010, 'EUR'))
|
|
expect(html).not.toContain(formatCurrency(1_070, 'EUR'))
|
|
expect(html).toContain('Lag 1981:739')
|
|
})
|
|
|
|
it('never labels the fee with the invoice currency in plain text', () => {
|
|
const text = generateReminderEmailText({ ...eurBaseData, ...eurSurcharges })
|
|
|
|
expect(text).toContain(`Påminnelseavgift: ${formatCurrency(60, 'SEK')}`)
|
|
expect(text).not.toContain(formatCurrency(60, 'EUR'))
|
|
expect(text).toContain(
|
|
`Att betala: ${formatCurrency(1_010, 'EUR')} + ${formatCurrency(60, 'SEK')}`,
|
|
)
|
|
})
|
|
|
|
it('never quotes a mixed-currency amount in the subject', () => {
|
|
const subject = generateReminderEmailSubject({ ...eurBaseData, ...eurSurcharges })
|
|
|
|
expect(subject).not.toContain(formatCurrency(1_070, 'EUR'))
|
|
expect(subject).toContain(
|
|
`${formatCurrency(1_010, 'EUR')} + ${formatCurrency(60, 'SEK')}`,
|
|
)
|
|
})
|
|
|
|
it('leaves a SEK invoice untouched: one currency, one total, no split note', () => {
|
|
const data = {
|
|
...baseData,
|
|
interestAmount: 86.3,
|
|
interestRate: 0.105,
|
|
interestFromDate: '2026-05-01',
|
|
interestDays: 30,
|
|
reminderFee: 60,
|
|
}
|
|
const html = generateReminderEmailHtml(data)
|
|
const text = generateReminderEmailText(data)
|
|
const subject = generateReminderEmailSubject(data)
|
|
|
|
expect(html).toContain(formatCurrency(60, 'SEK'))
|
|
expect(html).toContain(formatCurrency(10_146.3, 'SEK'))
|
|
expect(html).not.toContain('Lag 1981:739')
|
|
expect(text).toContain(`Påminnelseavgift: ${formatCurrency(60, 'SEK')}`)
|
|
expect(text).toContain(`Att betala: ${formatCurrency(10_146.3, 'SEK')}`)
|
|
expect(text).not.toContain(' + ')
|
|
expect(subject).toContain(formatCurrency(10_146.3, 'SEK'))
|
|
})
|
|
|
|
it('does not append a SEK note when the fee is disabled on a EUR invoice', () => {
|
|
const html = generateReminderEmailHtml({
|
|
...eurBaseData,
|
|
...eurSurcharges,
|
|
reminderFee: 0,
|
|
})
|
|
expect(html).not.toContain('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)
|
|
})
|
|
})
|