* 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>
114 lines
3.8 KiB
TypeScript
114 lines
3.8 KiB
TypeScript
/**
|
|
* The ROT/RUT deduction box on the invoice PDF prints the buyer's
|
|
* personnummer as YYYYMMDD-XXXX (birth date visible, last four hidden): the
|
|
* same convention as the payroll roster and the invoice detail page. The
|
|
* template derives it from the stored ciphertext, or takes an already-masked
|
|
* value from the caller (the preview route has only plaintext), and drops the
|
|
* row when neither yields anything rather than failing the render.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { ReactElement, ReactNode } from 'react'
|
|
import { InvoicePDF, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
|
|
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
|
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
|
|
import type { InvoiceItem } from '@/types'
|
|
|
|
/** Every string leaf in the element tree, in document order. */
|
|
function textLeaves(node: ReactNode, out: string[] = []): string[] {
|
|
if (node === null || node === undefined || typeof node === 'boolean') return out
|
|
if (typeof node === 'string' || typeof node === 'number') {
|
|
out.push(String(node))
|
|
return out
|
|
}
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) textLeaves(child, out)
|
|
return out
|
|
}
|
|
const element = node as ReactElement<{ children?: ReactNode }>
|
|
if (element.props) textLeaves(element.props.children, out)
|
|
return out
|
|
}
|
|
|
|
function renderText(invoice: InvoicePdfInvoice): string {
|
|
const items: InvoiceItem[] = [
|
|
{
|
|
id: 'item-1',
|
|
invoice_id: invoice.id,
|
|
sort_order: 0,
|
|
line_type: 'product',
|
|
description: 'Städning',
|
|
quantity: 4,
|
|
unit: 'tim',
|
|
unit_price: 500,
|
|
line_total: 2000,
|
|
vat_rate: 25,
|
|
vat_amount: 500,
|
|
deduction_type: 'rut',
|
|
deduction_amount: 1250,
|
|
labor_hours: 4,
|
|
work_type: 'STAD',
|
|
created_at: '2026-01-15T00:00:00Z',
|
|
},
|
|
]
|
|
const tree = InvoicePDF({
|
|
invoice,
|
|
customer: makeCustomer(),
|
|
items,
|
|
company: makeCompanySettings(),
|
|
})
|
|
return textLeaves(tree).join('\n')
|
|
}
|
|
|
|
const rutInvoice = (overrides: Partial<InvoicePdfInvoice>): InvoicePdfInvoice => ({
|
|
...makeInvoice({ status: 'sent', invoice_number: '2026-0001', total: 2500 }),
|
|
deduction_total: 1250,
|
|
...overrides,
|
|
})
|
|
|
|
describe('invoice PDF deduction box personnummer', () => {
|
|
it('derives YYYYMMDD-XXXX from the stored ciphertext', () => {
|
|
const text = renderText(
|
|
rutInvoice({
|
|
deduction_personnummer_encrypted: encryptPersonnummer('199001012385'),
|
|
deduction_personnummer_last4: '2385',
|
|
}),
|
|
)
|
|
|
|
expect(text).toContain('Underlag för skattereduktion')
|
|
expect(text).toContain('19900101-XXXX')
|
|
expect(text).not.toContain('XXXXXXXX-2385')
|
|
expect(text).not.toContain('2385')
|
|
})
|
|
|
|
it('prints an already-masked value passed by the caller (preview has no ciphertext)', () => {
|
|
const text = renderText(rutInvoice({ deduction_personnummer_masked: '19850716-XXXX' }))
|
|
|
|
expect(text).toContain('19850716-XXXX')
|
|
})
|
|
|
|
it('omits the personnummer row, and still renders the box, when the ciphertext cannot be read', () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
try {
|
|
const text = renderText(
|
|
rutInvoice({
|
|
deduction_personnummer_encrypted: 'deadbeef'.repeat(10),
|
|
deduction_personnummer_last4: '2385',
|
|
}),
|
|
)
|
|
|
|
expect(text).toContain('Underlag för skattereduktion')
|
|
expect(text).not.toContain('Personnummer:')
|
|
expect(text).not.toContain('2385')
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('never falls back to the last four digits alone', () => {
|
|
const text = renderText(rutInvoice({ deduction_personnummer_last4: '2385' }))
|
|
|
|
expect(text).not.toContain('Personnummer:')
|
|
expect(text).not.toContain('2385')
|
|
})
|
|
})
|