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,
+110 -16
View File
@@ -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<typeof schema>
@@ -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({
</div>
{/* Contact */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Label htmlFor="contact_person">{t('contact_person_label')}</Label>
<Input
id="email"
type="email"
placeholder={t('email_placeholder')}
{...register('email')}
id="contact_person"
placeholder={t('contact_person_placeholder')}
{...register('contact_person')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
{errors.contact_person && (
<p className="text-sm text-destructive">{errors.contact_person.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
placeholder={t('phone_placeholder')}
{...register('phone')}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
type="email"
placeholder={t('email_placeholder')}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
placeholder={t('phone_placeholder')}
{...register('phone')}
/>
</div>
</div>
</div>
{/* Customer-specific invoice recipients */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t('invoice_email_section')}</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="invoice_email_cc_addresses">{t('invoice_email_cc_label')}</Label>
<Textarea
id="invoice_email_cc_addresses"
rows={3}
placeholder={t('invoice_email_placeholder')}
{...register('invoice_email_cc_addresses')}
/>
{errors.invoice_email_cc_addresses && (
<p className="text-sm text-destructive">{errors.invoice_email_cc_addresses.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="invoice_email_bcc_addresses">{t('invoice_email_bcc_label')}</Label>
<Textarea
id="invoice_email_bcc_addresses"
rows={3}
placeholder={t('invoice_email_placeholder')}
{...register('invoice_email_bcc_addresses')}
/>
{errors.invoice_email_bcc_addresses && (
<p className="text-sm text-destructive">{errors.invoice_email_bcc_addresses.message}</p>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">{t('invoice_email_hint')}</p>
</div>
{/* Address */}
<div className="space-y-4">
<h3 className="font-medium">{t('address_section')}</h3>
@@ -89,7 +89,7 @@ interface SkipReasons {
interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
@@ -1497,7 +1497,7 @@ function ResultStep({
// Check if anything meaningful was imported via entities
// Company info is always re-fetched (upsert) so it doesn't count as "new"
const entityImported = results && (
(results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)) ||
(results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 0 || results.customers.skipped > 0)) ||
(results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) ||
(results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) ||
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0))
@@ -1559,7 +1559,7 @@ function ResultStep({
{/* ── API import results (company info, customers, etc.) ── */}
{results && (() => {
const hasCompanyInfo = results.companyInfo?.imported
const hasCustomers = results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)
const hasCustomers = results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 0 || results.customers.skipped > 0)
const hasSuppliers = results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)
const hasSalesInvoices = results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)
const hasSupplierInvoices = results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)
@@ -1587,7 +1587,9 @@ function ResultStep({
icon={<Users className="h-4 w-4" />}
label="Kunder"
status="success"
statusText={`${results.customers!.imported} importerade`}
statusText={results.customers!.updated
? `${results.customers!.imported} importerade, ${results.customers!.updated} kompletterade`
: `${results.customers!.imported} importerade`}
detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer') ?? `${results.customers!.skipped} hoppades över` : undefined}
/>
)}
+12 -7
View File
@@ -178,11 +178,7 @@ export default function SendInvoiceDialog({
settingsResult.data?.invoice_email_cc_addresses
?? (legacyCc ? [legacyCc] : []),
)
setFixedBcc(
canCustomizeRecipients
? settingsResult.data?.invoice_email_bcc_addresses ?? []
: [],
)
setFixedBcc(settingsResult.data?.invoice_email_bcc_addresses ?? [])
setPeriodName(periodResult.data?.name || '')
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
setShouldBookOnIssue(bookOnIssue)
@@ -235,10 +231,19 @@ export default function SendInvoiceDialog({
)
const invalidAdditionalRecipient = [...additionalCc, ...additionalBcc]
.find((address) => !EMAIL_PATTERN.test(address))
const fixedRecipients = resolveInvoiceEmailRecipients({
to: invoice.customer.email ?? '',
configuredCc: fixedCc,
configuredBcc: fixedBcc,
customerCc: invoice.customer.invoice_email_cc_addresses,
customerBcc: invoice.customer.invoice_email_bcc_addresses,
})
const resolvedRecipients = resolveInvoiceEmailRecipients({
to: invoice.customer.email ?? '',
configuredCc: fixedCc,
configuredBcc: fixedBcc,
customerCc: invoice.customer.invoice_email_cc_addresses,
customerBcc: invoice.customer.invoice_email_bcc_addresses,
additionalCc,
additionalBcc,
})
@@ -494,12 +499,12 @@ export default function SendInvoiceDialog({
</p>
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{t('recipient_fixed_cc_label')}:</span>{' '}
{fixedCc.length > 0 ? fixedCc.join(', ') : t('recipient_none')}
{fixedRecipients.cc.length > 0 ? fixedRecipients.cc.join(', ') : t('recipient_none')}
</p>
{canCustomizeRecipients && (
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{t('recipient_fixed_bcc_label')}:</span>{' '}
{fixedBcc.length > 0 ? fixedBcc.join(', ') : t('recipient_none')}
{fixedRecipients.bcc.length > 0 ? fixedRecipients.bcc.join(', ') : t('recipient_none')}
</p>
)}
</div>
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { buildCustomerMetadataEnrichment } from '../customer-metadata'
describe('buildCustomerMetadataEnrichment', () => {
const mapped = {
contact_person: 'Anna Andersson',
invoice_email_cc_addresses: ['finance@example.test'],
invoice_email_bcc_addresses: ['archive@example.test'],
}
it('fills metadata that was never configured', () => {
expect(buildCustomerMetadataEnrichment({
contact_person: null,
invoice_email_cc_addresses: null,
invoice_email_bcc_addresses: null,
}, mapped)).toEqual(mapped)
})
it('does not overwrite existing values or explicit clears', () => {
expect(buildCustomerMetadataEnrichment({
contact_person: '',
invoice_email_cc_addresses: [],
invoice_email_bcc_addresses: ['manual@example.test'],
}, mapped)).toBeNull()
})
it('does not turn unknown provider metadata into explicit empty values', () => {
expect(buildCustomerMetadataEnrichment({
contact_person: null,
invoice_email_cc_addresses: null,
invoice_email_bcc_addresses: null,
}, {
contact_person: null,
invoice_email_cc_addresses: [],
invoice_email_bcc_addresses: null,
})).toBeNull()
})
})
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import type { CustomerDto } from '@/lib/providers/dto'
import { mapCustomer } from '../entity-mapper'
describe('Arcim customer metadata mapping', () => {
it('maps contact person and customer-specific invoice recipients', () => {
const dto: CustomerDto = {
id: 'customer-1',
customerNumber: '1001',
type: 'company',
party: {
name: 'Kund AB',
identifications: [{ id: '556677-8899', schemeId: 'SE:ORGNR' }],
contact: {
name: 'Anna Andersson',
email: 'invoice@example.test',
},
},
invoiceEmailCcAddresses: ['finance@example.test'],
invoiceEmailBccAddresses: ['archive@example.test'],
active: true,
}
expect(mapCustomer(dto, 'user-1', 'company-1')).toMatchObject({
contact_person: 'Anna Andersson',
email: 'invoice@example.test',
invoice_email_cc_addresses: ['finance@example.test'],
invoice_email_bcc_addresses: ['archive@example.test'],
})
})
it('uses null for provider fields that were not supplied', () => {
const dto: CustomerDto = {
id: 'customer-2',
customerNumber: '1002',
party: { name: 'Kund Två AB', identifications: [] },
active: true,
}
expect(mapCustomer(dto, 'user-1', 'company-1')).toMatchObject({
contact_person: null,
invoice_email_cc_addresses: null,
invoice_email_bcc_addresses: null,
})
})
})
@@ -0,0 +1,59 @@
export interface ExistingCustomerMetadata {
contact_person: string | null
invoice_email_cc_addresses: string[] | null
invoice_email_bcc_addresses: string[] | null
}
/**
* Keys restricted to the three metadata columns so callers can spell the
* update payload as an object literal (absent keys stay undefined and are
* dropped at serialization, leaving those columns untouched).
*/
export interface CustomerMetadataEnrichment {
contact_person?: string
invoice_email_cc_addresses?: string[]
invoice_email_bcc_addresses?: string[]
}
/**
* Build a provider-migration enrichment without overwriting user choices.
*
* NULL is the only "never configured" value. Empty strings/arrays are explicit
* clears, so a later migration rerun leaves them alone. The mapped row must
* also contain real metadata: converting NULL to an empty value is not useful.
*/
export function buildCustomerMetadataEnrichment(
existing: ExistingCustomerMetadata,
mapped: Record<string, unknown>,
): CustomerMetadataEnrichment | null {
const changes: CustomerMetadataEnrichment = {}
const contactPerson = mapped.contact_person
const cc = mapped.invoice_email_cc_addresses
const bcc = mapped.invoice_email_bcc_addresses
if (
existing.contact_person === null
&& typeof contactPerson === 'string'
&& contactPerson.trim().length > 0
) {
changes.contact_person = contactPerson
}
if (
existing.invoice_email_cc_addresses === null
&& Array.isArray(cc)
&& cc.length > 0
&& cc.every((x): x is string => typeof x === 'string')
) {
changes.invoice_email_cc_addresses = cc
}
if (
existing.invoice_email_bcc_addresses === null
&& Array.isArray(bcc)
&& bcc.length > 0
&& bcc.every((x): x is string => typeof x === 'string')
) {
changes.invoice_email_bcc_addresses = bcc
}
return Object.keys(changes).length > 0 ? changes : null
}
@@ -440,8 +440,11 @@ export function mapCustomer(dto: CustomerDto, userId: string, companyId: string)
company_id: companyId,
name: dto.party.name,
customer_type: customerType,
contact_person: dto.party.contact?.name || null,
email: dto.party.contact?.email || null,
phone: dto.party.contact?.telephone || null,
invoice_email_cc_addresses: dto.invoiceEmailCcAddresses ?? null,
invoice_email_bcc_addresses: dto.invoiceEmailBccAddresses ?? null,
...addr,
org_number: isIndividual ? null : number,
personal_number: isIndividual ? encryptCustomerPersonalNumber(number) : null,
@@ -33,6 +33,11 @@ import {
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { createLogger } from '@/lib/logger'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import {
buildCustomerMetadataEnrichment,
type CustomerMetadataEnrichment,
type ExistingCustomerMetadata,
} from './customer-metadata'
import {
mapCustomer,
mapSupplier,
@@ -66,6 +71,7 @@ export interface MigrationOptions {
* PostgREST's practical size limit while minimising round-trips.
*/
const INSERT_CHUNK_SIZE = 500
const ENRICHMENT_CONCURRENCY = 10
function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
options.onProgress?.(progress)
@@ -188,20 +194,27 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const customers = await fetchCustomersDirect(provider, accessToken, providerCompanyId)
// One bulk read instead of N `.eq('org_number', ...)` lookups.
const existingCustomers = await fetchAllRows<{ id: string; org_number: string | null; name: string | null }>(
type ExistingCustomer = ExistingCustomerMetadata & {
id: string
org_number: string | null
name: string | null
}
const existingCustomers = await fetchAllRows<ExistingCustomer>(
({ from, to }) =>
supabase
.from('customers')
.select('id, org_number, name')
.select('id, org_number, name, contact_person, invoice_email_cc_addresses, invoice_email_bcc_addresses')
.eq('company_id', companyId)
.range(from, to)
)
const existingCustomerById = new Map(existingCustomers.map((row) => [row.id, row]))
for (const row of existingCustomers) {
if (row.org_number) orgNumberToCustomerId.set(row.org_number, row.id)
if (row.name) nameToCustomerId.set(row.name, row.id)
}
let imported = 0
let updated = 0
let skipped = 0
const skipReasons: SkipReasons = {}
@@ -210,6 +223,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
row: Record<string, unknown>
}
const pending: PendingCustomer[] = []
const pendingEnrichments: { id: string; changes: CustomerMetadataEnrichment }[] = []
for (const customer of customers) {
if (!customer.active) {
@@ -230,8 +244,17 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
: undefined
if (existingCustomerId) {
customerIdMap.set(customer.id, existingCustomerId)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
const existingCustomer = existingCustomerById.get(existingCustomerId)
const mapped = mapCustomer(customer, userId, companyId)
const changes = existingCustomer
? buildCustomerMetadataEnrichment(existingCustomer, mapped)
: null
if (changes) {
pendingEnrichments.push({ id: existingCustomerId, changes })
} else {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
}
continue
}
@@ -265,7 +288,41 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.customers = { total: customers.length, imported, skipped, skipReasons }
// A rerun can match hundreds of legacy customers. Update only rows
// that actually have new provider metadata, with bounded concurrency,
// so enrichment neither overwrites edits nor serializes the migration.
for (const batch of chunk(pendingEnrichments, ENRICHMENT_CONCURRENCY)) {
const outcomes = await Promise.all(batch.map(async ({ id, changes }) => {
const { data, error } = await supabase
.from('customers')
// Object literal, not the record itself: absent keys serialize
// away, and the phantom-column guard can resolve the columns.
.update({
contact_person: changes.contact_person,
invoice_email_cc_addresses: changes.invoice_email_cc_addresses,
invoice_email_bcc_addresses: changes.invoice_email_bcc_addresses,
})
.eq('id', id)
.eq('company_id', companyId)
.select('id')
.maybeSingle()
return { data, error }
}))
for (const outcome of outcomes) {
if (outcome.error || !outcome.data) {
if (outcome.error) {
console.error('[migration] Customer metadata enrichment failed:', outcome.error.message)
}
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
} else {
updated++
}
}
}
results.customers = { total: customers.length, imported, updated, skipped, skipReasons }
} catch (err) {
console.error('Failed to import customers:', err)
}
+1 -1
View File
@@ -74,7 +74,7 @@ export interface SkipReasons {
*/
export interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number }
+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,
+10
View File
@@ -1080,11 +1080,21 @@
"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",
"contact_person_label": "Contact person",
"contact_person_placeholder": "E.g. Anna Andersson",
"contact_person_too_long": "The contact person must be 200 characters or fewer",
"email_label": "Email",
"email_placeholder": "name@company.com",
"email_invalid": "Invalid email address",
"phone_label": "Phone",
"phone_placeholder": "+46 70 123 45 67",
"invoice_email_section": "Additional invoice recipients",
"invoice_email_cc_label": "Copy (CC)",
"invoice_email_bcc_label": "Blind copy (BCC)",
"invoice_email_placeholder": "One email address per line",
"invoice_email_hint": "These recipients are added automatically when an invoice is sent to the customer.",
"invoice_email_invalid": "Invalid email address: {address}",
"invoice_email_too_many": "At most {count} additional recipients are allowed in total",
"address_section": "Address",
"street_label": "Street address",
"street_placeholder": "Storgatan 1",
+10
View File
@@ -1080,11 +1080,21 @@
"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",
"contact_person_label": "Kontaktperson",
"contact_person_placeholder": "T.ex. Anna Andersson",
"contact_person_too_long": "Kontaktpersonen får vara högst 200 tecken",
"email_label": "E-post",
"email_placeholder": "namn@foretag.se",
"email_invalid": "Ogiltig e-postadress",
"phone_label": "Telefon",
"phone_placeholder": "+46 70 123 45 67",
"invoice_email_section": "Extra mottagare av fakturor",
"invoice_email_cc_label": "Kopia (CC)",
"invoice_email_bcc_label": "Dold kopia (BCC)",
"invoice_email_placeholder": "En e-postadress per rad",
"invoice_email_hint": "Dessa mottagare läggs automatiskt till när en faktura skickas till kunden.",
"invoice_email_invalid": "Ogiltig e-postadress: {address}",
"invoice_email_too_many": "Högst {count} extra mottagare är tillåtna totalt",
"address_section": "Adress",
"street_label": "Gatuadress",
"street_placeholder": "Storgatan 1",
@@ -0,0 +1,26 @@
-- Customer-level invoice delivery metadata carried by provider migrations.
-- NULL means the provider/user has not configured the field; an empty array
-- is an explicit "no copy recipients" choice and must survive re-syncs.
ALTER TABLE public.customers
ADD COLUMN contact_person text,
ADD COLUMN invoice_email_cc_addresses text[],
ADD COLUMN invoice_email_bcc_addresses text[];
ALTER TABLE public.customers
ADD CONSTRAINT customers_contact_person_length_check
CHECK (contact_person IS NULL OR char_length(contact_person) <= 200),
ADD CONSTRAINT customers_invoice_email_copy_recipient_limit_check
CHECK (
cardinality(COALESCE(invoice_email_cc_addresses, '{}'::text[]))
+ cardinality(COALESCE(invoice_email_bcc_addresses, '{}'::text[]))
<= 19
);
COMMENT ON COLUMN public.customers.contact_person IS
'Customer contact/reference person used by provider migrations and invoicing.';
COMMENT ON COLUMN public.customers.invoice_email_cc_addresses IS
'Customer-specific invoice CC recipients. NULL means unconfigured; empty means explicitly none.';
COMMENT ON COLUMN public.customers.invoice_email_bcc_addresses IS
'Customer-specific invoice BCC recipients. NULL means unconfigured; empty means explicitly none.';
NOTIFY pgrst, 'reload schema';
+3
View File
@@ -417,6 +417,9 @@ export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
vat_number_validated: true,
vat_number_validated_at: '2024-01-01T00:00:00Z',
personal_number: null,
contact_person: null,
invoice_email_cc_addresses: null,
invoice_email_bcc_addresses: null,
language: 'sv',
default_payment_terms: 30,
notes: null,
+6
View File
@@ -650,8 +650,11 @@ export interface Customer {
customer_number: string | null
// Contact
contact_person: string | null
email: string | null
phone: string | null
invoice_email_cc_addresses: string[] | null
invoice_email_bcc_addresses: string[] | null
// Address
address_line1: string | null
@@ -1318,8 +1321,11 @@ export interface CreateCustomerInput {
name: string
customer_type: CustomerType
customer_number?: string | null
contact_person?: string | null
email?: string
phone?: string
invoice_email_cc_addresses?: string[] | null
invoice_email_bcc_addresses?: string[] | null
address_line1?: string
address_line2?: string
postal_code?: string