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:
Jakob Wennberg
2026-08-18 21:26:52 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 72603abfd6
commit 9d59e509ab
15 changed files with 637 additions and 170 deletions
@@ -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')
})
})
+36
View File
@@ -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
}
}
+27 -7
View File
@@ -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>
)}
{(() => {