* 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>
37 lines
1.6 KiB
TypeScript
37 lines
1.6 KiB
TypeScript
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
|
|
}
|
|
}
|