Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
This commit is contained in:
@@ -4,6 +4,7 @@ import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'customer.get',
|
||||
@@ -37,7 +38,7 @@ export const GET = withRouteContext(
|
||||
.eq('company_id', companyId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
|
||||
return NextResponse.json({ data: { ...maskCustomerRow(data), invoices: invoices || [] } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -55,6 +56,26 @@ export const PATCH = withRouteContext(
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, customer_type')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (existingError || !existing) {
|
||||
if (existingError?.code === 'PGRST116') {
|
||||
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
opLog.error('customer lookup before update failed', existingError)
|
||||
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const effectiveType = body.customer_type ?? existing.customer_type
|
||||
if (body.personal_number && effectiveType !== 'individual') {
|
||||
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
|
||||
@@ -69,6 +90,11 @@ export const PATCH = withRouteContext(
|
||||
if (body.country !== undefined) updateData.country = body.country
|
||||
if (body.org_number !== undefined) updateData.org_number = body.org_number
|
||||
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
|
||||
if (body.personal_number !== undefined) {
|
||||
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
|
||||
} else if (body.customer_type !== undefined && effectiveType !== 'individual') {
|
||||
updateData.personal_number = null
|
||||
}
|
||||
if (body.language !== undefined) updateData.language = body.language
|
||||
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
@@ -82,6 +108,9 @@ export const PATCH = withRouteContext(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
if (error.code === '23505') {
|
||||
return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, {
|
||||
requestId,
|
||||
@@ -127,7 +156,7 @@ export const PATCH = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
return NextResponse.json({ data: maskCustomerRow(data) })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
|
||||
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
|
||||
|
||||
const buildChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (value: unknown) => void) => resolve(queryResult)
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'insert') captured.insert.push(args[0])
|
||||
if (prop === 'update') captured.update.push(args[0])
|
||||
return buildChain()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn(() => buildChain()),
|
||||
rpc: vi.fn(() => buildChain()),
|
||||
}
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { PATCH } from '../[id]/route'
|
||||
|
||||
type CustomerWrite = { personal_number?: string | null }
|
||||
|
||||
describe('personal_number on customer routes', () => {
|
||||
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
captured.insert.length = 0
|
||||
captured.update.length = 0
|
||||
queryResult = { data: null, error: null }
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 before creating a customer when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'Anna Andersson', customer_type: 'individual' },
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 400 for an invalid personal number', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: 'not-a-personal-number',
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stores the personal number when creating a private customer', async () => {
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: '19900101-1234',
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('19900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
|
||||
expect(body.data.personal_number).toBe('********-1234')
|
||||
})
|
||||
|
||||
it('updates the personal number for an existing private customer', async () => {
|
||||
queryResult = {
|
||||
data: {
|
||||
id: 'customer-1',
|
||||
customer_type: 'individual',
|
||||
personal_number: '900101-1234',
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
|
||||
expect(encrypted).not.toBe('900101-1234')
|
||||
expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
|
||||
})
|
||||
|
||||
it('clears the personal number when null is sent', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'individual', personal_number: null },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: null },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
|
||||
})
|
||||
|
||||
it('does not change the personal number when the field is omitted', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Anna A' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(captured.update[0]).not.toHaveProperty('personal_number')
|
||||
})
|
||||
|
||||
it('rejects a personal number for a corporate customer', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'customer-1', customer_type: 'swedish_business' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/customer-1', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED')
|
||||
expect(captured.update).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 404 when the customer does not exist', async () => {
|
||||
queryResult = {
|
||||
data: null,
|
||||
error: { code: 'PGRST116', message: 'No rows returned' },
|
||||
}
|
||||
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/customers/missing', {
|
||||
method: 'PATCH',
|
||||
body: { personal_number: '900101-1234' },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: 'missing' }) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { Customer } from '@/types'
|
||||
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
return NextResponse.json({ data: (data ?? []).map(maskCustomerRow) })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -59,6 +60,7 @@ export const POST = withRouteContext(
|
||||
country: body.country || 'Sweden',
|
||||
org_number: body.org_number,
|
||||
vat_number: body.vat_number,
|
||||
personal_number: encryptCustomerPersonalNumber(body.personal_number),
|
||||
language: body.language || 'sv',
|
||||
default_payment_terms: body.default_payment_terms || 30,
|
||||
notes: body.notes,
|
||||
@@ -104,12 +106,13 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const safeCustomer = maskCustomerRow(data)
|
||||
await eventBus.emit({
|
||||
type: 'customer.created',
|
||||
payload: { customer: data as Customer, companyId: companyId!, userId: user.id },
|
||||
payload: { customer: safeCustomer as Customer, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
return NextResponse.json({ data: safeCustomer })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user