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
+111 -110
View File
@@ -48,13 +48,12 @@ import {
CalendarClock,
Pencil,
Copy,
Landmark,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import {
@@ -170,6 +169,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
decided_at: string | null
}>
>([])
// Display form of the ROT/RUT personnummer (YYYYMMDD-XXXX). The row the
// browser holds carries only ciphertext + last four digits, and it must
// never hold both a mask and the last four (that is the full number), so
// the mask is fetched from the server for invoices with a claim.
// undefined = not fetched yet, null = nothing stored or unreadable.
const [deductionPersonnummerMasked, setDeductionPersonnummerMasked] = useState<
string | null | undefined
>(undefined)
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
@@ -232,6 +239,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
}
/** Masked ROT/RUT personnummer from the server; null when absent or unreadable. */
async function loadDeductionPersonnummerMasked(): Promise<string | null> {
try {
const response = await fetch(`/api/invoices/${encodeURIComponent(id)}/rot-rut`)
if (!response.ok) return null
const payload = (await response.json()) as {
data?: { deduction_personnummer_masked?: string | null }
}
return payload.data?.deduction_personnummer_masked ?? null
} catch {
return null
}
}
async function retryLoadDeliveries() {
const result = await loadDeliveries()
setDeliveries(result.deliveries)
@@ -246,7 +267,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// change / finalize / payment / send reconcile BEHIND the mounted page:
// a one-field state change must not collapse the whole detail view to a
// spinner, reset scroll, and remount every card.
if (!invoice || invoice.id !== id) setIsLoading(true)
if (!invoice || invoice.id !== id) {
setIsLoading(true)
// A different invoice: never let the previous one's mask show on it.
setDeductionPersonnummerMasked(undefined)
}
// Settings depend only on the active company, so start them with the main
// invoice batch instead of waiting for the invoice row first.
@@ -401,6 +426,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// view. Resolve them together after first paint and fill their links in.
setIsLoading(false)
void Promise.all([
(data.deduction_total ?? 0) > 0
? loadDeductionPersonnummerMasked()
: Promise.resolve(null),
!data.credited_invoice_id &&
['sent', 'paid', 'overdue', 'credited'].includes(data.status)
? supabase
@@ -424,11 +452,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.eq('id', data.converted_from_id)
.single()
: Promise.resolve(null),
]).then(([creditNoteRes, originalRes, convertedRes]) => {
]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes]) => {
// Deferred writes need the same guard: they land after first paint
// and would otherwise attach the previous invoice's related documents
// to the one the pager has since navigated to.
if (seq !== fetchSeqRef.current) return
setDeductionPersonnummerMasked(personnummerMasked)
setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
if (originalRes?.data) {
setOriginalInvoice(originalRes.data as Invoice)
@@ -1031,8 +1060,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const rotApartment = rotItem?.apartment_number ?? null
const rotBrf = rotItem?.brf_org_number ?? null
const skvClaimable = invoice.status === 'paid' && payoutRequests.length === 0
// "RUT · Städning · 4 tim · avdrag 1 250,00 kr" under a claimed line, so
// the claim is visible on the item itself, not only in the PDF.
// "RUT · Städning · 4 tim" under a claimed line, so the claim is visible on
// the item itself, not only in the PDF. The amount lives in the totals block.
const deductionLineInfo = (item: InvoiceItem): string => {
const parts = [item.deduction_type === 'rot' ? 'ROT' : 'RUT']
const label = workTypeLabel(item.work_type)
@@ -1040,13 +1069,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (item.labor_hours && item.labor_hours > 0) {
parts.push(t('deduction_line_hours', { hours: item.labor_hours }))
}
if ((item.deduction_amount ?? 0) > 0) {
parts.push(
t('deduction_line_amount', {
amount: formatCurrency(item.deduction_amount ?? 0, invoice.currency),
}),
)
}
return parts.join(' · ')
}
const hasAccruedItems = invoice.items.some(itemHasAccrual)
@@ -1593,6 +1615,82 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</span>
</div>
)}
{/* ROT/RUT (fakturamodellen): the underlag Skatteverket needs and
where the begäran om utbetalning stands, as plain rows. The
amounts live in the totals block; nothing is repeated here. */}
{showDeduction && (
<>
<Separator />
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_personnummer_label')}</span>
{deductionPersonnummerMasked ? (
<span className="tabular-nums">{deductionPersonnummerMasked}</span>
) : !invoice.deduction_personnummer_last4 ? (
<span className="text-attn">{t('deduction_personnummer_missing')}</span>
) : deductionPersonnummerMasked === undefined ? (
// Stored; the mask is still on its way from the server.
// Never print the last four digits meanwhile.
<Skeleton className="h-4 w-24" />
) : (
// Stored but the server could not read it back.
<span className="text-muted-foreground">{t('deduction_personnummer_unreadable')}</span>
)}
</div>
{hasRot && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_property_label')}</span>
{rotHousing ? (
<span className="text-right">
{rotHousing}
{rotApartment && (
<span className="text-muted-foreground tabular-nums">
{' · '}{t('deduction_apartment_value', { number: rotApartment })}
</span>
)}
</span>
) : rotBrf ? (
<span className="text-right">
{t('deduction_brf_value', { org: rotBrf })}
{rotApartment && (
<span className="text-muted-foreground tabular-nums">
{' · '}{t('deduction_apartment_value', { number: rotApartment })}
</span>
)}
</span>
) : (
<span className="text-attn">{t('deduction_housing_missing')}</span>
)}
</div>
)}
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-sm">{t('deduction_status_label')}</span>
{payoutRequests.length === 0 ? (
// Same shape as the Bokföring row below: the state, and
// the action under it when there is one.
<div className="flex flex-col items-end gap-1 text-sm">
<span className="text-muted-foreground">{t('deduction_claim_none')}</span>
{skvClaimable && (
<Link href="/invoices?rot-rut=1" className="hover:underline whitespace-nowrap">
{t('deduction_claim_cta')}
</Link>
)}
</div>
) : (
<div className="flex flex-col items-end gap-1 text-sm tabular-nums">
{payoutRequests.map((req) => (
<span key={req.id} className="text-right">
{tInvoices(`rot_rut_status_${req.status}`)}
{' '}
{formatDate(req.decided_at ?? req.submitted_at ?? req.created_at)}
{req.decided_amount !== null &&
` · ${formatCurrency(req.decided_amount, invoice.currency)}`}
</span>
))}
</div>
)}
</div>
</>
)}
{canBookAfterwards && (
<>
<Separator />
@@ -1646,103 +1744,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</CardContent>
</Card>
{/* Skattereduktion ROT/RUT card (fakturamodellen). The claim used to
be visible only on the PDF; whoever created the invoice needs it
here too: who pays what, the underlag Skatteverket needs, and
where the begäran om utbetalning stands. */}
{showDeduction && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Landmark className="h-5 w-5" />
{t('deduction_card_title', { kind: deductionKindLabel })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('deduction_customer_pays')}
</p>
<p className="font-display text-xl tabular-nums mt-1">
{formatCurrency(amountToPay.toPay, invoice.currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('deduction_skv_pays')}
</p>
<p className="font-display text-xl tabular-nums mt-1">
{formatCurrency(invoice.deduction_total ?? 0, invoice.currency)}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('deduction_card_explainer')}</p>
<Separator />
<div className="space-y-2 text-sm">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_personnummer_label')}</span>
{invoice.deduction_personnummer_last4 ? (
<span className="tabular-nums">XXXXXXXX-{invoice.deduction_personnummer_last4}</span>
) : (
<span className="text-attn">{t('deduction_personnummer_missing')}</span>
)}
</div>
{hasRot && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_housing_label')}</span>
{rotHousing ? (
<span className="text-right">{rotHousing}</span>
) : rotBrf ? (
<span className="text-right">{t('deduction_brf_value', { org: rotBrf })}</span>
) : (
<span className="text-attn">{t('deduction_housing_missing')}</span>
)}
</div>
)}
{hasRot && rotApartment && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_apartment_label')}</span>
<span className="tabular-nums">{rotApartment}</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('deduction_claim_heading')}
</p>
{payoutRequests.length === 0 ? (
skvClaimable ? (
<AttnLine action={{ label: t('deduction_claim_cta'), href: '/invoices?rot-rut=1' }}>
{t('deduction_claim_ready')}
</AttnLine>
) : (
<p className="text-sm text-muted-foreground">{t('deduction_claim_not_yet')}</p>
)
) : (
payoutRequests.map((req) => (
<div key={req.id} className="flex items-center justify-between gap-4 text-sm">
<span className="tabular-nums">
{req.name} · {formatDate(req.decided_at ?? req.submitted_at ?? req.created_at)}
</span>
<span className="text-right text-muted-foreground tabular-nums">
{tInvoices(`rot_rut_status_${req.status}`)}
{req.decided_amount !== null &&
` · ${formatCurrency(req.decided_amount, invoice.currency)}`}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
)}
{/* Betalningsstatus card. Shows for both `paid` and `partially_paid`
so the user always sees how much has been paid + what remains +
the individual payment events. Previously only the `paid` case
@@ -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)