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
parent 11400b4dec
commit 2e7931b36b
18 changed files with 463 additions and 8 deletions
+8 -1
View File
@@ -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,
+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)
+17
View File
@@ -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">
+8
View File
@@ -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(),
+8
View File
@@ -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
+5
View File
@@ -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",
+5
View File
@@ -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';
+1
View File
@@ -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',
+5
View File
@@ -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