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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-04 10:00:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent cb3ef45f14
commit 00ae3540db
32 changed files with 700 additions and 42 deletions
+7
View File
@@ -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
@@ -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'],
})
})
})
+3
View File
@@ -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,
+2
View File
@@ -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,
+3
View File
@@ -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,
@@ -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',
@@ -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,
@@ -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,
@@ -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,