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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user