Files
accounted/lib/invoices/__tests__/deduction-personnummer.test.ts
T
9d59e509ab 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>
2026-08-18 21:26:52 +02:00

48 lines
1.9 KiB
TypeScript

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()
}
})
})