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:
co-authored by
Claude Fable 5
parent
731a57dd6e
commit
5afd031306
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
Reference in New Issue
Block a user