fix(invoices): fold the ROT/RUT card into Detaljer and mask personnummer as YYYYMMDD-XXXX (#1699)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
72603abfd6
commit
9d59e509ab
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer'
|
||||
|
||||
describe('maskedDeductionPersonnummer', () => {
|
||||
it('shows the birth date and hides the last four digits (YYYYMMDD-XXXX)', () => {
|
||||
const encrypted = encryptPersonnummer('199001012385')
|
||||
expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: encrypted })).toBe(
|
||||
'19900101-XXXX',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: null })).toBeNull()
|
||||
expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: undefined })).toBeNull()
|
||||
expect(maskedDeductionPersonnummer({})).toBeNull()
|
||||
expect(maskedDeductionPersonnummer(null)).toBeNull()
|
||||
expect(maskedDeductionPersonnummer(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null and never throws on a ciphertext that cannot be decrypted', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
expect(() =>
|
||||
maskedDeductionPersonnummer({ deduction_personnummer_encrypted: 'deadbeef'.repeat(10) }),
|
||||
).not.toThrow()
|
||||
expect(
|
||||
maskedDeductionPersonnummer({ deduction_personnummer_encrypted: 'deadbeef'.repeat(10) }),
|
||||
).toBeNull()
|
||||
// Too short to even carry an IV + auth tag.
|
||||
expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: 'abc' })).toBeNull()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('tolerates a legacy plaintext row the same way decryptPersonnummer does', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect(
|
||||
maskedDeductionPersonnummer({ deduction_personnummer_encrypted: '198507162389' }),
|
||||
).toBe('19850716-XXXX')
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const log = createLogger('invoices/deduction-personnummer')
|
||||
|
||||
/**
|
||||
* The display form of an invoice's ROT/RUT personnummer: `YYYYMMDD-XXXX`,
|
||||
* birth date visible and the last four digits hidden. Same convention as the
|
||||
* payroll roster (maskPersonnummer), so every surface that shows a
|
||||
* personnummer in the app reads the same way.
|
||||
*
|
||||
* Invoices store the personnummer only as AES-256-GCM ciphertext
|
||||
* (`deduction_personnummer_encrypted`) plus `deduction_personnummer_last4`.
|
||||
* The mask is computed on read, server-side, and never stored: a stored
|
||||
* mask next to the stored last4 would hand any reader the full number by
|
||||
* concatenation. That is also why the browser never gets both.
|
||||
*
|
||||
* Never throws. Nothing stored (or an invoice without a claim) returns null,
|
||||
* and so does a ciphertext that cannot be decrypted (wrong key on a restored
|
||||
* or self-hosted database, corrupted row): the invoice must still render and
|
||||
* the PDF must still ship. Logs the failure without the value.
|
||||
*/
|
||||
export function maskedDeductionPersonnummer(
|
||||
invoice: { deduction_personnummer_encrypted?: string | null } | null | undefined,
|
||||
): string | null {
|
||||
const encrypted = invoice?.deduction_personnummer_encrypted
|
||||
if (!encrypted) return null
|
||||
try {
|
||||
return maskPersonnummer(decryptPersonnummer(encrypted))
|
||||
} catch (err) {
|
||||
log.error('deduction personnummer decrypt failed; rendering without it', {
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { CUSTOM_INVOICE_FONT_RENDER_PREFIX } from '@/lib/invoices/pdf-fonts'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { isTextLikeLine } from '@/lib/invoices/display'
|
||||
import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer'
|
||||
|
||||
type PdfLang = 'sv' | 'en'
|
||||
|
||||
@@ -655,8 +656,18 @@ function getDocumentTitle(invoice: Invoice, lang: PdfLang): string {
|
||||
return L.titleInvoice
|
||||
}
|
||||
|
||||
/**
|
||||
* The invoice row as the template reads it. `deduction_personnummer_masked`
|
||||
* is the display form of the ROT/RUT personnummer (`YYYYMMDD-XXXX`, see
|
||||
* maskedDeductionPersonnummer). Callers that hold a plaintext value (the
|
||||
* preview route) pass it in; callers that pass the stored row can leave it
|
||||
* out and the template derives it from the ciphertext, so no render path
|
||||
* silently loses the personnummer.
|
||||
*/
|
||||
export type InvoicePdfInvoice = Invoice & { deduction_personnummer_masked?: string | null }
|
||||
|
||||
interface InvoicePDFProps {
|
||||
invoice: Invoice
|
||||
invoice: InvoicePdfInvoice
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
company: CompanySettings
|
||||
@@ -686,6 +697,14 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
// hardcoded stylesheet: the default code path is unchanged.
|
||||
const styles = createStyles(branding)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
// ROT/RUT personnummer as printed in the deduction box: YYYYMMDD-XXXX.
|
||||
// Derived from the stored ciphertext unless the caller already masked a
|
||||
// plaintext value (preview). Null when nothing is stored or it cannot be
|
||||
// decrypted, and the row is then simply omitted.
|
||||
const deductionPersonnummerMasked =
|
||||
(invoice.deduction_total ?? 0) > 0
|
||||
? (invoice.deduction_personnummer_masked ?? maskedDeductionPersonnummer(invoice))
|
||||
: null
|
||||
|
||||
// Free-text / blank rows carry no amounts: exclude them from every VAT
|
||||
// calculation. They still render as their own row in the line-items table.
|
||||
@@ -1016,17 +1035,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ROT/RUT-avdrag underlying details. Surfaces personnummer last 4,
|
||||
fastighetsbeteckning, lägenhetsnummer, the per-line breakdown
|
||||
and the statutory notice about fakturamodellen. Suppressed on
|
||||
delivery notes (no payment info at all). */}
|
||||
{/* ROT/RUT-avdrag underlying details. Surfaces the masked
|
||||
personnummer (YYYYMMDD-XXXX), fastighetsbeteckning,
|
||||
lägenhetsnummer, the per-line breakdown and the statutory notice
|
||||
about fakturamodellen. Suppressed on delivery notes (no payment
|
||||
info at all). */}
|
||||
{!isDeliveryNote && !isCreditNote && (invoice.deduction_total ?? 0) > 0 && (
|
||||
<View style={styles.deductionBox} wrap={false}>
|
||||
<Text style={styles.deductionTitle}>{L.deductionInfoHeading}</Text>
|
||||
{invoice.deduction_personnummer_last4 && (
|
||||
{deductionPersonnummerMasked && (
|
||||
<View style={styles.deductionRow}>
|
||||
<Text style={styles.deductionLabel}>{L.deductionPersonnummer}</Text>
|
||||
<Text style={styles.deductionValue}>XXXXXXXX-{invoice.deduction_personnummer_last4}</Text>
|
||||
<Text style={styles.deductionValue}>{deductionPersonnummerMasked}</Text>
|
||||
</View>
|
||||
)}
|
||||
{(() => {
|
||||
|
||||
Reference in New Issue
Block a user