feat(customers): let users set a customer number shown on the invoice (#957)
Implements #914 (kundnummer on customers, printed on the invoice PDF). - Migration: nullable text column customers.customer_number, no unique constraint in v1 so existing rows and imports keep working. - API: CreateCustomerSchema/UpdateCustomerSchema accept an optional customer_number (trimmed, max 32 chars, nullable-then-optional so the OpenAPI registry sees it as not required); create/update routes persist it and normalize empty string to null so it can be cleared. - v1 public API: customers create/detail/update round-trip the field (insert and update field lists, response projections, response schemas), and the invoices :send route fetches customer_number in its explicit customer join so the emailed PDF matches the downloaded one (the pdf route already selects customers(*)). - UI: optional Kundnummer field in CustomerForm (next-intl keys in both sv and en), wired into the edit dialog's initialData; read-only Kundnummer row on the customer detail page's business-details card. - Invoice PDF: renders "Kundnr:" / "Customer no.:" in the customer box when set; the PDF reads the live customers join, so no snapshot column is needed. - Tests: route tests cover 400 validation, trimming, clearing with null/empty, and omit-leaves-untouched on POST and PATCH; v1 tests cover the create/update round-trip (insert/update payload + response projection) and the :send customer-join projection. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
11400b4dec
commit
2e7931b36b
@@ -58,6 +58,8 @@ export const PATCH = withRouteContext(
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
|
||||
// Empty string clears the customer number, same as an explicit null.
|
||||
if (body.customer_number !== undefined) updateData.customer_number = body.customer_number || null
|
||||
if (body.email !== undefined) updateData.email = body.email
|
||||
if (body.phone !== undefined) updateData.phone = body.phone
|
||||
if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Tests for the customer_number field (kundnummer, issue #914) on
|
||||
* POST /api/customers and PATCH /api/customers/[id].
|
||||
*
|
||||
* Exercises the routes through the real withRouteContext wrapper, mocking its
|
||||
* auth/company/write dependencies. Uses a hand-rolled Supabase mock that
|
||||
* records insert/update payloads so the tests can assert the route-level
|
||||
* normalization: the value is trimmed by the Zod schema, and empty string or
|
||||
* null clears the column to null.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
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: (v: 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 CustomerRow = { customer_number?: string | null }
|
||||
|
||||
describe('customer_number on POST /api/customers', () => {
|
||||
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('rejects a customer_number longer than 32 characters with 400', async () => {
|
||||
const request = createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Test AB',
|
||||
customer_type: 'swedish_business',
|
||||
customer_number: 'X'.repeat(33),
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(captured.insert).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stores a trimmed customer_number on create', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business', customer_number: '1001' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const request = createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Test AB',
|
||||
customer_type: 'swedish_business',
|
||||
customer_number: ' 1001 ',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: CustomerRow }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect((captured.insert[0] as CustomerRow).customer_number).toBe('1001')
|
||||
expect(body.data.customer_number).toBe('1001')
|
||||
})
|
||||
|
||||
it('normalizes an empty customer_number to null on create', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business', customer_number: null },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const request = createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'Test AB', customer_type: 'swedish_business', customer_number: '' },
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect((captured.insert[0] as CustomerRow).customer_number).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults customer_number to null when omitted', async () => {
|
||||
queryResult = {
|
||||
data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business', customer_number: null },
|
||||
error: null,
|
||||
}
|
||||
|
||||
const request = createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'Test AB', customer_type: 'swedish_business' },
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect((captured.insert[0] as CustomerRow).customer_number).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('customer_number on PATCH /api/customers/[id]', () => {
|
||||
const routeParams = { params: Promise.resolve({ id: 'cust-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
captured.insert.length = 0
|
||||
captured.update.length = 0
|
||||
queryResult = {
|
||||
data: { id: 'cust-1', customer_type: 'swedish_business' },
|
||||
error: null,
|
||||
}
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('rejects a customer_number longer than 32 characters with 400', async () => {
|
||||
const request = createMockRequest('/api/customers/cust-1', {
|
||||
method: 'PATCH',
|
||||
body: { customer_number: 'X'.repeat(33) },
|
||||
})
|
||||
|
||||
const response = await PATCH(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(captured.update).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('updates the customer_number', async () => {
|
||||
const request = createMockRequest('/api/customers/cust-1', {
|
||||
method: 'PATCH',
|
||||
body: { customer_number: 'K-42' },
|
||||
})
|
||||
|
||||
const response = await PATCH(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect((captured.update[0] as CustomerRow).customer_number).toBe('K-42')
|
||||
})
|
||||
|
||||
it('clears the customer_number when null is sent', async () => {
|
||||
const request = createMockRequest('/api/customers/cust-1', {
|
||||
method: 'PATCH',
|
||||
body: { customer_number: null },
|
||||
})
|
||||
|
||||
const response = await PATCH(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect((captured.update[0] as CustomerRow).customer_number).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the customer_number when an empty string is sent', async () => {
|
||||
const request = createMockRequest('/api/customers/cust-1', {
|
||||
method: 'PATCH',
|
||||
body: { customer_number: '' },
|
||||
})
|
||||
|
||||
const response = await PATCH(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect((captured.update[0] as CustomerRow).customer_number).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the customer_number untouched when the field is omitted', async () => {
|
||||
const request = createMockRequest('/api/customers/cust-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'New Name AB' },
|
||||
})
|
||||
|
||||
const response = await PATCH(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(captured.update[0]).not.toHaveProperty('customer_number')
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,7 @@ export const POST = withRouteContext(
|
||||
company_id: companyId,
|
||||
name: body.name,
|
||||
customer_type: body.customer_type,
|
||||
customer_number: body.customer_number || null,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address_line1: body.address_line1,
|
||||
|
||||
Reference in New Issue
Block a user