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
@@ -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,