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:
@@ -281,6 +281,12 @@ export default function CustomerDetailPage({
|
||||
<CardTitle className="text-base">{t('section_business')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{customer.customer_number && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t('label_customer_number')} </span>
|
||||
{customer.customer_number}
|
||||
</div>
|
||||
)}
|
||||
{customer.org_number && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t('label_org_number')} </span>
|
||||
@@ -300,7 +306,7 @@ export default function CustomerDetailPage({
|
||||
<span className="text-muted-foreground">{t('label_payment_terms')} </span>
|
||||
{t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
|
||||
</div>
|
||||
{!customer.org_number && !customer.vat_number && (
|
||||
{!customer.customer_number && !customer.org_number && !customer.vat_number && (
|
||||
<p className="text-sm text-muted-foreground">{t('no_business_info')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -393,6 +399,7 @@ export default function CustomerDetailPage({
|
||||
initialData={{
|
||||
name: customer.name,
|
||||
customer_type: customer.customer_type,
|
||||
customer_number: customer.customer_number || undefined,
|
||||
email: customer.email || undefined,
|
||||
phone: customer.phone || undefined,
|
||||
address_line1: customer.address_line1 || undefined,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function CustomerForm({
|
||||
const schema = useMemo(() => z.object({
|
||||
name: z.string().min(1, t('name_required')),
|
||||
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
|
||||
customer_number: z.string().trim().max(32, t('customer_number_too_long')).optional(),
|
||||
email: z.string().email(t('email_invalid')).optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
@@ -70,6 +71,7 @@ export default function CustomerForm({
|
||||
defaultValues: {
|
||||
name: initialData?.name || '',
|
||||
customer_type: initialData?.customer_type || 'swedish_business',
|
||||
customer_number: initialData?.customer_number || '',
|
||||
email: initialData?.email || '',
|
||||
phone: initialData?.phone || '',
|
||||
address_line1: initialData?.address_line1 || '',
|
||||
@@ -178,6 +180,21 @@ export default function CustomerForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Customer number */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customer_number">{t('customer_number_label')}</Label>
|
||||
<Input
|
||||
id="customer_number"
|
||||
placeholder={t('customer_number_placeholder')}
|
||||
{...register('customer_number')}
|
||||
/>
|
||||
{errors.customer_number ? (
|
||||
<p className="text-sm text-destructive">{errors.customer_number.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('customer_number_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -601,6 +601,14 @@ export const MarkInvoicePaidSchema = z.object({
|
||||
export const CreateCustomerSchema = z.object({
|
||||
name: z.string().min(1, 'Customer name is required'),
|
||||
customer_type: CustomerTypeSchema,
|
||||
// Kundnummer shown on invoices. Free text, not unique in v1. Empty string
|
||||
// and null both clear the value (routes normalize '' to null).
|
||||
customer_number: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(32, 'Customer number must be 32 characters or fewer')
|
||||
.nullable()
|
||||
.optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
|
||||
@@ -42,6 +42,7 @@ const LABELS = {
|
||||
yourReference: 'Er referens:',
|
||||
ourReference: 'Vår referens:',
|
||||
// Customer box
|
||||
custNo: 'Kundnr:',
|
||||
orgNo: 'Org.nr:',
|
||||
vat: 'VAT:',
|
||||
// Table columns
|
||||
@@ -111,6 +112,7 @@ const LABELS = {
|
||||
deliveryDate: 'Delivery date:',
|
||||
yourReference: 'Your reference:',
|
||||
ourReference: 'Our reference:',
|
||||
custNo: 'Customer no.:',
|
||||
orgNo: 'Reg. no.:',
|
||||
vat: 'VAT:',
|
||||
colDescription: 'Description',
|
||||
@@ -799,6 +801,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{customer.country && customer.country !== 'SE' && (
|
||||
<Text>{customer.country}</Text>
|
||||
)}
|
||||
{/* Seller-assigned kundnummer: no per-customer-type guard needed,
|
||||
it identifies the customer in the seller's own register and
|
||||
carries no personal data of its own. */}
|
||||
{customer.customer_number && (
|
||||
<Text style={{ marginTop: 6 }}>{L.custNo} {customer.customer_number}</Text>
|
||||
)}
|
||||
{/* Suppress the identifier row for private customers: their
|
||||
personnummer is not required on a B2C invoice (ML 17 kap 24§
|
||||
asks for name + address only) and printing it is a GDPR
|
||||
|
||||
@@ -660,6 +660,7 @@
|
||||
"no_contact_info": "No contact details",
|
||||
"no_business_info": "No business details",
|
||||
"no_invoices": "No invoices linked to this customer",
|
||||
"label_customer_number": "Customer number:",
|
||||
"label_org_number": "Org. no.:",
|
||||
"label_vat": "VAT:",
|
||||
"label_payment_terms": "Payment terms:",
|
||||
@@ -695,6 +696,10 @@
|
||||
"name_label": "Name *",
|
||||
"name_placeholder": "Company or person name",
|
||||
"name_required": "Name is required",
|
||||
"customer_number_label": "Customer number",
|
||||
"customer_number_placeholder": "E.g. 1001",
|
||||
"customer_number_hint": "Shown on the invoice. Leave empty if you do not use customer numbers.",
|
||||
"customer_number_too_long": "Customer number must be 32 characters or fewer",
|
||||
"email_label": "Email",
|
||||
"email_placeholder": "name@company.com",
|
||||
"email_invalid": "Invalid email address",
|
||||
|
||||
@@ -660,6 +660,7 @@
|
||||
"no_contact_info": "Inga kontaktuppgifter",
|
||||
"no_business_info": "Inga företagsuppgifter",
|
||||
"no_invoices": "Inga fakturor kopplade till denna kund",
|
||||
"label_customer_number": "Kundnummer:",
|
||||
"label_org_number": "Org.nr:",
|
||||
"label_vat": "VAT:",
|
||||
"label_payment_terms": "Betalningsvillkor:",
|
||||
@@ -695,6 +696,10 @@
|
||||
"name_label": "Namn *",
|
||||
"name_placeholder": "Företagsnamn eller personnamn",
|
||||
"name_required": "Namn krävs",
|
||||
"customer_number_label": "Kundnummer",
|
||||
"customer_number_placeholder": "T.ex. 1001",
|
||||
"customer_number_hint": "Visas på fakturan. Lämna tomt om du inte använder kundnummer.",
|
||||
"customer_number_too_long": "Kundnumret får vara högst 32 tecken",
|
||||
"email_label": "E-post",
|
||||
"email_placeholder": "namn@foretag.se",
|
||||
"email_invalid": "Ogiltig e-postadress",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Customer number (kundnummer) on customers, shown on the invoice (issue #914).
|
||||
--
|
||||
-- Nullable free text set by the user. Deliberately NO unique constraint in v1:
|
||||
-- existing rows, register imports, and provider syncs must not start failing
|
||||
-- on duplicates. Auto-numbering and uniqueness can be layered on later.
|
||||
--
|
||||
-- No RLS changes needed: customers already has company-scoped policies and a
|
||||
-- plain column add inherits them.
|
||||
|
||||
ALTER TABLE public.customers
|
||||
ADD COLUMN IF NOT EXISTS customer_number text;
|
||||
|
||||
COMMENT ON COLUMN public.customers.customer_number IS
|
||||
'User-assigned customer number (kundnummer) printed on invoices. Free text, not unique in v1.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -400,6 +400,7 @@ export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
|
||||
company_id: 'company-1',
|
||||
name: 'Test AB',
|
||||
customer_type: 'swedish_business',
|
||||
customer_number: null,
|
||||
email: 'kontakt@test.se',
|
||||
phone: null,
|
||||
address_line1: 'Storgatan 1',
|
||||
|
||||
@@ -557,6 +557,10 @@ export interface Customer {
|
||||
name: string
|
||||
customer_type: CustomerType
|
||||
|
||||
// User-assigned customer number (kundnummer) shown on invoices.
|
||||
// Free text, no uniqueness enforced in v1.
|
||||
customer_number: string | null
|
||||
|
||||
// Contact
|
||||
email: string | null
|
||||
phone: string | null
|
||||
@@ -1129,6 +1133,7 @@ export interface TaxRate {
|
||||
export interface CreateCustomerInput {
|
||||
name: string
|
||||
customer_type: CustomerType
|
||||
customer_number?: string | null
|
||||
email?: string
|
||||
phone?: string
|
||||
address_line1?: string
|
||||
|
||||
Reference in New Issue
Block a user