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:
Jakob Wennberg
2026-07-09 21:27:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 11400b4dec
commit 2e7931b36b
18 changed files with 463 additions and 8 deletions
+2
View File
@@ -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')
})
})
+1
View File
@@ -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,
+1
View File
@@ -44,6 +44,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
company_id: 'preview-company',
name: 'Exempel AB',
customer_type: 'swedish_business',
customer_number: null,
email: 'kund@exempel.se',
phone: null,
address_line1: 'Storgatan 1',
@@ -33,6 +33,7 @@ const CustomerDetail = z.object({
id: z.string().uuid(),
name: z.string(),
customer_type: z.string(),
customer_number: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
@@ -56,7 +57,7 @@ const OPEN_INVOICE_STATUSES = ['sent', 'partially_paid', 'overdue']
// Explicit projection. Excludes user_id, company_id (internal scoping),
// and vat_number_validated_at (internal timestamp not in the public schema).
const CUSTOMER_DETAIL_COLUMNS =
'id, name, customer_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at'
'id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at'
const OPEN_INVOICE_COLUMNS =
'id, invoice_number, invoice_date, due_date, status, currency, total, remaining_amount'
@@ -310,6 +311,11 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
] as const) {
if (body[key] !== undefined) updateData[key] = body[key]
}
// Empty string clears the customer number, same as an explicit null
// (matches the internal /api/customers route).
if (body.customer_number !== undefined) {
updateData.customer_number = body.customer_number || null
}
if (Object.keys(updateData).length === 0) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -51,6 +51,13 @@ const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
// Records insert/update payloads and .select() projection strings so
// tests can assert what the route writes and which columns it fetches.
const captured: {
insert: unknown[]
update: unknown[]
selects: Record<string, string[]>
} = { insert: [], update: [], selects: {} }
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
@@ -58,12 +65,19 @@ function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?:
return (resolve: (v: unknown) => void) =>
resolve(byTable[table] ?? { data: null, error: null })
}
return (..._args: unknown[]) => buildChain(table)
return (...args: unknown[]) => {
if (prop === 'insert') captured.insert.push(args[0])
if (prop === 'update') captured.update.push(args[0])
if (prop === 'select' && typeof args[0] === 'string') {
;(captured.selects[table] ??= []).push(args[0])
}
return buildChain(table)
}
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
return { from: vi.fn((table: string) => buildChain(table)), captured }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
@@ -589,6 +603,55 @@ describe('POST /api/v1/companies/:companyId/customers', () => {
const body = await res.json()
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
})
it('round-trips customer_number: persisted in the insert and selected back in the response', async () => {
withWriteScope()
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, customer_number: '1001' }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await createCustomer(
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, {
name: 'Acme AB',
customer_type: 'swedish_business',
customer_number: '1001',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.customer_number).toBe('1001')
// The insert payload carries the field (not silently dropped) …
const insertPayload = supabaseMock.captured.insert[0] as { customer_number?: string | null }
expect(insertPayload.customer_number).toBe('1001')
// … and the response projection selects it back.
expect(supabaseMock.captured.selects['customers']?.[0]).toContain('customer_number')
})
it('normalizes an empty customer_number to null on create', async () => {
withWriteScope()
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, customer_number: null }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await createCustomer(
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, {
name: 'Acme AB',
customer_type: 'swedish_business',
customer_number: '',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
const insertPayload = supabaseMock.captured.insert[0] as { customer_number?: string | null }
expect(insertPayload.customer_number).toBeNull()
})
})
// ──────────────────────────────────────────────────────────────────
@@ -722,6 +785,49 @@ describe('PATCH /api/v1/companies/:companyId/customers/:id', () => {
expect(body.data.archived_at).toBeNull()
})
it('round-trips customer_number: persisted in the update and selected back in the response', async () => {
withWriteScope()
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, customer_number: 'K-42' }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await updateCustomer(
makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {
customer_number: 'K-42',
}),
detailParams(COMPANY_ID, CUSTOMER_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.customer_number).toBe('K-42')
const updatePayload = supabaseMock.captured.update[0] as { customer_number?: string | null }
expect(updatePayload.customer_number).toBe('K-42')
expect(supabaseMock.captured.selects['customers']?.[0]).toContain('customer_number')
})
it('clears customer_number when an empty string is sent', async () => {
withWriteScope()
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, customer_number: null }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await updateCustomer(
makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {
customer_number: '',
}),
detailParams(COMPANY_ID, CUSTOMER_ID),
)
expect(res.status).toBe(200)
const updatePayload = supabaseMock.captured.update[0] as { customer_number?: string | null }
expect(updatePayload.customer_number).toBeNull()
})
it('rejects an archived_at value that is not null', async () => {
withWriteScope()
mockServiceClient.mockReturnValue(
@@ -232,6 +232,7 @@ const CustomerCreated = z.object({
id: z.string().uuid().nullable(),
name: z.string(),
customer_type: CustomerType,
customer_number: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
@@ -252,7 +253,7 @@ const CustomerCreated = z.object({
// Drop vat_number_validated_at: declared in neither CustomerCreated nor
// CustomerDetail; an internal timestamp with no documented consumer.
const CUSTOMER_RESPONSE_COLUMNS =
'id, name, customer_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at'
'id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at'
registerEndpoint({
operation: 'customers.create',
@@ -339,6 +340,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
id: null,
name: body.name,
customer_type: body.customer_type,
customer_number: body.customer_number || null,
email: body.email ?? null,
phone: body.phone ?? null,
address_line1: body.address_line1 ?? null,
@@ -383,6 +385,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
company_id: ctx.companyId!,
name: body.name,
customer_type: body.customer_type,
// Empty string clears the customer number, same as an explicit null
// (matches the internal /api/customers route).
customer_number: body.customer_number || null,
email: body.email ?? null,
phone: body.phone ?? null,
address_line1: body.address_line1 ?? null,
@@ -101,6 +101,9 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
// Records every .select() projection string per table so tests can assert
// which columns the route actually fetches.
const selects: Record<string, string[]> = {}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
@@ -111,12 +114,17 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
return (...args: unknown[]) => {
if (prop === 'select' && typeof args[0] === 'string') {
;(selects[table] ??= []).push(args[0])
}
return buildChain(table)
}
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
return { from: vi.fn((table: string) => buildChain(table)), selects }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
@@ -416,6 +424,30 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
expect(finalRenderArgs.invoice.invoice_number).toBe('2026-0043')
})
it('fetches customer_number in the customer join so the emailed PDF matches the downloaded one', async () => {
// The pdf route selects customers(*); this route uses an explicit
// projection. If customer_number is dropped here, the emailed PDF
// silently omits the kundnummer that the downloaded PDF shows.
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: DRAFT_INVOICE, error: null },
{ data: { invoice_number: '2026-0042' }, error: null },
],
company_settings: { data: COMPANY_SETTINGS, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const invoiceFetchProjection = supabaseMock.selects['invoices']?.[0] ?? ''
expect(invoiceFetchProjection).toMatch(/customer:customers\([^)]*\bcustomer_number\b[^)]*\)/)
})
it('test-mode key forces dry-run: returns a preview, no email, no number burned', async () => {
// A test key has no ?dry_run flag, but the wrapper forces dry-run because
// the key is mode='test'. The send endpoint declares dryRunSupported, so the
@@ -162,7 +162,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const { data: invoice, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(
`${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account, dimensions)`,
`${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, customer_number, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account, dimensions)`,
)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)