From 00ae3540db8dce0e3c437cd4b8c3d98190d0b8f1 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:00:03 +0200 Subject: [PATCH] feat(customers): carry contact person and invoice copy recipients through migration (#1392) * feat(customers): carry contact person and invoice copy recipients through migration Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical DTOs, customer APIs (web + v1) and invoice send flows so contact person and customer-level invoice CC/BCC addresses survive provider migrations. NULL means unconfigured and empty means an explicit clear, so re-syncs enrich legacy gaps without resurrecting deliberately removed values. Fortnox fixed assets are split into a dedicated follow-up issue. Fixes #1345 Co-Authored-By: Claude Fable 5 * chore(db): bump customer metadata migration past pack-slug version Main already contains 20260803230000; keep new versions strictly newest so Supabase branching applies them in order. Co-Authored-By: Claude Fable 5 * fix(customers): complete Customer type consumers and make enrichment payload resolvable The preview-pdf mock customer and the makeCustomer fixture now carry the three new metadata fields, fixing the type-check failure in Build (zero extensions) and Vercel. The enrichment update in the migration orchestrator now spells its payload as an object literal typed CustomerMetadataEnrichment (absent keys drop at serialization), so the phantom-column guard resolves the columns instead of counting another unresolvable dynamic payload past its ceiling. The cc/bcc guards also verify element types instead of casting. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/api/customers/[id]/route.ts | 7 + .../__tests__/customer-number.test.ts | 69 +++++++++- app/api/customers/route.ts | 3 + app/api/invoices/[id]/send/route.ts | 2 + app/api/invoices/preview-pdf/route.ts | 3 + .../[companyId]/customers/[id]/route.ts | 8 +- .../customers/bulk-create/route.ts | 8 +- .../companies/[companyId]/customers/route.ts | 11 +- .../[companyId]/invoices/[id]/send/route.ts | 4 +- components/customers/CustomerForm.tsx | 126 +++++++++++++++--- .../general/ArcimMigrationWorkspace.tsx | 10 +- components/invoices/SendInvoiceDialog.tsx | 19 ++- .../lib/__tests__/customer-metadata.test.ts | 38 ++++++ .../entity-mapper-customer-metadata.test.ts | 46 +++++++ .../arcim-migration/lib/customer-metadata.ts | 59 ++++++++ .../arcim-migration/lib/entity-mapper.ts | 3 + .../lib/migration-orchestrator.ts | 67 +++++++++- extensions/general/arcim-migration/types.ts | 2 +- lib/api/__tests__/schemas.test.ts | 24 ++++ lib/api/schemas.ts | 29 ++++ .../__tests__/email-recipients.test.ts | 45 +++++++ lib/invoices/email-recipients.ts | 20 ++- lib/invoices/recurring-schedule-service.ts | 2 + lib/pending-operations/commit.ts | 2 + lib/providers/dto.ts | 2 + .../mapper-customer-metadata.test.ts | 42 ++++++ lib/providers/fortnox/mapper.ts | 36 ++++- messages/en.json | 10 ++ messages/sv.json | 10 ++ ...260803231000_customer_invoice_metadata.sql | 26 ++++ tests/helpers.ts | 3 + types/index.ts | 6 + 32 files changed, 700 insertions(+), 42 deletions(-) create mode 100644 extensions/general/arcim-migration/lib/__tests__/customer-metadata.test.ts create mode 100644 extensions/general/arcim-migration/lib/__tests__/entity-mapper-customer-metadata.test.ts create mode 100644 extensions/general/arcim-migration/lib/customer-metadata.ts create mode 100644 lib/providers/fortnox/__tests__/mapper-customer-metadata.test.ts create mode 100644 supabase/migrations/20260803231000_customer_invoice_metadata.sql 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')}

+
+
+ +