diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index ec8f65c8..936b1bef 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -148,7 +148,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( // received self-billing document) may be edited. const { data: existing, error: fetchError } = await supabase .from('invoices') - .select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id') + .select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, deduction_personnummer_encrypted, deduction_personnummer_last4') .eq('id', id) .eq('company_id', companyId!) .single() @@ -186,6 +186,15 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( customer, documentType, input, + // The stored personnummer exists only as ciphertext (client sees _last4 + // at most), so an edit that leaves the field empty keeps it rather than + // failing ROT/RUT validation (issue #1175). + existingPersonnummer: existing.deduction_personnummer_encrypted + ? { + encrypted: existing.deduction_personnummer_encrypted, + last4: existing.deduction_personnummer_last4 ?? null, + } + : null, }) if (!build.ok) { if ('dbError' in build) { diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index dd73c588..9cb7337c 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -2074,7 +2074,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat {...register('deduction_personnummer')} />

- {t('deduction_personnummer_hint')} + {/* Stored pn exists only as ciphertext: an empty field on + edit keeps it server-side instead of failing validation. */} + {initial?.deduction_personnummer_last4 + ? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 }) + : t('deduction_personnummer_hint')}

{hasAnyRotLine && ( diff --git a/lib/invoices/__tests__/build-invoice-write.test.ts b/lib/invoices/__tests__/build-invoice-write.test.ts index a6bf9eff..9dbd5e38 100644 --- a/lib/invoices/__tests__/build-invoice-write.test.ts +++ b/lib/invoices/__tests__/build-invoice-write.test.ts @@ -159,3 +159,73 @@ describe('buildInvoiceWriteData', () => { expect(result.items[0]).toMatchObject({ line_type: 'text', line_total: 0, vat_amount: 0 }) }) }) + +describe('buildInvoiceWriteData stored ROT/RUT personnummer (edit path)', () => { + const rutItem = { + description: 'Städning', + quantity: 10, + unit: 'tim', + unit_price: 500, + vat_rate: 25, + deduction_type: 'rut' as const, + labor_hours: 10, + } + + it('keeps the stored ciphertext when the edit leaves personnummer empty', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { vat_registered: true }, error: null }) + + const customer = makeCustomer({ customer_type: 'individual' }) + const result = await buildInvoiceWriteData({ + supabase: supabase as unknown as SupabaseClient, + companyId: 'company-1', + customer, + documentType: 'invoice', + input: { ...baseHeader, items: [rutItem] }, + existingPersonnummer: { encrypted: 'stored-ciphertext', last4: '1234' }, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.invoiceFields.deduction_personnummer_encrypted).toBe('stored-ciphertext') + expect(result.invoiceFields.deduction_personnummer_last4).toBe('1234') + }) + + it('still rejects a deduction invoice with no personnummer anywhere (create path)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { vat_registered: true }, error: null }) + + const customer = makeCustomer({ customer_type: 'individual' }) + const result = await buildInvoiceWriteData({ + supabase: supabase as unknown as SupabaseClient, + companyId: 'company-1', + customer, + documentType: 'invoice', + input: { ...baseHeader, items: [rutItem] }, + }) + + expect(result.ok).toBe(false) + if (result.ok) return + expect('code' in result && result.code).toBe('INVOICE_CREATE_ROT_RUT_VALIDATION') + }) + + it('does not resurrect the stored personnummer when all deduction lines are removed', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { vat_registered: true }, error: null }) + + const customer = makeCustomer({ customer_type: 'individual' }) + const result = await buildInvoiceWriteData({ + supabase: supabase as unknown as SupabaseClient, + companyId: 'company-1', + customer, + documentType: 'invoice', + input: { ...baseHeader, items: [{ description: 'Vanlig tjänst', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 25 }] }, + existingPersonnummer: { encrypted: 'stored-ciphertext', last4: '1234' }, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.invoiceFields.deduction_personnummer_encrypted).toBeNull() + expect(result.invoiceFields.deduction_personnummer_last4).toBeNull() + }) +}) diff --git a/lib/invoices/build-invoice-write.ts b/lib/invoices/build-invoice-write.ts index 07b0c977..6e048af2 100644 --- a/lib/invoices/build-invoice-write.ts +++ b/lib/invoices/build-invoice-write.ts @@ -158,8 +158,15 @@ export async function buildInvoiceWriteData(params: { customer: Customer documentType: InvoiceDocumentType input: InvoiceWriteInput + /** + * Update path only: the stored encrypted personnummer of the draft being + * edited. The plaintext is never rehydratable client-side (only _last4 is), + * so an edit that leaves the field empty keeps these stored values instead + * of failing ROT/RUT validation or wiping the ciphertext. + */ + existingPersonnummer?: { encrypted: string; last4: string | null } | null }): Promise { - const { supabase, companyId, customer, documentType, input } = params + const { supabase, companyId, customer, documentType, input, existingPersonnummer } = params const items = input.items const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated) @@ -308,7 +315,6 @@ export async function buildInvoiceWriteData(params: { } const housingProvided = fastighetProvided || (apartmentProvided && brfProvided) const personnummerRaw = input.deduction_personnummer?.trim() || '' - const personnummerProvided = personnummerRaw.length > 0 const validateInput = items.map((item) => ({ unit_price: item.unit_price, @@ -317,6 +323,14 @@ export async function buildInvoiceWriteData(params: { labor_hours: item.labor_hours ?? null, housing_designation: item.housing_designation ?? null, })) + + // Editing a draft: the stored personnummer only exists as ciphertext, so + // the client cannot resend it. An empty field on an invoice that still has + // deduction lines means "keep the stored one", not "remove it". + const hasDeductionItems = validateInput.some((item) => item.deduction_type != null) + const keepStoredPersonnummer = + personnummerRaw.length === 0 && hasDeductionItems && !!existingPersonnummer + const personnummerProvided = personnummerRaw.length > 0 || keepStoredPersonnummer const validation = validateRotRut(validateInput, personnummerProvided, housingProvided) if (validation.errors.length > 0) { return { @@ -330,7 +344,10 @@ export async function buildInvoiceWriteData(params: { // never touches the DB: only the AES-256-GCM ciphertext + the last four // digits go into invoices columns. deductionTotal = computeInvoiceDeductionTotal(validateInput) - if (personnummerProvided) { + if (keepStoredPersonnummer && existingPersonnummer) { + deductionPersonnummerEncrypted = existingPersonnummer.encrypted + deductionPersonnummerLast4 = existingPersonnummer.last4 + } else if (personnummerProvided) { const pnValid = validatePersonnummer(personnummerRaw) if (!pnValid.valid) { return { ok: false, code: 'INVOICE_CREATE_ROT_RUT_PERSONNUMMER_INVALID', details: { error: pnValid.error } } diff --git a/messages/en.json b/messages/en.json index 4f05a42c..1bfbd344 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2914,6 +2914,7 @@ "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_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).", diff --git a/messages/sv.json b/messages/sv.json index d76c88c3..ac7cd8b8 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2914,6 +2914,7 @@ "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_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).",