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
+24
View File
@@ -629,6 +629,9 @@ describe('CreateCustomerSchema', () => {
const result = CreateCustomerSchema.safeParse(validCustomer({
email: 'billing@acme.se',
phone: '+46701234567',
contact_person: 'Anna Andersson',
invoice_email_cc_addresses: ['finance@acme.se'],
invoice_email_bcc_addresses: ['archive@acme.se'],
address_line1: 'Storgatan 1',
address_line2: 'Box 123',
postal_code: '111 22',
@@ -682,6 +685,20 @@ describe('CreateCustomerSchema', () => {
const result = CreateCustomerSchema.safeParse(validCustomer({ default_payment_terms: 30.5 }))
expect(result.success).toBe(false)
})
it('rejects more than 19 customer invoice copy recipients across CC and BCC', () => {
const result = CreateCustomerSchema.safeParse(validCustomer({
invoice_email_cc_addresses: Array.from(
{ length: 10 },
(_, index) => `copy-${index}@example.test`,
),
invoice_email_bcc_addresses: Array.from(
{ length: 10 },
(_, index) => `archive-${index}@example.test`,
),
}))
expect(result.success).toBe(false)
})
})
// ============================================================
@@ -2149,6 +2166,13 @@ describe('UpdateCustomerSchema', () => {
const result = UpdateCustomerSchema.safeParse({ customer_type: 'government' })
expect(result.success).toBe(false)
})
it('rejects an invalid customer invoice copy address', () => {
const result = UpdateCustomerSchema.safeParse({
invoice_email_cc_addresses: ['not-an-email'],
})
expect(result.success).toBe(false)
})
})
describe('UpdateSupplierSchema', () => {
+29
View File
@@ -824,8 +824,11 @@ export const CreateCustomerSchema = z.object({
.max(32, 'Customer number must be 32 characters or fewer')
.nullable()
.optional(),
contact_person: z.string().trim().max(200).nullable().optional(),
email: z.string().email('Invalid email address').optional(),
phone: z.string().optional(),
invoice_email_cc_addresses: invoiceEmailAddressList.nullable().optional(),
invoice_email_bcc_addresses: invoiceEmailAddressList.nullable().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
@@ -849,14 +852,28 @@ export const CreateCustomerSchema = z.object({
message: 'Personal number is only allowed for individual customers',
})
}
if (
(customer.invoice_email_cc_addresses?.length ?? 0)
+ (customer.invoice_email_bcc_addresses?.length ?? 0)
> MAX_INVOICE_EMAIL_COPY_RECIPIENTS
) {
ctx.addIssue({
code: 'custom',
path: ['invoice_email_cc_addresses'],
message: `At most ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} customer invoice copy recipients are allowed in total`,
})
}
})
export const UpdateCustomerSchema = z.object({
name: z.string().min(1, 'Customer name is required').optional(),
customer_type: CustomerTypeSchema.optional(),
customer_number: z.string().trim().max(32).nullable().optional(),
contact_person: z.string().trim().max(200).nullable().optional(),
email: z.string().email('Invalid email address').optional(),
phone: z.string().optional(),
invoice_email_cc_addresses: invoiceEmailAddressList.nullable().optional(),
invoice_email_bcc_addresses: invoiceEmailAddressList.nullable().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
@@ -883,6 +900,18 @@ export const UpdateCustomerSchema = z.object({
language: z.enum(['sv', 'en']).optional(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
}).superRefine((customer, ctx) => {
if (
(customer.invoice_email_cc_addresses?.length ?? 0)
+ (customer.invoice_email_bcc_addresses?.length ?? 0)
> MAX_INVOICE_EMAIL_COPY_RECIPIENTS
) {
ctx.addIssue({
code: 'custom',
path: ['invoice_email_cc_addresses'],
message: `At most ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} customer invoice copy recipients are allowed in total`,
})
}
})
// ============================================================
@@ -36,6 +36,30 @@ describe('resolveInvoiceEmailRecipients', () => {
})
})
it('merges customer-specific recipients between company settings and per-send additions', () => {
expect(resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: ['company-copy@example.test'],
configuredBcc: ['company-archive@example.test'],
customerCc: ['customer-copy@example.test', 'COMPANY-COPY@example.test'],
customerBcc: ['customer-archive@example.test', 'customer-copy@example.test'],
additionalCc: ['case-owner@example.test'],
additionalBcc: ['audit@example.test'],
})).toEqual({
to: ['customer@example.test'],
cc: [
'company-copy@example.test',
'customer-copy@example.test',
'case-owner@example.test',
],
bcc: [
'company-archive@example.test',
'customer-archive@example.test',
'audit@example.test',
],
})
})
it('counts the final de-duplicated To, CC, and BCC recipients', () => {
const atLimit = resolveInvoiceEmailRecipients({
to: 'customer@example.test',
@@ -84,4 +108,25 @@ describe('resolveInvoiceEmailRecipients', () => {
},
])
})
it('reports collisions with customer-specific recipients', () => {
expect(findAdditionalInvoiceRecipientCollisions({
to: 'customer@example.test',
customerCc: ['finance@example.test'],
customerBcc: ['archive@example.test'],
additionalCc: ['ARCHIVE@example.test'],
additionalBcc: ['FINANCE@example.test'],
})).toEqual([
{
address: 'ARCHIVE@example.test',
field: 'additional_cc',
conflicts_with: 'customer_bcc',
},
{
address: 'FINANCE@example.test',
field: 'additional_bcc',
conflicts_with: 'customer_cc',
},
])
})
})
+18 -2
View File
@@ -6,6 +6,8 @@ export interface ResolveInvoiceEmailRecipientsInput {
to: string | readonly string[]
configuredCc?: readonly string[] | null
configuredBcc?: readonly string[] | null
customerCc?: readonly string[] | null
customerBcc?: readonly string[] | null
legacyCc?: string | null
additionalCc?: readonly string[]
additionalBcc?: readonly string[]
@@ -36,6 +38,8 @@ export interface InvoiceEmailRecipientCollision {
| 'to'
| 'configured_cc'
| 'configured_bcc'
| 'customer_cc'
| 'customer_bcc'
| 'additional_cc'
| 'additional_bcc'
}
@@ -83,11 +87,15 @@ export function resolveInvoiceEmailRecipients(
: input.configuredCc
const cc = uniqueAddresses(
[...fixedCc, ...(input.additionalCc ?? [])],
[...fixedCc, ...(input.customerCc ?? []), ...(input.additionalCc ?? [])],
used,
)
const bcc = uniqueAddresses(
[...(input.configuredBcc ?? []), ...(input.additionalBcc ?? [])],
[
...(input.configuredBcc ?? []),
...(input.customerBcc ?? []),
...(input.additionalBcc ?? []),
],
used,
)
@@ -119,10 +127,18 @@ export function findAdditionalInvoiceRecipientCollisions(
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_cc')
}
for (const address of input.customerCc ?? []) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'customer_cc')
}
for (const address of input.configuredBcc ?? []) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_bcc')
}
for (const address of input.customerBcc ?? []) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'customer_bcc')
}
const collisions: InvoiceEmailRecipientCollision[] = []
for (const address of input.additionalCc ?? []) {
@@ -495,6 +495,8 @@ async function sendInvoiceFromSchedule(
to: invoice.customer.email,
configuredCc: company.invoice_email_cc_addresses,
configuredBcc: company.invoice_email_bcc_addresses,
customerCc: invoice.customer.invoice_email_cc_addresses,
customerBcc: invoice.customer.invoice_email_bcc_addresses,
legacyCc: company.email,
})
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
+2
View File
@@ -2163,6 +2163,8 @@ async function commitSendInvoice(
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,
legacyCc: company.email || userEmail,
})
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
+2
View File
@@ -219,6 +219,8 @@ export interface CustomerDto {
customerNumber: string;
type?: CustomerType;
party: PartyDto;
invoiceEmailCcAddresses?: string[];
invoiceEmailBccAddresses?: string[];
deliveryAddresses?: PostalAddress[];
financialDimensions?: FinancialDimensionRef[];
active: boolean;
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { mapFortnoxToCustomer } from '../mapper'
describe('Fortnox customer metadata mapping', () => {
it('prefers invoice email and carries reference plus CC/BCC recipients', () => {
const dto = mapFortnoxToCustomer({
CustomerNumber: '1001',
Name: 'Kund AB',
Email: 'general@example.test',
EmailInvoice: 'invoice@example.test',
EmailInvoiceCC: 'finance@example.test; owner@example.test, FINANCE@example.test',
EmailInvoiceBCC: ['archive@example.test', 'audit@example.test'],
YourReference: 'Anna Andersson',
Active: true,
})
expect(dto.party.contact).toMatchObject({
name: 'Anna Andersson',
email: 'invoice@example.test',
})
expect(dto.invoiceEmailCcAddresses).toEqual([
'finance@example.test',
'owner@example.test',
])
expect(dto.invoiceEmailBccAddresses).toEqual([
'archive@example.test',
'audit@example.test',
])
})
it('falls back to the general email and preserves absent copy fields as undefined', () => {
const dto = mapFortnoxToCustomer({
CustomerNumber: '1002',
Name: 'Kund Två AB',
Email: 'general@example.test',
})
expect(dto.party.contact?.email).toBe('general@example.test')
expect(dto.invoiceEmailCcAddresses).toBeUndefined()
expect(dto.invoiceEmailBccAddresses).toBeUndefined()
})
})
+34 -2
View File
@@ -14,6 +14,33 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
return { value: value ?? 0, currencyCode: currency };
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function providerEmailAddresses(
raw: Record<string, unknown>,
field: string,
): string[] | undefined {
if (!(field in raw)) return undefined;
const value = raw[field];
const parts = Array.isArray(value)
? value.flatMap((item) => typeof item === 'string' ? item.split(/[\n,;]+/) : [])
: typeof value === 'string'
? value.split(/[\n,;]+/)
: [];
const seen = new Set<string>();
return parts.flatMap((part) => {
const address = part.trim();
const key = address.toLocaleLowerCase('en-US');
if (!key || seen.has(key)) return [];
seen.add(key);
return [address];
});
}
/**
* Single source of truth for "is this invoice fully settled?", used by BOTH
* deriveInvoiceStatus and the paymentStatus.paid flag so they can never diverge.
@@ -52,8 +79,11 @@ function buildParty(name: string, orgNumber?: string, address?: Record<string, u
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: {
email: (address?.['Email'] ?? address?.['EmailInvoice']) as string | undefined,
telephone: address?.['Phone1'] as string | undefined,
name: nonEmptyString(address?.['YourReference']),
// EmailInvoice is the delivery address. Email is the general contact
// fallback and must not override an invoice-specific address.
email: nonEmptyString(address?.['EmailInvoice']) ?? nonEmptyString(address?.['Email']),
telephone: nonEmptyString(address?.['Phone1']),
},
};
}
@@ -188,6 +218,8 @@ export function mapFortnoxToCustomer(raw: Record<string, unknown>): CustomerDto
customerNumber: String(raw['CustomerNumber'] ?? ''),
type: raw['Type'] === 'PRIVATE' ? 'private' : 'company',
party: buildParty(name, orgNumber, raw),
invoiceEmailCcAddresses: providerEmailAddresses(raw, 'EmailInvoiceCC'),
invoiceEmailBccAddresses: providerEmailAddresses(raw, 'EmailInvoiceBCC'),
active: raw['Active'] !== false,
vatNumber: raw['VATNumber'] as string | undefined,
defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined,