fix(invoices): keep the stored ROT/RUT personnummer when editing a draft (#1186)

Fixes #1175. The stored personnummer exists only as AES-256-GCM
ciphertext (+ last4), so the editor cannot rehydrate it and sent an
empty string; buildInvoiceWriteData then failed ROT/RUT validation and
every edit of a draft deduction invoice was blocked with "Personnummer
krävs för ROT/RUT-avdrag" unless the user re-entered the customer's
personnummer.

buildInvoiceWriteData accepts the stored ciphertext from the update
path: an empty field on an invoice that still has deduction lines
means keep, a typed value replaces, and removing every deduction line
clears as before. The editor hint shows the kept last4 in edit mode
(new i18n key, sv+en).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-25 12:59:50 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 731a57dd6e
commit 5afd031306
6 changed files with 107 additions and 5 deletions
+10 -1
View File
@@ -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) {
+5 -1
View File
@@ -2074,7 +2074,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
{...register('deduction_personnummer')}
/>
<p className="text-xs text-muted-foreground">
{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')}
</p>
</div>
{hasAnyRotLine && (
@@ -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()
})
})
+20 -3
View File
@@ -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<BuildInvoiceWriteResult> {
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 } }
+1
View File
@@ -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).",
+1
View File
@@ -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).",