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
@@ -219,6 +219,25 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
cancelled = true
|
||||
}
|
||||
}, [paymentLinksEnabled])
|
||||
// Edit mode: the draft's stored ROT/RUT personnummer, in display form
|
||||
// (YYYYMMDD-XXXX). The row only carries ciphertext + last four digits, and
|
||||
// the mask must come from the server so the browser never holds both
|
||||
// halves of the number. Read once per draft; the hint shows it.
|
||||
const storedPersonnummerId = initial?.deduction_personnummer_last4 ? initial.id : null
|
||||
const [storedPersonnummerMasked, setStoredPersonnummerMasked] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!storedPersonnummerId) return
|
||||
let cancelled = false
|
||||
fetch(`/api/invoices/${encodeURIComponent(storedPersonnummerId)}/rot-rut`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((payload: { data?: { deduction_personnummer_masked?: string | null } } | null) => {
|
||||
if (!cancelled) setStoredPersonnummerMasked(payload?.data?.deduction_personnummer_masked ?? null)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [storedPersonnummerId])
|
||||
|
||||
// The item schema is memoised on translations only; whether ROT/RUT claim
|
||||
// completeness applies depends on the document type (proformas, delivery
|
||||
@@ -2678,7 +2697,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
Otherwise, a kundkort with a personnummer covers an
|
||||
empty field via the server-side fallback. */}
|
||||
{initial?.deduction_personnummer_last4
|
||||
? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 })
|
||||
? storedPersonnummerMasked
|
||||
? t('deduction_personnummer_kept_hint', { masked: storedPersonnummerMasked })
|
||||
: t('deduction_personnummer_kept_hint_pending')
|
||||
: customerHasPersonalNumber
|
||||
? t('deduction_personnummer_customer_hint')
|
||||
: t('deduction_personnummer_hint')}
|
||||
|
||||
Reference in New Issue
Block a user