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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
3ea03c0fe1
commit
ffa18019f4
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user