diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 645f7318..f810aa99 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -100,8 +100,15 @@ export const PATCH = withRouteContext( 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.contact_person !== undefined) updateData.contact_person = body.contact_person if (body.email !== undefined) updateData.email = body.email if (body.phone !== undefined) updateData.phone = body.phone + if (body.invoice_email_cc_addresses !== undefined) { + updateData.invoice_email_cc_addresses = body.invoice_email_cc_addresses + } + if (body.invoice_email_bcc_addresses !== undefined) { + updateData.invoice_email_bcc_addresses = body.invoice_email_bcc_addresses + } if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1 if (body.address_line2 !== undefined) updateData.address_line2 = body.address_line2 if (body.postal_code !== undefined) updateData.postal_code = body.postal_code diff --git a/app/api/customers/__tests__/customer-number.test.ts b/app/api/customers/__tests__/customer-number.test.ts index 583104b9..0e3baf9a 100644 --- a/app/api/customers/__tests__/customer-number.test.ts +++ b/app/api/customers/__tests__/customer-number.test.ts @@ -57,7 +57,12 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) import { POST } from '../route' import { PATCH } from '../[id]/route' -type CustomerRow = { customer_number?: string | null } +type CustomerRow = { + customer_number?: string | null + contact_person?: string | null + invoice_email_cc_addresses?: string[] | null + invoice_email_bcc_addresses?: string[] | null +} describe('customer_number on POST /api/customers', () => { beforeEach(() => { @@ -145,6 +150,49 @@ describe('customer_number on POST /api/customers', () => { expect(status).toBe(200) expect((captured.insert[0] as CustomerRow).customer_number).toBeNull() }) + + it('stores customer invoice contact metadata on create', async () => { + queryResult = { + data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business' }, + error: null, + } + const request = createMockRequest('/api/customers', { + method: 'POST', + body: { + name: 'Test AB', + customer_type: 'swedish_business', + contact_person: 'Anna Andersson', + invoice_email_cc_addresses: ['finance@example.test'], + invoice_email_bcc_addresses: ['archive@example.test'], + }, + }) + + const response = await POST(request, { params: Promise.resolve({}) }) + expect((await parseJsonResponse(response)).status).toBe(200) + expect(captured.insert[0]).toMatchObject({ + contact_person: 'Anna Andersson', + invoice_email_cc_addresses: ['finance@example.test'], + invoice_email_bcc_addresses: ['archive@example.test'], + }) + }) + + it('rejects more than 19 customer invoice copy recipients', async () => { + const request = createMockRequest('/api/customers', { + method: 'POST', + body: { + name: 'Test AB', + customer_type: 'swedish_business', + invoice_email_cc_addresses: Array.from( + { length: 20 }, + (_, index) => `copy-${index}@example.test`, + ), + }, + }) + + const response = await POST(request, { params: Promise.resolve({}) }) + expect((await parseJsonResponse(response)).status).toBe(400) + expect(captured.insert).toHaveLength(0) + }) }) describe('customer_number on PATCH /api/customers/[id]', () => { @@ -227,4 +275,23 @@ describe('customer_number on PATCH /api/customers/[id]', () => { expect(status).toBe(200) expect(captured.update[0]).not.toHaveProperty('customer_number') }) + + it('updates customer invoice contact metadata', async () => { + const request = createMockRequest('/api/customers/cust-1', { + method: 'PATCH', + body: { + contact_person: '', + invoice_email_cc_addresses: [], + invoice_email_bcc_addresses: ['archive@example.test'], + }, + }) + + const response = await PATCH(request, routeParams) + expect((await parseJsonResponse(response)).status).toBe(200) + expect(captured.update[0]).toMatchObject({ + contact_person: '', + invoice_email_cc_addresses: [], + invoice_email_bcc_addresses: ['archive@example.test'], + }) + }) }) diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index b033666f..1982a8af 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -65,8 +65,11 @@ export const POST = withRouteContext( name: body.name, customer_type: body.customer_type, customer_number: body.customer_number || null, + contact_person: body.contact_person ?? null, email: body.email, phone: body.phone, + invoice_email_cc_addresses: body.invoice_email_cc_addresses ?? null, + invoice_email_bcc_addresses: body.invoice_email_bcc_addresses ?? null, address_line1: body.address_line1, address_line2: body.address_line2, postal_code: body.postal_code, diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index bf79070e..b11093ce 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -226,6 +226,8 @@ export const POST = withRouteContext( to: customer.email, configuredCc: company.invoice_email_cc_addresses, configuredBcc: company.invoice_email_bcc_addresses, + customerCc: customer.invoice_email_cc_addresses, + customerBcc: customer.invoice_email_bcc_addresses, // This value comes from company settings or the authenticated sender. It // is fixed routing, not an arbitrary request-controlled recipient. legacyCc: company.email || user.email, diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 030e58ae..27fe41b2 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -124,6 +124,9 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { vat_number_validated: false, vat_number_validated_at: null, personal_number: null, + contact_person: null, + invoice_email_cc_addresses: null, + invoice_email_bcc_addresses: null, language: 'sv', default_payment_terms: 30, notes: null, diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 65bca839..d0f98082 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -34,8 +34,11 @@ const CustomerDetail = z.object({ name: z.string(), customer_type: z.string(), customer_number: z.string().nullable(), + contact_person: z.string().nullable(), email: z.string().nullable(), phone: z.string().nullable(), + invoice_email_cc_addresses: z.array(z.string()).nullable(), + invoice_email_bcc_addresses: z.array(z.string()).nullable(), address_line1: z.string().nullable(), address_line2: z.string().nullable(), postal_code: z.string().nullable(), @@ -57,7 +60,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, 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' + 'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, 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' @@ -296,8 +299,11 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string for (const key of [ 'name', 'customer_type', + 'contact_person', 'email', 'phone', + 'invoice_email_cc_addresses', + 'invoice_email_bcc_addresses', 'address_line1', 'address_line2', 'postal_code', diff --git a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts index 134ec6c4..2ac352da 100644 --- a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts @@ -56,7 +56,7 @@ const BulkCreateResponse = z.object({ // Same projection as the single-create endpoint: keeps response shapes // identical so callers can union the two surfaces transparently. 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, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, 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.bulk-create', @@ -127,8 +127,11 @@ async function createOneCustomer( id: null, name: input.name, customer_type: input.customer_type, + contact_person: input.contact_person ?? null, email: input.email ?? null, phone: input.phone ?? null, + invoice_email_cc_addresses: input.invoice_email_cc_addresses ?? null, + invoice_email_bcc_addresses: input.invoice_email_bcc_addresses ?? null, address_line1: input.address_line1 ?? null, address_line2: input.address_line2 ?? null, postal_code: input.postal_code ?? null, @@ -172,8 +175,11 @@ async function createOneCustomer( company_id: companyId, name: input.name, customer_type: input.customer_type, + contact_person: input.contact_person ?? null, email: input.email ?? null, phone: input.phone ?? null, + invoice_email_cc_addresses: input.invoice_email_cc_addresses ?? null, + invoice_email_bcc_addresses: input.invoice_email_bcc_addresses ?? null, address_line1: input.address_line1 ?? null, address_line2: input.address_line2 ?? null, postal_code: input.postal_code ?? null, diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 41e2b557..125c3801 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -233,8 +233,11 @@ const CustomerCreated = z.object({ name: z.string(), customer_type: CustomerType, customer_number: z.string().nullable(), + contact_person: z.string().nullable(), email: z.string().nullable(), phone: z.string().nullable(), + invoice_email_cc_addresses: z.array(z.string()).nullable(), + invoice_email_bcc_addresses: z.array(z.string()).nullable(), address_line1: z.string().nullable(), address_line2: z.string().nullable(), postal_code: z.string().nullable(), @@ -253,7 +256,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, 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' + 'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, 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', @@ -341,8 +344,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( name: body.name, customer_type: body.customer_type, customer_number: body.customer_number || null, + contact_person: body.contact_person ?? null, email: body.email ?? null, phone: body.phone ?? null, + invoice_email_cc_addresses: body.invoice_email_cc_addresses ?? null, + invoice_email_bcc_addresses: body.invoice_email_bcc_addresses ?? null, address_line1: body.address_line1 ?? null, address_line2: body.address_line2 ?? null, postal_code: body.postal_code ?? null, @@ -388,8 +394,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( // Empty string clears the customer number, same as an explicit null // (matches the internal /api/customers route). customer_number: body.customer_number || null, + contact_person: body.contact_person ?? null, email: body.email ?? null, phone: body.phone ?? null, + invoice_email_cc_addresses: body.invoice_email_cc_addresses ?? null, + invoice_email_bcc_addresses: body.invoice_email_bcc_addresses ?? null, address_line1: body.address_line1 ?? null, address_line2: body.address_line2 ?? null, postal_code: body.postal_code ?? null, 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 0ea58c95..11b77c8d 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -225,7 +225,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_FULL_COLUMNS}, customer:customers(id, name, customer_number, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(${INVOICE_ITEM_FULL_COLUMNS})`, + `${INVOICE_FULL_COLUMNS}, customer:customers(id, name, customer_number, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number, invoice_email_cc_addresses, invoice_email_bcc_addresses), items:invoice_items(${INVOICE_ITEM_FULL_COLUMNS})`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) @@ -382,6 +382,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string to: customer.email, configuredCc: settings.invoice_email_cc_addresses, configuredBcc: settings.invoice_email_bcc_addresses, + customerCc: customer.invoice_email_cc_addresses, + customerBcc: customer.invoice_email_bcc_addresses, // The company email is fixed routing, not an arbitrary // request-controlled recipient. legacyCc: settings.email, diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index dc06bd62..9b8d48d0 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -15,6 +15,11 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { + EMAIL_PATTERN, + MAX_INVOICE_EMAIL_COPY_RECIPIENTS, + parseInvoiceRecipientText, +} from '@/lib/invoices/email-recipients' import { PERSONAL_NUMBER_INPUT_RE, UNDECRYPTABLE_PERSONAL_NUMBER_MASK, @@ -46,8 +51,11 @@ export default function CustomerForm({ 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(), + contact_person: z.string().max(200, t('contact_person_too_long')).optional(), email: z.string().email(t('email_invalid')).optional().or(z.literal('')), phone: z.string().optional(), + invoice_email_cc_addresses: z.string().optional(), + invoice_email_bcc_addresses: z.string().optional(), address_line1: z.string().optional(), address_line2: z.string().optional(), postal_code: z.string().optional(), @@ -68,6 +76,29 @@ export default function CustomerForm({ language: z.enum(['sv', 'en']).optional(), default_payment_terms: z.number().min(1).optional(), notes: z.string().optional(), + }).superRefine((customer, ctx) => { + const cc = parseInvoiceRecipientText(customer.invoice_email_cc_addresses ?? '') + const bcc = parseInvoiceRecipientText(customer.invoice_email_bcc_addresses ?? '') + for (const [field, addresses] of [ + ['invoice_email_cc_addresses', cc], + ['invoice_email_bcc_addresses', bcc], + ] as const) { + const invalid = addresses.find((address) => !EMAIL_PATTERN.test(address)) + if (invalid) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: t('invoice_email_invalid', { address: invalid }), + }) + } + } + if (cc.length + bcc.length > MAX_INVOICE_EMAIL_COPY_RECIPIENTS) { + ctx.addIssue({ + code: 'custom', + path: ['invoice_email_cc_addresses'], + message: t('invoice_email_too_many', { count: MAX_INVOICE_EMAIL_COPY_RECIPIENTS }), + }) + } }), [t]) type FormData = z.infer @@ -84,8 +115,11 @@ export default function CustomerForm({ name: initialData?.name || '', customer_type: initialData?.customer_type || 'swedish_business', customer_number: initialData?.customer_number || '', + contact_person: initialData?.contact_person ?? '', email: initialData?.email || '', phone: initialData?.phone || '', + invoice_email_cc_addresses: initialData?.invoice_email_cc_addresses?.join('\n') ?? '', + invoice_email_bcc_addresses: initialData?.invoice_email_bcc_addresses?.join('\n') ?? '', address_line1: initialData?.address_line1 || '', postal_code: initialData?.postal_code || '', city: initialData?.city || '', @@ -161,10 +195,25 @@ export default function CustomerForm({ } const onFormSubmit = (data: FormData) => { + const { + invoice_email_cc_addresses: ccText, + invoice_email_bcc_addresses: bccText, + ...customerData + } = data + const isEditing = initialData !== undefined const payload: CreateCustomerInput = { - ...data, + ...customerData, + // NULL means never configured and lets a migration enrich the row. + // Empty values on an existing row are explicit clears and survive sync. + contact_person: data.contact_person?.trim() || (isEditing ? '' : null), email: data.email || undefined, personal_number: data.personal_number || null, + invoice_email_cc_addresses: ccText + ? parseInvoiceRecipientText(ccText) + : isEditing ? [] : null, + invoice_email_bcc_addresses: bccText + ? parseInvoiceRecipientText(bccText) + : isEditing ? [] : null, } // A mask means "unchanged", whichever form it is. Sending it would be // harmless (the route ignores masks too) but omitting it keeps the intent @@ -231,29 +280,74 @@ export default function CustomerForm({ {/* Contact */} -
+
- + - {errors.email && ( -

{errors.email.message}

+ {errors.contact_person && ( +

{errors.contact_person.message}

)}
-
- - +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ + +
+ {/* Customer-specific invoice recipients */} +
+

{t('invoice_email_section')}

+
+
+ +