* fix(invoices): fold the ROT/RUT card into Detaljer and mask personnummer as YYYYMMDD-XXXX Founder review of #1690 (2026-08-18), two decisions. Declutter (design B): the separate Skattereduktion card on the invoice detail page duplicated the totals block. It is gone; what it carried beyond the amounts now lives in Detaljer as plain rows, only for invoices with a claim: Personnummer (masked, or "Saknas"), Fastighet (ROT only: fastighetsbeteckning or BRF, with lagenhetsnummer inline), and Skattereduktion with the begaran lifecycle ("Ej begard" + inline "Skapa begaran" link when paid and unclaimed; otherwise the rot_rut_status_* label, date and decided amount), styled like the neighbouring Bokforing row. Totals block unchanged. Per-line subtext shortened to "<RUT|ROT> · <arbetstyp> · <n> tim" (desktop + mobile). Personnummer mask: invoice surfaces now show YYYYMMDD-XXXX (birth date visible, last four hidden), the payroll convention (maskPersonnummer), instead of XXXXXXXX-<last4>. Computed on read from the stored AES-GCM ciphertext by lib/invoices/deduction-personnummer.ts: no schema change, nothing stored, never throws (bad ciphertext logs and renders no personnummer). InvoicePDF derives it itself when given the stored row so no render call site can drop it; the preview route passes an already-masked value (it only has the typed plaintext or the kundkort fallback). The v1 pdf/send routes fetch the ciphertext for the render only; INVOICE_FULL_COLUMNS / INVOICE_PDF_COLUMNS stay as pinned. The detail page and the editor's kept-hint read the mask from the new GET /api/invoices/[id]/rot-rut (withRouteContext, company members), which never returns the last four alongside the mask. v1 REST and MCP keep deduction_personnummer_last4 for compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): stack the ROT/RUT claim state and action in Detaljer At the sidebar card width "Ej begard" and "Skapa begaran" wrapped mid-word side by side (seen in the sandbox on a paid invoice). Same shape as the Bokforing row now: state on top, the action under it. 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>
367 lines
13 KiB
TypeScript
367 lines
13 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 { InvoiceItem } from '@/types'
|
|
import type { InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
|
|
|
|
/** The invoice + items the route handed to the PDF template on the last render. */
|
|
function lastRenderProps(): { invoice: InvoicePdfInvoice; items: InvoiceItem[] } {
|
|
const call = invoicePdfMock.mock.calls.at(-1)
|
|
if (!call) throw new Error('InvoicePDF was not called')
|
|
return call[0] as { invoice: InvoicePdfInvoice; 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_masked
|
|
// 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)
|
|
// Masked like the stored-invoice PDF and the payroll roster: birth
|
|
// date visible, last four hidden. Neither the plaintext nor the last
|
|
// four digits reach the template.
|
|
expect(invoice.deduction_personnummer_masked).toBe('19900101-XXXX')
|
|
expect(invoice).not.toHaveProperty('deduction_personnummer_last4')
|
|
expect(invoice).not.toHaveProperty('deduction_personnummer_encrypted')
|
|
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)
|
|
// The 10-digit kundkort value is expanded to 12 digits before masking,
|
|
// so the mask carries the full birth date.
|
|
expect(lastRenderProps().invoice.deduction_personnummer_masked).toBe('19900101-XXXX')
|
|
})
|
|
|
|
it('masks a 10-digit typed personnummer with the full birth date', async () => {
|
|
enqueue({ data: company, error: null })
|
|
enqueue({ data: customer, error: null })
|
|
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/preview-pdf', {
|
|
method: 'POST',
|
|
body: { ...rutBody, deduction_personnummer: '900101-2385' },
|
|
}),
|
|
createMockRouteParams({}),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(lastRenderProps().invoice.deduction_personnummer_masked).toBe('19900101-XXXX')
|
|
})
|
|
|
|
it('shows no personnummer for a half-typed value that does not expand', async () => {
|
|
enqueue({ data: company, error: null })
|
|
enqueue({ data: customer, error: null })
|
|
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/preview-pdf', {
|
|
method: 'POST',
|
|
body: { ...rutBody, deduction_personnummer: '1990' },
|
|
}),
|
|
createMockRouteParams({}),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(lastRenderProps().invoice.deduction_personnummer_masked).toBeNull()
|
|
})
|
|
|
|
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_masked).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_masked).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')
|
|
})
|
|
})
|