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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user