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