fix(invoices): carry ROT/RUT deduction into the editor PDF preview (#1687)

The preview route built previewInvoice without any deduction fields and
its item mapping dropped deduction_type, so the editor's PDF preview of a
ROT/RUT invoice showed no avdrag row, no deduction info box, and "Att
betala" at the full undeduced total, unlike the invoice that is then
created and sent.

The preview now mirrors build-invoice-write.ts: per-line deduction_amount
via computeDeduction (base inkl. moms at the rendered rate, invoice
document type only), invoice-level deduction_total via
computeInvoiceDeductionTotal, and the per-line work_type / labor_hours /
housing fields the PDF's info box reads. The masked personnummer is
resolved like the write path (typed value, else an individual customer's
kundkort personnummer). The editor posts deduction_personnummer and
deduction_housing_designation to the preview only when a line claims a
deduction.

Closes #1686

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-18 16:32:32 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 3ea03c0fe1
commit ffa18019f4
4 changed files with 283 additions and 4 deletions
@@ -25,8 +25,9 @@ vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args),
}))
const invoicePdfMock = vi.fn().mockReturnValue('mock-pdf-element')
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
InvoicePDF: (...args: unknown[]) => invoicePdfMock(...args),
}))
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
@@ -36,6 +37,14 @@ vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
}))
import { POST } from '../route'
import type { Invoice, InvoiceItem } from '@/types'
/** The invoice + items the route handed to the PDF template on the last render. */
function lastRenderProps(): { invoice: Invoice; items: InvoiceItem[] } {
const call = invoicePdfMock.mock.calls.at(-1)
if (!call) throw new Error('InvoicePDF was not called')
return call[0] as { invoice: Invoice; items: InvoiceItem[] }
}
describe('POST /api/invoices/preview-pdf', () => {
const user = { id: 'user-1', email: 'owner@example.test' }
@@ -139,6 +148,168 @@ describe('POST /api/invoices/preview-pdf', () => {
expect(mockSupabase.from).not.toHaveBeenCalledWith('customers')
})
// ROT/RUT (issue #1686): the preview must state the same avdrag row, info
// box and "Att betala" as the invoice the write path creates. The PDF
// template reads invoice.deduction_total / deduction_personnummer_last4 and
// the per-item deduction fields, so those are what the route must carry.
describe('ROT/RUT deduction', () => {
const rutBody = {
...validBody,
document_type: 'invoice',
deduction_personnummer: '19900101-2385',
deduction_housing_designation: 'Stockholm Kvarteret 1:2',
items: [
{
description: 'Städning',
quantity: 4,
unit: 'tim',
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut',
labor_hours: 4,
work_type: 'STAD',
},
{
description: 'Rengöringsmedel',
quantity: 1,
unit: 'st',
unit_price: 200,
vat_rate: 25,
},
],
}
it('computes deduction_total from the posted items and carries the per-item fields', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: rutBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
const { invoice, items } = lastRenderProps()
// 4 x 500 = 2 000 exkl. moms = 2 500 inkl. 25% moms; RUT = 50% = 1 250.
expect(invoice.deduction_total).toBe(1250)
expect(invoice.total).toBe(2750)
expect(invoice.deduction_personnummer_last4).toBe('2385')
expect(items[0]).toMatchObject({
deduction_type: 'rut',
deduction_amount: 1250,
labor_hours: 4,
work_type: 'STAD',
housing_designation: 'Stockholm Kvarteret 1:2',
})
expect(items[1]).toMatchObject({ deduction_type: null, deduction_amount: 0, housing_designation: null })
})
it('uses the deduction base inkl. moms at the rate the line is rendered with', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
// Skatteverket worked example: 18 000 kr arbetskostnad = 22 500 kr inkl.
// moms, ROT 30% = 6 750 kr.
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: {
...rutBody,
items: [{
description: 'Målning',
quantity: 1,
unit: 'st',
unit_price: 18000,
vat_rate: 25,
deduction_type: 'rot',
labor_hours: 30,
work_type: 'MALNING',
}],
},
}),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
const { invoice, items } = lastRenderProps()
expect(invoice.deduction_total).toBe(6750)
expect(items[0].deduction_amount).toBe(6750)
})
it('falls back to the kundkort personnummer of an individual customer, like the write path', async () => {
enqueue({ data: company, error: null })
enqueue({
data: makeCustomer({ id: customer.id, customer_type: 'individual', personal_number: '900101-2385' }),
error: null,
})
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...rutBody, deduction_personnummer: '' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
expect(lastRenderProps().invoice.deduction_personnummer_last4).toBe('2385')
})
it('leaves a non-deduction invoice unchanged', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
const { invoice, items } = lastRenderProps()
expect(invoice.deduction_total).toBe(0)
expect(invoice.deduction_personnummer_last4).toBeNull()
expect(invoice.total).toBe(17500)
expect(items[0]).toMatchObject({ deduction_type: null, deduction_amount: 0 })
})
it('ignores deduction fields on non-invoice document types, like the write path', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...rutBody, document_type: 'proforma' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
const { invoice, items } = lastRenderProps()
expect(invoice.deduction_total).toBe(0)
expect(invoice.deduction_personnummer_last4).toBeNull()
expect(items[0]).toMatchObject({ deduction_type: null, deduction_amount: 0, work_type: null })
})
it('does not compute a deduction for a seller that is not VAT registered on VAT-free labor', async () => {
enqueue({ data: { ...company, vat_registered: false }, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: rutBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
const { invoice, items } = lastRenderProps()
// Base is the line total inkl. moms; with no output VAT the base is the
// bare 2 000 kr, RUT 50% = 1 000.
expect(items[0].vat_rate).toBe(0)
expect(items[0].deduction_amount).toBe(1000)
expect(invoice.deduction_total).toBe(1000)
})
})
it('marks preview generation errors as private and non-cacheable', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
+100 -3
View File
@@ -8,6 +8,9 @@ import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { computeDeduction, computeInvoiceDeductionTotal, type DeductionType } from '@/lib/invoices/rot-rut-rules'
import { expandPersonnummerTo12, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
@@ -20,6 +23,48 @@ function privateNoStore(response: NextResponse): NextResponse {
return response
}
/** The per-line ROT/RUT fields the editor posts alongside the amounts. */
interface PreviewItemInput {
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
deduction_type?: DeductionType | null
labor_hours?: number | null
work_type?: string | null
housing_designation?: string | null
apartment_number?: string | null
brf_org_number?: string | null
}
function optionalTrimmed(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
/**
* The masked personnummer the deduction info box shows, resolved the same way
* the write path does (lib/invoices/build-invoice-write.ts): the value typed
* on the claim card wins; otherwise an individual customer's kundkort
* personnummer, if it expands to a valid 12-digit number. Only the last four
* digits leave this function; the plaintext is never stored or logged.
*/
function resolvePreviewPersonnummerLast4(typed: string | null, customer: Customer): string | null {
if (typed) {
const last4 = extractLast4(typed)
return last4.length === 4 ? last4 : null
}
if (customer.customer_type !== 'individual') return null
try {
const revealed = revealStoredCustomerPersonalNumber(customer.personal_number)
const expanded = revealed ? expandPersonnummerTo12(revealed) : null
if (expanded && validatePersonnummer(expanded).valid) return extractLast4(expanded)
} catch {
// Undecryptable customer value: same as absent.
}
return null
}
/**
* POST /api/invoices/preview-pdf
*
@@ -34,7 +79,11 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
requestId,
}) => {
const body = await request.json()
const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number, payment_link_url } = body
const {
customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes,
document_type, invoice_number, payment_link_url,
deduction_personnummer, deduction_housing_designation, deduction_apartment_number, deduction_brf_org_number,
} = body
// Preview-only https gate, mirroring CreateInvoiceSchema: the value is
// rendered as a clickable link + QR in the preview PDF.
@@ -163,10 +212,31 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
const notVatRegistered = (company as { vat_registered?: boolean }).vat_registered === false
const zeroVat = notVatRegistered && !isDeliveryNote
// Build items with line totals and per-item VAT
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => {
// ROT/RUT (fakturamodellen) only exists on real invoices: the write path
// zeroes deduction fields on proformas, delivery notes and quotes, so the
// preview must too or it would show an avdrag the created document lacks.
const deductionsApply = docType === 'invoice'
const claimHousing = optionalTrimmed(deduction_housing_designation)
const claimApartment = optionalTrimmed(deduction_apartment_number)
const claimBrf = optionalTrimmed(deduction_brf_org_number)
// Build items with line totals, per-item VAT and the per-line ROT/RUT
// deduction, mirroring build-invoice-write.ts so the preview states the
// same avdrag row, info box and "Att betala" as the invoice it becomes.
const invoiceItems: InvoiceItem[] = items.map((item: PreviewItemInput, index: number) => {
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
const rate = zeroVat ? 0 : (item.vat_rate ?? vatRules.rate)
const deductionType = deductionsApply ? (item.deduction_type ?? null) : null
// Same base as the write path: the line total inkl. moms at the rate the
// line is rendered with (HUSFL 6-9 §§).
const deductionAmount = deductionType
? computeDeduction({
unit_price: item.unit_price,
quantity: item.quantity,
deduction_type: deductionType,
vat_rate: rate,
})
: 0
return {
id: `preview-${index}`,
invoice_id: 'preview',
@@ -178,6 +248,15 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
line_total: lineTotal,
vat_rate: rate,
vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100,
deduction_type: deductionType,
deduction_amount: deductionAmount,
labor_hours: deductionType ? (item.labor_hours ?? null) : null,
work_type: deductionType ? (item.work_type ?? null) : null,
// Property info: per-line value wins, else the invoice-level claim-card
// value is stamped onto every deduction line (same as the write path).
housing_designation: deductionType ? (optionalTrimmed(item.housing_designation) ?? claimHousing) : null,
apartment_number: deductionType ? (optionalTrimmed(item.apartment_number) ?? claimApartment) : null,
brf_org_number: deductionType ? (optionalTrimmed(item.brf_org_number) ?? claimBrf) : null,
created_at: new Date().toISOString(),
}
})
@@ -186,6 +265,22 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
const vatAmount = isDeliveryNote ? 0 : invoiceItems.reduce((sum, item) => sum + item.vat_amount, 0)
const total = isDeliveryNote ? 0 : subtotal + vatAmount
// Invoice-level deduction: the sum of the per-line amounts, computed with
// the same helper the write path stores on invoices.deduction_total.
const deductionTotal = deductionsApply
? computeInvoiceDeductionTotal(
invoiceItems.map((item) => ({
unit_price: item.unit_price,
quantity: item.quantity,
deduction_type: item.deduction_type ?? null,
vat_rate: item.vat_rate,
})),
)
: 0
const deductionPersonnummerLast4 = deductionTotal > 0
? resolvePreviewPersonnummerLast4(optionalTrimmed(deduction_personnummer), customer)
: null
// Derive vat_rate from items: single rate → that rate, mixed → null
const itemRates = new Set(invoiceItems.map((item) => item.vat_rate))
const effectiveVatRate = isDeliveryNote ? 0 : (itemRates.size === 1 ? itemRates.values().next().value! : null)
@@ -224,6 +319,8 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
converted_from_id: null,
paid_at: null,
paid_amount: null,
deduction_total: deductionTotal,
deduction_personnummer_last4: deductionPersonnummerLast4,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Invoice