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:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
+31 -2
View File
@@ -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)
})
})
+6 -3
View File
@@ -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 },
)