From 9d59e509abd8f542f760dad5acd50344ec23fba0 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 18 Aug 2026 21:26:52 +0200 Subject: [PATCH] fix(invoices): fold the ROT/RUT card into Detaljer and mask personnummer as YYYYMMDD-XXXX (#1699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 " · · tim" (desktop + mobile). Personnummer mask: invoice surfaces now show YYYYMMDD-XXXX (birth date visible, last four hidden), the payroll convention (maskPersonnummer), instead of XXXXXXXX-. 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/invoices/[id]/page.tsx | 221 +++++++++--------- .../[id]/rot-rut/__tests__/route.test.ts | 126 ++++++++++ app/api/invoices/[id]/rot-rut/route.ts | 56 +++++ .../preview-pdf/__tests__/route.test.ts | 58 ++++- app/api/invoices/preview-pdf/route.ts | 37 +-- .../[companyId]/invoices/[id]/pdf/route.ts | 5 + .../[companyId]/invoices/[id]/send/route.ts | 6 +- components/invoices/InvoiceEditor.tsx | 23 +- .../__tests__/deduction-personnummer.test.ts | 47 ++++ .../__tests__/pdf-template-deduction.test.ts | 113 +++++++++ lib/invoices/deduction-personnummer.ts | 36 +++ lib/invoices/pdf-template.tsx | 34 ++- messages/en.json | 22 +- messages/sv.json | 22 +- 15 files changed, 637 insertions(+), 170 deletions(-) create mode 100644 app/api/invoices/[id]/rot-rut/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/rot-rut/route.ts create mode 100644 lib/invoices/__tests__/deduction-personnummer.test.ts create mode 100644 lib/invoices/__tests__/pdf-template-deduction.test.ts create mode 100644 lib/invoices/deduction-personnummer.ts diff --git a/DECISIONS.md b/DECISIONS.md index f8481f7c..6c7a9b24 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1062,3 +1062,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-18] Editor PDF preview (#1686) recomputes ROT/RUT server-side from the posted lines with the same helpers as build-invoice-write.ts (computeDeduction / computeInvoiceDeductionTotal, base inkl. moms at the rendered rate, invoice-doc only) and resolves the masked personnummer the same way (typed value, else an individual customer's kundkort personnummer): the client is not trusted with the deduction math, and the preview must state the same avdrag row, info box and "Att betala" as the invoice that gets created. The editor now also posts deduction_personnummer / deduction_housing_designation to the preview route, only when a line claims a deduction (same privacy rule as buildInvoiceWritePayload). Swish QR amount in the preview follows buildSwishQrDataUrl, fixed separately in #1685. [2026-08-18] Skatteverket read data is visible to every company member, no new role gate (#1673): token rows are per (user, company) but the fetched skattekonto/declaration data belongs to the company, and viewers already read `skattekonto_transactions` and the local snapshot with no role check; membership (dispatcher-resolved ctx.companyId + company-scoped SELECT policy on `skatteverket_tokens`) is the gate. Reads resolve the caller's own token first, then the most recently issued active token of any member (all rows ordered, never `.maybeSingle()`, which errored once two members had connected). Writes (moms utkast/las/submit, AGI submit/spara/las, connect/disconnect, /status) stay on the caller's own token: BankID signing is personal. [2026-08-18] AGI receipt fallback (#1597): GET /agi/status serves the signed record from agi_declarations (kvittensnummer, response_data.signeradAv/signeradTid, submitted_at) only when the agi_submission_{period} cache is absent, and the declaration-sourced record deliberately carries NO salaryRunId: the period row is UNIQUE per company+period and regenerating a correction repoints its salary_run_id at the correction run while the stored kvittens still belongs to the original, so trusting the column would render the correction as filed with a superseded receipt. Ownership rests on signeradTid/submittedAt vs the run's agi_submitted_at stamp (same value) plus updatedAt = submitted_at, which predates any later correction's XML. Cache present still wins because it is the only place the in-flight states live. Rejected: a second client fetch in AGIPanel (two sources of truth for one card) and merging both records in the route (mixes another declaration's fields into an in-flight state). +[2026-08-18] Invoice ROT/RUT personnummer surfaces (detail page, invoice PDF, preview PDF, editor kept-hint) switch to the payroll mask convention YYYYMMDD-XXXX (birth date visible, last four hidden), computed on read from deduction_personnummer_encrypted via lib/invoices/deduction-personnummer.ts: no schema change, nothing stored, never throws (bad ciphertext logs and renders no personnummer). The browser gets the mask from GET /api/invoices/[id]/rot-rut and never both the mask and the last four (that is the full number); v1 REST and MCP keep deduction_personnummer_last4 for compatibility (an additive deduction_personnummer_masked is a possible follow-up). InvoicePDF derives the mask itself when the caller passes the stored row, so none of the 11 render call sites can silently drop the personnummer; the preview route passes an already-masked value since it only has plaintext. The separate Skattereduktion card on the invoice detail page is folded into Detaljer as plain rows (Personnummer, Fastighet, Skattereduktion status with the begäran lifecycle) per founder decision 2026-08-18: it duplicated the totals block. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 45632ab0..7c634565 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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(null) const [originalInvoice, setOriginalInvoice] = useState(null) const [convertedFromInvoice, setConvertedFromInvoice] = useState(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 { + 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 )} + {/* 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 && ( + <> + +
+ {t('deduction_personnummer_label')} + {deductionPersonnummerMasked ? ( + {deductionPersonnummerMasked} + ) : !invoice.deduction_personnummer_last4 ? ( + {t('deduction_personnummer_missing')} + ) : deductionPersonnummerMasked === undefined ? ( + // Stored; the mask is still on its way from the server. + // Never print the last four digits meanwhile. + + ) : ( + // Stored but the server could not read it back. + {t('deduction_personnummer_unreadable')} + )} +
+ {hasRot && ( +
+ {t('deduction_property_label')} + {rotHousing ? ( + + {rotHousing} + {rotApartment && ( + + {' · '}{t('deduction_apartment_value', { number: rotApartment })} + + )} + + ) : rotBrf ? ( + + {t('deduction_brf_value', { org: rotBrf })} + {rotApartment && ( + + {' · '}{t('deduction_apartment_value', { number: rotApartment })} + + )} + + ) : ( + {t('deduction_housing_missing')} + )} +
+ )} +
+ {t('deduction_status_label')} + {payoutRequests.length === 0 ? ( + // Same shape as the Bokföring row below: the state, and + // the action under it when there is one. +
+ {t('deduction_claim_none')} + {skvClaimable && ( + + {t('deduction_claim_cta')} + + )} +
+ ) : ( +
+ {payoutRequests.map((req) => ( + + {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)}`} + + ))} +
+ )} +
+ + )} {canBookAfterwards && ( <> @@ -1646,103 +1744,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st - {/* 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 && ( - - - - - {t('deduction_card_title', { kind: deductionKindLabel })} - - - -
-
-

- {t('deduction_customer_pays')} -

-

- {formatCurrency(amountToPay.toPay, invoice.currency)} -

-
-
-

- {t('deduction_skv_pays')} -

-

- {formatCurrency(invoice.deduction_total ?? 0, invoice.currency)} -

-
-
-

{t('deduction_card_explainer')}

- - - -
-
- {t('deduction_personnummer_label')} - {invoice.deduction_personnummer_last4 ? ( - XXXXXXXX-{invoice.deduction_personnummer_last4} - ) : ( - {t('deduction_personnummer_missing')} - )} -
- {hasRot && ( -
- {t('deduction_housing_label')} - {rotHousing ? ( - {rotHousing} - ) : rotBrf ? ( - {t('deduction_brf_value', { org: rotBrf })} - ) : ( - {t('deduction_housing_missing')} - )} -
- )} - {hasRot && rotApartment && ( -
- {t('deduction_apartment_label')} - {rotApartment} -
- )} -
- - - -
-

- {t('deduction_claim_heading')} -

- {payoutRequests.length === 0 ? ( - skvClaimable ? ( - - {t('deduction_claim_ready')} - - ) : ( -

{t('deduction_claim_not_yet')}

- ) - ) : ( - payoutRequests.map((req) => ( -
- - {req.name} · {formatDate(req.decided_at ?? req.submitted_at ?? req.created_at)} - - - {tInvoices(`rot_rut_status_${req.status}`)} - {req.decided_amount !== null && - ` · ${formatCurrency(req.decided_amount, invoice.currency)}`} - -
- )) - )} -
-
-
- )} - {/* 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 diff --git a/app/api/invoices/[id]/rot-rut/__tests__/route.test.ts b/app/api/invoices/[id]/rot-rut/__tests__/route.test.ts new file mode 100644 index 00000000..78557935 --- /dev/null +++ b/app/api/invoices/[id]/rot-rut/__tests__/route.test.ts @@ -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(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(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(response) + + expect(response.status).toBe(200) + expect(body.data.deduction_personnummer_masked).toBeNull() + }) +}) diff --git a/app/api/invoices/[id]/rot-rut/route.ts b/app/api/invoices/[id]/rot-rut/route.ts new file mode 100644 index 00000000..ed1bdb88 --- /dev/null +++ b/app/api/invoices/[id]/rot-rut/route.ts @@ -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' } }, + ) + }, +) diff --git a/app/api/invoices/preview-pdf/__tests__/route.test.ts b/app/api/invoices/preview-pdf/__tests__/route.test.ts index 909bb612..9b0f3bc0 100644 --- a/app/api/invoices/preview-pdf/__tests__/route.test.ts +++ b/app/api/invoices/preview-pdf/__tests__/route.test.ts @@ -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 }) }) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 23db18e6..226be027 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -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( diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts index a2d0585a..4e491f63 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts @@ -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(*) ` diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 11b77c8d..7d36ecba 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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) diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index ad4d8e9d..8ddea46a 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -219,6 +219,25 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat cancelled = true } }, [paymentLinksEnabled]) + // Edit mode: the draft's stored ROT/RUT personnummer, in display form + // (YYYYMMDD-XXXX). The row only carries ciphertext + last four digits, and + // the mask must come from the server so the browser never holds both + // halves of the number. Read once per draft; the hint shows it. + const storedPersonnummerId = initial?.deduction_personnummer_last4 ? initial.id : null + const [storedPersonnummerMasked, setStoredPersonnummerMasked] = useState(null) + useEffect(() => { + if (!storedPersonnummerId) return + let cancelled = false + fetch(`/api/invoices/${encodeURIComponent(storedPersonnummerId)}/rot-rut`) + .then((res) => (res.ok ? res.json() : null)) + .then((payload: { data?: { deduction_personnummer_masked?: string | null } } | null) => { + if (!cancelled) setStoredPersonnummerMasked(payload?.data?.deduction_personnummer_masked ?? null) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [storedPersonnummerId]) // The item schema is memoised on translations only; whether ROT/RUT claim // completeness applies depends on the document type (proformas, delivery @@ -2678,7 +2697,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat Otherwise, a kundkort with a personnummer covers an empty field via the server-side fallback. */} {initial?.deduction_personnummer_last4 - ? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 }) + ? storedPersonnummerMasked + ? t('deduction_personnummer_kept_hint', { masked: storedPersonnummerMasked }) + : t('deduction_personnummer_kept_hint_pending') : customerHasPersonalNumber ? t('deduction_personnummer_customer_hint') : t('deduction_personnummer_hint')} diff --git a/lib/invoices/__tests__/deduction-personnummer.test.ts b/lib/invoices/__tests__/deduction-personnummer.test.ts new file mode 100644 index 00000000..a8f54257 --- /dev/null +++ b/lib/invoices/__tests__/deduction-personnummer.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { encryptPersonnummer } from '@/lib/salary/personnummer' +import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer' + +describe('maskedDeductionPersonnummer', () => { + it('shows the birth date and hides the last four digits (YYYYMMDD-XXXX)', () => { + const encrypted = encryptPersonnummer('199001012385') + expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: encrypted })).toBe( + '19900101-XXXX', + ) + }) + + it('returns null when nothing is stored', () => { + expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: null })).toBeNull() + expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: undefined })).toBeNull() + expect(maskedDeductionPersonnummer({})).toBeNull() + expect(maskedDeductionPersonnummer(null)).toBeNull() + expect(maskedDeductionPersonnummer(undefined)).toBeNull() + }) + + it('returns null and never throws on a ciphertext that cannot be decrypted', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => + maskedDeductionPersonnummer({ deduction_personnummer_encrypted: 'deadbeef'.repeat(10) }), + ).not.toThrow() + expect( + maskedDeductionPersonnummer({ deduction_personnummer_encrypted: 'deadbeef'.repeat(10) }), + ).toBeNull() + // Too short to even carry an IV + auth tag. + expect(maskedDeductionPersonnummer({ deduction_personnummer_encrypted: 'abc' })).toBeNull() + } finally { + errorSpy.mockRestore() + } + }) + + it('tolerates a legacy plaintext row the same way decryptPersonnummer does', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect( + maskedDeductionPersonnummer({ deduction_personnummer_encrypted: '198507162389' }), + ).toBe('19850716-XXXX') + } finally { + warnSpy.mockRestore() + } + }) +}) diff --git a/lib/invoices/__tests__/pdf-template-deduction.test.ts b/lib/invoices/__tests__/pdf-template-deduction.test.ts new file mode 100644 index 00000000..f5abfb34 --- /dev/null +++ b/lib/invoices/__tests__/pdf-template-deduction.test.ts @@ -0,0 +1,113 @@ +/** + * The ROT/RUT deduction box on the invoice PDF prints the buyer's + * personnummer as YYYYMMDD-XXXX (birth date visible, last four hidden): the + * same convention as the payroll roster and the invoice detail page. The + * template derives it from the stored ciphertext, or takes an already-masked + * value from the caller (the preview route has only plaintext), and drops the + * row when neither yields anything rather than failing the render. + */ +import { describe, expect, it, vi } from 'vitest' +import type { ReactElement, ReactNode } from 'react' +import { InvoicePDF, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template' +import { encryptPersonnummer } from '@/lib/salary/personnummer' +import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers' +import type { InvoiceItem } from '@/types' + +/** Every string leaf in the element tree, in document order. */ +function textLeaves(node: ReactNode, out: string[] = []): string[] { + if (node === null || node === undefined || typeof node === 'boolean') return out + if (typeof node === 'string' || typeof node === 'number') { + out.push(String(node)) + return out + } + if (Array.isArray(node)) { + for (const child of node) textLeaves(child, out) + return out + } + const element = node as ReactElement<{ children?: ReactNode }> + if (element.props) textLeaves(element.props.children, out) + return out +} + +function renderText(invoice: InvoicePdfInvoice): string { + const items: InvoiceItem[] = [ + { + id: 'item-1', + invoice_id: invoice.id, + sort_order: 0, + line_type: 'product', + description: 'Städning', + quantity: 4, + unit: 'tim', + unit_price: 500, + line_total: 2000, + vat_rate: 25, + vat_amount: 500, + deduction_type: 'rut', + deduction_amount: 1250, + labor_hours: 4, + work_type: 'STAD', + created_at: '2026-01-15T00:00:00Z', + }, + ] + const tree = InvoicePDF({ + invoice, + customer: makeCustomer(), + items, + company: makeCompanySettings(), + }) + return textLeaves(tree).join('\n') +} + +const rutInvoice = (overrides: Partial): InvoicePdfInvoice => ({ + ...makeInvoice({ status: 'sent', invoice_number: '2026-0001', total: 2500 }), + deduction_total: 1250, + ...overrides, +}) + +describe('invoice PDF deduction box personnummer', () => { + it('derives YYYYMMDD-XXXX from the stored ciphertext', () => { + const text = renderText( + rutInvoice({ + deduction_personnummer_encrypted: encryptPersonnummer('199001012385'), + deduction_personnummer_last4: '2385', + }), + ) + + expect(text).toContain('Underlag för skattereduktion') + expect(text).toContain('19900101-XXXX') + expect(text).not.toContain('XXXXXXXX-2385') + expect(text).not.toContain('2385') + }) + + it('prints an already-masked value passed by the caller (preview has no ciphertext)', () => { + const text = renderText(rutInvoice({ deduction_personnummer_masked: '19850716-XXXX' })) + + expect(text).toContain('19850716-XXXX') + }) + + it('omits the personnummer row, and still renders the box, when the ciphertext cannot be read', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const text = renderText( + rutInvoice({ + deduction_personnummer_encrypted: 'deadbeef'.repeat(10), + deduction_personnummer_last4: '2385', + }), + ) + + expect(text).toContain('Underlag för skattereduktion') + expect(text).not.toContain('Personnummer:') + expect(text).not.toContain('2385') + } finally { + errorSpy.mockRestore() + } + }) + + it('never falls back to the last four digits alone', () => { + const text = renderText(rutInvoice({ deduction_personnummer_last4: '2385' })) + + expect(text).not.toContain('Personnummer:') + expect(text).not.toContain('2385') + }) +}) diff --git a/lib/invoices/deduction-personnummer.ts b/lib/invoices/deduction-personnummer.ts new file mode 100644 index 00000000..041a581a --- /dev/null +++ b/lib/invoices/deduction-personnummer.ts @@ -0,0 +1,36 @@ +import { createLogger } from '@/lib/logger' +import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' + +const log = createLogger('invoices/deduction-personnummer') + +/** + * The display form of an invoice's ROT/RUT personnummer: `YYYYMMDD-XXXX`, + * birth date visible and the last four digits hidden. Same convention as the + * payroll roster (maskPersonnummer), so every surface that shows a + * personnummer in the app reads the same way. + * + * Invoices store the personnummer only as AES-256-GCM ciphertext + * (`deduction_personnummer_encrypted`) plus `deduction_personnummer_last4`. + * The mask is computed on read, server-side, and never stored: a stored + * mask next to the stored last4 would hand any reader the full number by + * concatenation. That is also why the browser never gets both. + * + * Never throws. Nothing stored (or an invoice without a claim) returns null, + * and so does a ciphertext that cannot be decrypted (wrong key on a restored + * or self-hosted database, corrupted row): the invoice must still render and + * the PDF must still ship. Logs the failure without the value. + */ +export function maskedDeductionPersonnummer( + invoice: { deduction_personnummer_encrypted?: string | null } | null | undefined, +): string | null { + const encrypted = invoice?.deduction_personnummer_encrypted + if (!encrypted) return null + try { + return maskPersonnummer(decryptPersonnummer(encrypted)) + } catch (err) { + log.error('deduction personnummer decrypt failed; rendering without it', { + reason: err instanceof Error ? err.message : String(err), + }) + return null + } +} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 5c8be8d1..6c3fb2b3 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -18,6 +18,7 @@ import { import { CUSTOM_INVOICE_FONT_RENDER_PREFIX } from '@/lib/invoices/pdf-fonts' import { getAmountToPay } from '@/lib/invoices/rounding' import { isTextLikeLine } from '@/lib/invoices/display' +import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer' type PdfLang = 'sv' | 'en' @@ -655,8 +656,18 @@ function getDocumentTitle(invoice: Invoice, lang: PdfLang): string { return L.titleInvoice } +/** + * The invoice row as the template reads it. `deduction_personnummer_masked` + * is the display form of the ROT/RUT personnummer (`YYYYMMDD-XXXX`, see + * maskedDeductionPersonnummer). Callers that hold a plaintext value (the + * preview route) pass it in; callers that pass the stored row can leave it + * out and the template derives it from the ciphertext, so no render path + * silently loses the personnummer. + */ +export type InvoicePdfInvoice = Invoice & { deduction_personnummer_masked?: string | null } + interface InvoicePDFProps { - invoice: Invoice + invoice: InvoicePdfInvoice customer: Customer items: InvoiceItem[] company: CompanySettings @@ -686,6 +697,14 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN // hardcoded stylesheet: the default code path is unchanged. const styles = createStyles(branding) const isCreditNote = !!invoice.credited_invoice_id + // ROT/RUT personnummer as printed in the deduction box: YYYYMMDD-XXXX. + // Derived from the stored ciphertext unless the caller already masked a + // plaintext value (preview). Null when nothing is stored or it cannot be + // decrypted, and the row is then simply omitted. + const deductionPersonnummerMasked = + (invoice.deduction_total ?? 0) > 0 + ? (invoice.deduction_personnummer_masked ?? maskedDeductionPersonnummer(invoice)) + : null // Free-text / blank rows carry no amounts: exclude them from every VAT // calculation. They still render as their own row in the line-items table. @@ -1016,17 +1035,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN )} - {/* ROT/RUT-avdrag underlying details. Surfaces personnummer last 4, - fastighetsbeteckning, lägenhetsnummer, the per-line breakdown - and the statutory notice about fakturamodellen. Suppressed on - delivery notes (no payment info at all). */} + {/* ROT/RUT-avdrag underlying details. Surfaces the masked + personnummer (YYYYMMDD-XXXX), fastighetsbeteckning, + lägenhetsnummer, the per-line breakdown and the statutory notice + about fakturamodellen. Suppressed on delivery notes (no payment + info at all). */} {!isDeliveryNote && !isCreditNote && (invoice.deduction_total ?? 0) > 0 && ( {L.deductionInfoHeading} - {invoice.deduction_personnummer_last4 && ( + {deductionPersonnummerMasked && ( {L.deductionPersonnummer} - XXXXXXXX-{invoice.deduction_personnummer_last4} + {deductionPersonnummerMasked} )} {(() => { diff --git a/messages/en.json b/messages/en.json index fde7180a..cc95db15 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3595,9 +3595,10 @@ "deduction_card_description": "ROT/RUT deductions are claimed from Skatteverket via the invoice model (fakturamodellen). The customer must approve the payout, so the details must match the buyer exactly.", "deduction_personnummer_label": "Personal identity number (personnummer)", "deduction_personnummer_placeholder": "YYYYMMDD-NNNN", - "deduction_personnummer_hint": "Encrypted before storage. Only the last four digits are shown on the invoice.", - "deduction_personnummer_kept_hint": "The saved personal number (****{last4}) is kept if the field is left empty. Enter one only to replace it.", - "deduction_personnummer_customer_hint": "Taken from the customer card if left empty. Encrypted before storage; only the last four digits are shown on the invoice.", + "deduction_personnummer_hint": "Encrypted before storage. The invoice shows only the birth date (YYYYMMDD-XXXX).", + "deduction_personnummer_kept_hint": "The saved personal number ({masked}) is kept if the field is left empty. Enter one only to replace it.", + "deduction_personnummer_kept_hint_pending": "The saved personal number is kept if the field is left empty. Enter one only to replace it.", + "deduction_personnummer_customer_hint": "Taken from the customer card if left empty. Encrypted before storage; the invoice shows only the birth date (YYYYMMDD-XXXX).", "deduction_housing_label": "Property designation (fastighetsbeteckning)", "deduction_housing_placeholder": "e.g. Stockholm Vasastan 1:23", "deduction_housing_hint": "Required for ROT deductions (not needed for RUT).", @@ -3760,20 +3761,15 @@ "deduction_row": "{kind} tax reduction", "amount_to_pay": "Amount to pay", "deduction_line_hours": "{hours} h", - "deduction_line_amount": "reduction {amount}", - "deduction_card_title": "{kind} tax reduction", - "deduction_customer_pays": "Customer pays", - "deduction_skv_pays": "Skatteverket", - "deduction_card_explainer": "The customer pays the invoice total minus the tax reduction. You claim the rest from Skatteverket once the customer has paid (fakturamodellen).", "deduction_personnummer_label": "Personnummer", "deduction_personnummer_missing": "Missing", - "deduction_housing_label": "Property designation", + "deduction_personnummer_unreadable": "Cannot be shown", + "deduction_property_label": "Property", "deduction_housing_missing": "Missing", "deduction_brf_value": "Housing cooperative, BRF {org}", - "deduction_apartment_label": "Apartment number", - "deduction_claim_heading": "Claim with Skatteverket", - "deduction_claim_not_yet": "Created once the customer has paid their share.", - "deduction_claim_ready": "The customer has paid. You can now claim the payout from Skatteverket.", + "deduction_apartment_value": "apt. {number}", + "deduction_status_label": "Tax reduction", + "deduction_claim_none": "Not claimed", "deduction_claim_cta": "Create claim", "notes_card_title": "Notes", "reverse_charge_label": "Reverse charge", diff --git a/messages/sv.json b/messages/sv.json index 9cf969a3..63914af6 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3595,9 +3595,10 @@ "deduction_card_description": "ROT/RUT-avdrag begärs hos Skatteverket via fakturamodellen. Kunden behöver godkänna utbetalningen, så uppgifterna måste matcha köparen exakt.", "deduction_personnummer_label": "Personnummer", "deduction_personnummer_placeholder": "ÅÅÅÅMMDD-NNNN", - "deduction_personnummer_hint": "Krypteras innan lagring. Endast de fyra sista siffrorna visas på fakturan.", - "deduction_personnummer_kept_hint": "Sparat personnummer (****{last4}) behålls om fältet lämnas tomt. Fyll i endast för att byta.", - "deduction_personnummer_customer_hint": "Hämtas från kundkortet om fältet lämnas tomt. Krypteras innan lagring; endast de fyra sista siffrorna visas på fakturan.", + "deduction_personnummer_hint": "Krypteras innan lagring. På fakturan visas bara födelsedatumet (YYYYMMDD-XXXX).", + "deduction_personnummer_kept_hint": "Sparat personnummer ({masked}) behålls om fältet lämnas tomt. Fyll i endast för att byta.", + "deduction_personnummer_kept_hint_pending": "Sparat personnummer behålls om fältet lämnas tomt. Fyll i endast för att byta.", + "deduction_personnummer_customer_hint": "Hämtas från kundkortet om fältet lämnas tomt. Krypteras innan lagring; på fakturan visas bara födelsedatumet (YYYYMMDD-XXXX).", "deduction_housing_label": "Fastighetsbeteckning", "deduction_housing_placeholder": "t.ex. Stockholm Vasastan 1:23", "deduction_housing_hint": "Krävs för ROT-avdrag (RUT behöver inte detta fält).", @@ -3760,20 +3761,15 @@ "deduction_row": "Skattereduktion {kind}", "amount_to_pay": "Att betala", "deduction_line_hours": "{hours} tim", - "deduction_line_amount": "avdrag {amount}", - "deduction_card_title": "Skattereduktion {kind}", - "deduction_customer_pays": "Kunden betalar", - "deduction_skv_pays": "Skatteverket", - "deduction_card_explainer": "Kunden betalar fakturabeloppet minus skattereduktionen. Resten begär du från Skatteverket när kunden har betalat (fakturamodellen).", "deduction_personnummer_label": "Personnummer", "deduction_personnummer_missing": "Saknas", - "deduction_housing_label": "Fastighetsbeteckning", + "deduction_personnummer_unreadable": "Kan inte visas", + "deduction_property_label": "Fastighet", "deduction_housing_missing": "Saknas", "deduction_brf_value": "Bostadsrätt, BRF {org}", - "deduction_apartment_label": "Lägenhetsnummer", - "deduction_claim_heading": "Begäran hos Skatteverket", - "deduction_claim_not_yet": "Skapas när kunden har betalat sin del.", - "deduction_claim_ready": "Kunden har betalat. Nu kan du begära utbetalning från Skatteverket.", + "deduction_apartment_value": "lgh {number}", + "deduction_status_label": "Skattereduktion", + "deduction_claim_none": "Ej begärd", "deduction_claim_cta": "Skapa begäran", "notes_card_title": "Anteckningar", "reverse_charge_label": "Omvänd skattskyldighet",