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,126 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
parseJsonResponse,
} from '@/tests/helpers'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { GET } from '../route'
const INVOICE_ID = '550e8400-e29b-41d4-a716-446655440000'
type Body = { data: { deduction_personnummer_masked: string | null } }
describe('GET /api/invoices/[id]/rot-rut', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1', email: 'user@example.com' } },
})
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/rot-rut`),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(401)
})
it('returns 400 when the invoice id is not a UUID', async () => {
const response = await GET(
createMockRequest('/api/invoices/not-a-uuid/rot-rut'),
createMockRouteParams({ id: 'not-a-uuid' }),
)
expect(response.status).toBe(400)
})
it('returns 404 when the invoice is outside the active company', async () => {
enqueue({ data: null, error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/rot-rut`),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(404)
// Scoped by company_id on top of RLS (defense in depth).
expect(findCalls('invoices', 'eq')).toContainEqual(['company_id', 'company-1'])
})
it('returns the personnummer masked as YYYYMMDD-XXXX, never the last four digits', async () => {
enqueue({
data: {
id: INVOICE_ID,
deduction_personnummer_encrypted: encryptPersonnummer('199001012385'),
deduction_personnummer_last4: '2385',
},
error: null,
})
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/rot-rut`),
createMockRouteParams({ id: INVOICE_ID }),
)
const { body } = await parseJsonResponse<Body>(response)
expect(response.status).toBe(200)
expect(body.data).toEqual({ deduction_personnummer_masked: '19900101-XXXX' })
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
// The ciphertext is selected for the decrypt only: the browser must never
// get it back alongside a mask.
expect(JSON.stringify(body)).not.toContain('2385')
expect(JSON.stringify(body)).not.toContain('deduction_personnummer_encrypted')
})
it('returns null when the invoice carries no personnummer', async () => {
enqueue({ data: { id: INVOICE_ID, deduction_personnummer_encrypted: null }, error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/rot-rut`),
createMockRouteParams({ id: INVOICE_ID }),
)
const { body } = await parseJsonResponse<Body>(response)
expect(response.status).toBe(200)
expect(body.data.deduction_personnummer_masked).toBeNull()
})
it('returns null instead of failing when the stored ciphertext cannot be decrypted', async () => {
enqueue({
data: { id: INVOICE_ID, deduction_personnummer_encrypted: 'deadbeef'.repeat(10) },
error: null,
})
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/rot-rut`),
createMockRouteParams({ id: INVOICE_ID }),
)
const { body } = await parseJsonResponse<Body>(response)
expect(response.status).toBe(200)
expect(body.data.deduction_personnummer_masked).toBeNull()
})
})
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer'
/**
* GET /api/invoices/[id]/rot-rut
*
* The display form of the invoice's ROT/RUT personnummer, `YYYYMMDD-XXXX`.
* The browser holds the invoice row (via RLS) but only as ciphertext plus the
* last four digits, and it must never hold the ciphertext AND a mask that
* would complete the number. So the mask is computed here, server-side, from
* the ciphertext, and the last four digits are not returned. Null when the
* invoice carries no personnummer or the stored value cannot be decrypted.
*
* Read-only, company members only (withRouteContext + the company_id filter).
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.rot_rut.read',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
if (!z.string().uuid().safeParse(id).success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const { data: invoice, error } = await supabase
.from('invoices')
.select('id, deduction_personnummer_encrypted')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
log.error('failed to load invoice for rot-rut read', error, { invoiceId: id })
throw error
}
if (!invoice) {
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
return NextResponse.json(
{
data: {
deduction_personnummer_masked: maskedDeductionPersonnummer(
invoice as { deduction_personnummer_encrypted?: string | null },
),
},
},
{ headers: { 'Cache-Control': 'private, no-store' } },
)
},
)
@@ -37,13 +37,14 @@ vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
}))
import { POST } from '../route'
import type { Invoice, InvoiceItem } from '@/types'
import type { InvoiceItem } from '@/types'
import type { InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
/** The invoice + items the route handed to the PDF template on the last render. */
function lastRenderProps(): { invoice: Invoice; items: InvoiceItem[] } {
function lastRenderProps(): { invoice: InvoicePdfInvoice; items: InvoiceItem[] } {
const call = invoicePdfMock.mock.calls.at(-1)
if (!call) throw new Error('InvoicePDF was not called')
return call[0] as { invoice: Invoice; items: InvoiceItem[] }
return call[0] as { invoice: InvoicePdfInvoice; items: InvoiceItem[] }
}
describe('POST /api/invoices/preview-pdf', () => {
@@ -150,8 +151,8 @@ describe('POST /api/invoices/preview-pdf', () => {
// ROT/RUT (issue #1686): the preview must state the same avdrag row, info
// box and "Att betala" as the invoice the write path creates. The PDF
// template reads invoice.deduction_total / deduction_personnummer_last4 and
// the per-item deduction fields, so those are what the route must carry.
// template reads invoice.deduction_total / deduction_personnummer_masked
// and the per-item deduction fields, so those are what the route must carry.
describe('ROT/RUT deduction', () => {
const rutBody = {
...validBody,
@@ -193,7 +194,12 @@ describe('POST /api/invoices/preview-pdf', () => {
// 4 x 500 = 2 000 exkl. moms = 2 500 inkl. 25% moms; RUT = 50% = 1 250.
expect(invoice.deduction_total).toBe(1250)
expect(invoice.total).toBe(2750)
expect(invoice.deduction_personnummer_last4).toBe('2385')
// Masked like the stored-invoice PDF and the payroll roster: birth
// date visible, last four hidden. Neither the plaintext nor the last
// four digits reach the template.
expect(invoice.deduction_personnummer_masked).toBe('19900101-XXXX')
expect(invoice).not.toHaveProperty('deduction_personnummer_last4')
expect(invoice).not.toHaveProperty('deduction_personnummer_encrypted')
expect(items[0]).toMatchObject({
deduction_type: 'rut',
deduction_amount: 1250,
@@ -252,7 +258,41 @@ describe('POST /api/invoices/preview-pdf', () => {
)
expect(response.status).toBe(200)
expect(lastRenderProps().invoice.deduction_personnummer_last4).toBe('2385')
// The 10-digit kundkort value is expanded to 12 digits before masking,
// so the mask carries the full birth date.
expect(lastRenderProps().invoice.deduction_personnummer_masked).toBe('19900101-XXXX')
})
it('masks a 10-digit typed personnummer with the full birth date', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...rutBody, deduction_personnummer: '900101-2385' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
expect(lastRenderProps().invoice.deduction_personnummer_masked).toBe('19900101-XXXX')
})
it('shows no personnummer for a half-typed value that does not expand', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...rutBody, deduction_personnummer: '1990' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
expect(lastRenderProps().invoice.deduction_personnummer_masked).toBeNull()
})
it('leaves a non-deduction invoice unchanged', async () => {
@@ -267,7 +307,7 @@ describe('POST /api/invoices/preview-pdf', () => {
expect(response.status).toBe(200)
const { invoice, items } = lastRenderProps()
expect(invoice.deduction_total).toBe(0)
expect(invoice.deduction_personnummer_last4).toBeNull()
expect(invoice.deduction_personnummer_masked).toBeNull()
expect(invoice.total).toBe(17500)
expect(items[0]).toMatchObject({ deduction_type: null, deduction_amount: 0 })
})
@@ -287,7 +327,7 @@ describe('POST /api/invoices/preview-pdf', () => {
expect(response.status).toBe(200)
const { invoice, items } = lastRenderProps()
expect(invoice.deduction_total).toBe(0)
expect(invoice.deduction_personnummer_last4).toBeNull()
expect(invoice.deduction_personnummer_masked).toBeNull()
expect(items[0]).toMatchObject({ deduction_type: null, deduction_amount: 0, work_type: null })
})
+21 -16
View File
@@ -1,15 +1,15 @@
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { InvoicePDF, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getVatRules } from '@/lib/invoices/vat-rules'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import type { InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { computeDeduction, computeInvoiceDeductionTotal, type DeductionType } from '@/lib/invoices/rot-rut-rules'
import { expandPersonnummerTo12, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
import { expandPersonnummerTo12, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import {
hasRequiredInvoicePaymentAccount,
@@ -43,22 +43,25 @@ function optionalTrimmed(value: unknown): string | null {
}
/**
* The masked personnummer the deduction info box shows, resolved the same way
* the write path does (lib/invoices/build-invoice-write.ts): the value typed
* on the claim card wins; otherwise an individual customer's kundkort
* personnummer, if it expands to a valid 12-digit number. Only the last four
* digits leave this function; the plaintext is never stored or logged.
* The masked personnummer the deduction info box shows (`YYYYMMDD-XXXX`, the
* same convention as the stored-invoice PDF and the payroll roster), resolved
* the same way the write path does (lib/invoices/build-invoice-write.ts): the
* value typed on the claim card wins; otherwise an individual customer's
* kundkort personnummer, if it expands to a valid 12-digit number. Only the
* masked form leaves this function; the plaintext is never stored or logged.
*/
function resolvePreviewPersonnummerLast4(typed: string | null, customer: Customer): string | null {
function resolvePreviewPersonnummerMasked(typed: string | null, customer: Customer): string | null {
if (typed) {
const last4 = extractLast4(typed)
return last4.length === 4 ? last4 : null
// A 10-digit form (YYMMDD-NNNN) is expanded first so the mask shows the
// full birth date; a half-typed value that does not expand shows nothing.
const expanded = expandPersonnummerTo12(typed)
return expanded ? maskPersonnummer(expanded) : null
}
if (customer.customer_type !== 'individual') return null
try {
const revealed = revealStoredCustomerPersonalNumber(customer.personal_number)
const expanded = revealed ? expandPersonnummerTo12(revealed) : null
if (expanded && validatePersonnummer(expanded).valid) return extractLast4(expanded)
if (expanded && validatePersonnummer(expanded).valid) return maskPersonnummer(expanded)
} catch {
// Undecryptable customer value: same as absent.
}
@@ -277,8 +280,8 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
})),
)
: 0
const deductionPersonnummerLast4 = deductionTotal > 0
? resolvePreviewPersonnummerLast4(optionalTrimmed(deduction_personnummer), customer)
const deductionPersonnummerMasked = deductionTotal > 0
? resolvePreviewPersonnummerMasked(optionalTrimmed(deduction_personnummer), customer)
: null
// Derive vat_rate from items: single rate → that rate, mixed → null
@@ -320,10 +323,12 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
paid_at: null,
paid_amount: null,
deduction_total: deductionTotal,
deduction_personnummer_last4: deductionPersonnummerLast4,
// Preview invoices have no stored ciphertext: the template renders the
// masked value passed here instead of deriving one.
deduction_personnummer_masked: deductionPersonnummerMasked,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Invoice
} as InvoicePdfInvoice
try {
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
@@ -33,8 +33,13 @@ import {
import { INVOICE_PDF_COLUMNS } from '@/lib/api/v1/invoice-columns'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
// The ciphertext is fetched here, for the render only: the template derives
// the masked personnummer (YYYYMMDD-XXXX) in the deduction box from it. It is
// deliberately not part of INVOICE_PDF_COLUMNS, which pins what the API
// projects; nothing on this route echoes the row.
const PDF_FETCH_SELECT = `
${INVOICE_PDF_COLUMNS},
deduction_personnummer_encrypted,
customer:customers(*),
items:invoice_items(*)
`
@@ -225,7 +225,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const { data: invoice, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(
`${INVOICE_FULL_COLUMNS}, customer:customers(id, name, customer_number, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number, invoice_email_cc_addresses, invoice_email_bcc_addresses), items:invoice_items(${INVOICE_ITEM_FULL_COLUMNS})`,
// deduction_personnummer_encrypted rides along for the render only:
// the PDF template derives the masked personnummer (YYYYMMDD-XXXX)
// from it. It stays out of INVOICE_FULL_COLUMNS (the API projection)
// and this route never echoes the row.
`${INVOICE_FULL_COLUMNS}, deduction_personnummer_encrypted, customer:customers(id, name, customer_number, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number, invoice_email_cc_addresses, invoice_email_bcc_addresses), items:invoice_items(${INVOICE_ITEM_FULL_COLUMNS})`,
)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)