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>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
createMockRequest,
|
|
createMockRouteParams,
|
|
createQueuedMockSupabase,
|
|
makeCompanySettings,
|
|
makeCustomer,
|
|
} from '@/tests/helpers'
|
|
import { contentDispositionFilename } from '@/lib/api/content-disposition'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
const requireAuthMock = vi.fn()
|
|
const renderToBufferMock = vi.fn()
|
|
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
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: (...args: unknown[]) => invoicePdfMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
|
|
prepareInvoicePdfRender: vi.fn(async (company: unknown) => ({ branding: {}, company })),
|
|
buildSwishQrDataUrl: vi.fn().mockResolvedValue(null),
|
|
buildPaymentLinkQrDataUrl: vi.fn().mockResolvedValue(null),
|
|
}))
|
|
|
|
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' }
|
|
const customer = makeCustomer({ id: 'customer-1', name: 'Kund ÅÄÖ AB' })
|
|
const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' })
|
|
const validBody = {
|
|
customer_id: customer.id,
|
|
invoice_number: '2621',
|
|
invoice_date: '2026-07-21',
|
|
due_date: '2026-08-20',
|
|
currency: 'SEK',
|
|
items: [{
|
|
description: 'Konsulttjänst',
|
|
quantity: 1,
|
|
unit: 'st',
|
|
unit_price: 14000,
|
|
vat_rate: 25,
|
|
}],
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
|
|
renderToBufferMock.mockResolvedValue(Buffer.from('pdf-bytes'))
|
|
})
|
|
|
|
it('returns 401 when the caller is not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: mockSupabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
|
|
createMockRouteParams({}),
|
|
)
|
|
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when invoice rows are missing', async () => {
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/preview-pdf', {
|
|
method: 'POST',
|
|
body: { ...validBody, items: [] },
|
|
}),
|
|
createMockRouteParams({}),
|
|
)
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
|
|
it('returns 404 when the customer does not exist', async () => {
|
|
enqueue({ data: company, error: null })
|
|
enqueue({ data: null, error: { message: 'not found' } })
|
|
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
|
|
createMockRouteParams({}),
|
|
)
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
|
|
it('returns a descriptive UTF-8 filename for the PDF preview', 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)
|
|
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
|
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
|
})
|
|
|
|
it('returns 400 when a foreign payment account is missing', async () => {
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/preview-pdf', {
|
|
method: 'POST',
|
|
body: { ...validBody, currency: 'EUR' },
|
|
}),
|
|
createMockRouteParams({}),
|
|
)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(renderToBufferMock).not.toHaveBeenCalled()
|
|
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 })
|
|
renderToBufferMock.mockRejectedValueOnce(new Error('render failed'))
|
|
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
|
|
createMockRouteParams({}),
|
|
)
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
})
|