diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index 6496406f..7887f8a0 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -281,6 +281,12 @@ export default function CustomerDetailPage({ {t('section_business')} + {customer.customer_number && ( +
+ {t('label_customer_number')} + {customer.customer_number} +
+ )} {customer.org_number && (
{t('label_org_number')} @@ -300,7 +306,7 @@ export default function CustomerDetailPage({ {t('label_payment_terms')} {t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
- {!customer.org_number && !customer.vat_number && ( + {!customer.customer_number && !customer.org_number && !customer.vat_number && (

{t('no_business_info')}

)}
@@ -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, diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index e22b7f6a..0964ef67 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -58,6 +58,8 @@ export const PATCH = withRouteContext( const updateData: Record = {} 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 diff --git a/app/api/customers/__tests__/customer-number.test.ts b/app/api/customers/__tests__/customer-number.test.ts new file mode 100644 index 00000000..583104b9 --- /dev/null +++ b/app/api/customers/__tests__/customer-number.test.ts @@ -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') + }) +}) diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 4342c8ad..10bb84c3 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -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, diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 522c3580..068da7ea 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -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', diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 80e38154..994ea3bb 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -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, { diff --git a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts index 7431023a..b8fa73a6 100644 --- a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts @@ -51,6 +51,13 @@ const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType function makeFlexibleSupabase(byTable: Record) { + // 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 + } = { insert: [], update: [], selects: {} } const buildChain = (table: string): unknown => { const handler: ProxyHandler = { get(_target, prop) { @@ -58,12 +65,19 @@ function makeFlexibleSupabase(byTable: Record 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( diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index a887793c..41e2b557 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index fe03a55e..130855dc 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -101,6 +101,9 @@ function makeFlexibleSupabase(byTable: Record 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 = {} const buildChain = (table: string): unknown => { const handler: ProxyHandler = { get(_target, prop) { @@ -111,12 +114,17 @@ function makeFlexibleSupabase(byTable: Record 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 diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 3bf8748c..e774d421 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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) diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index 6ebfdad9..f865ecfb 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -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({ )} + {/* Customer number */} +
+ + + {errors.customer_number ? ( +

{errors.customer_number.message}

+ ) : ( +

{t('customer_number_hint')}

+ )} +
+ {/* Contact */}
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index b6e587ca..97980ec1 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index c1cdc0e5..acd4e2b0 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -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' && ( {customer.country} )} + {/* 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 && ( + {L.custNo} {customer.customer_number} + )} {/* 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 diff --git a/messages/en.json b/messages/en.json index 3d9b9505..1d81ed3b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index cac13a5e..db4f5fc7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/supabase/migrations/20260709140000_add_customer_number_to_customers.sql b/supabase/migrations/20260709140000_add_customer_number_to_customers.sql new file mode 100644 index 00000000..02f6036a --- /dev/null +++ b/supabase/migrations/20260709140000_add_customer_number_to_customers.sql @@ -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'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 9035a118..cf2a6022 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -400,6 +400,7 @@ export function makeCustomer(overrides: Partial = {}): 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', diff --git a/types/index.ts b/types/index.ts index 3bc590f9..088229e0 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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