refactor: Replace conditional fields with customer info extractor callback

- Add CustomerInfoExtractor callback type for flexible customer data extraction
- Implement automatic customer info sync via beforeChange hook
- Make customer info fields read-only when using extractor
- Add defaultCustomerInfoExtractor for built-in customer collection
- Update validation to require customer selection when using extractor
- Keep customer info in sync when relationship changes

Breaking change: Plugin users must now provide customerInfoExtractor callback
to enable customer relationship syncing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-15 21:11:42 +02:00
parent 7fb45570a7
commit a340e5d9e7
4 changed files with 136 additions and 19 deletions

View File

@@ -2,7 +2,7 @@ import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { billingPlugin } from '../dist/index.js'
import { billingPlugin, defaultCustomerInfoExtractor } from '../dist/index.js'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
@@ -61,7 +61,23 @@ const buildConfigWithSQLite = () => {
refunds: 'refunds',
// customerRelation: false, // Set to false to disable customer relationship in invoices
// customerRelation: 'clients', // Or set to a custom collection slug
}
},
// Use the default extractor for the built-in customer collection
customerInfoExtractor: defaultCustomerInfoExtractor,
// Or provide a custom extractor for your own customer collection structure:
// customerInfoExtractor: (customer) => ({
// name: customer.fullName,
// email: customer.contactEmail,
// phone: customer.phoneNumber,
// company: customer.companyName,
// taxId: customer.vatNumber,
// billingAddress: {
// line1: customer.billing.street,
// city: customer.billing.city,
// postalCode: customer.billing.zip,
// country: customer.billing.countryCode,
// }
// })
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

View File

@@ -9,10 +9,12 @@ import type {
InvoiceDocument,
InvoiceItemData
} from '../types/payload'
import type { CustomerInfoExtractor } from '../types'
export function createInvoicesCollection(
slug: string = 'invoices',
customerCollectionSlug?: string
customerCollectionSlug?: string,
customerInfoExtractor?: CustomerInfoExtractor
): CollectionConfig {
return {
slug,
@@ -54,8 +56,10 @@ export function createInvoicesCollection(
name: 'customerInfo',
type: 'group',
admin: {
description: 'Customer billing information',
condition: customerCollectionSlug ? (data: InvoiceData) => !data.customer : undefined,
description: customerCollectionSlug && customerInfoExtractor
? 'Customer billing information (auto-populated from customer relationship)'
: 'Customer billing information',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
fields: [
{
@@ -63,22 +67,25 @@ export function createInvoicesCollection(
type: 'text',
admin: {
description: 'Customer name',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug,
required: true,
},
{
name: 'email',
type: 'email',
admin: {
description: 'Customer email address',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug,
required: true,
},
{
name: 'phone',
type: 'text',
admin: {
description: 'Customer phone number',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
@@ -86,6 +93,7 @@ export function createInvoicesCollection(
type: 'text',
admin: {
description: 'Company name (optional)',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
@@ -93,6 +101,7 @@ export function createInvoicesCollection(
type: 'text',
admin: {
description: 'Tax ID or VAT number',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
],
@@ -101,8 +110,10 @@ export function createInvoicesCollection(
name: 'billingAddress',
type: 'group',
admin: {
description: 'Billing address',
condition: customerCollectionSlug ? (data: InvoiceData) => !data.customer : undefined,
description: customerCollectionSlug && customerInfoExtractor
? 'Billing address (auto-populated from customer relationship)'
: 'Billing address',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
fields: [
{
@@ -110,26 +121,32 @@ export function createInvoicesCollection(
type: 'text',
admin: {
description: 'Address line 1',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug,
required: true,
},
{
name: 'line2',
type: 'text',
admin: {
description: 'Address line 2',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
name: 'city',
type: 'text',
required: !customerCollectionSlug,
admin: {
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: true,
},
{
name: 'state',
type: 'text',
admin: {
description: 'State or province',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
@@ -137,17 +154,19 @@ export function createInvoicesCollection(
type: 'text',
admin: {
description: 'Postal or ZIP code',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug,
required: true,
},
{
name: 'country',
type: 'text',
admin: {
description: 'Country code (e.g., US, GB)',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
maxLength: 2,
required: !customerCollectionSlug,
required: true,
},
],
},
@@ -299,7 +318,43 @@ export function createInvoicesCollection(
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<InvoiceData>) => {
async ({ data, operation, req, originalDoc }: CollectionBeforeChangeHook<InvoiceData>) => {
// Sync customer info from relationship if extractor is provided
if (customerCollectionSlug && customerInfoExtractor && data.customer) {
// Check if customer changed or this is a new invoice
const customerChanged = operation === 'create' ||
(originalDoc && originalDoc.customer !== data.customer)
if (customerChanged) {
try {
// Fetch the customer data
const customer = await req.payload.findByID({
collection: customerCollectionSlug,
id: data.customer,
})
// Extract customer info using the provided callback
const extractedInfo = customerInfoExtractor(customer)
// Update the invoice data with extracted info
data.customerInfo = {
name: extractedInfo.name,
email: extractedInfo.email,
phone: extractedInfo.phone,
company: extractedInfo.company,
taxId: extractedInfo.taxId,
}
if (extractedInfo.billingAddress) {
data.billingAddress = extractedInfo.billingAddress
}
} catch (error) {
req.payload.logger.error(`Failed to extract customer info: ${error}`)
throw new Error('Failed to extract customer information')
}
}
}
if (operation === 'create') {
// Generate invoice number if not provided
if (!data.number) {
@@ -333,8 +388,14 @@ export function createInvoicesCollection(
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
if (!data) return
// Validate customer data: either relationship or embedded info must be provided
if (customerCollectionSlug && !data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) {
// If using extractor, customer relationship is required
if (customerCollectionSlug && customerInfoExtractor && !data.customer) {
throw new Error('Please select a customer')
}
// If not using extractor but have customer collection, either relationship or info is required
if (customerCollectionSlug && !customerInfoExtractor &&
!data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) {
throw new Error('Either select a customer or provide customer information')
}

View File

@@ -1,6 +1,6 @@
import type { Config } from 'payload'
import type { BillingPluginConfig } from './types'
import type { BillingPluginConfig, CustomerInfoExtractor } from './types'
import { createCustomersCollection } from './collections/customers'
import { createInvoicesCollection } from './collections/invoices'
@@ -9,6 +9,25 @@ import { createRefundsCollection } from './collections/refunds'
export * from './types'
// Default customer info extractor for the built-in customer collection
export const defaultCustomerInfoExtractor: CustomerInfoExtractor = (customer) => {
return {
name: customer.name || '',
email: customer.email || '',
phone: customer.phone,
company: customer.company,
taxId: customer.taxId,
billingAddress: customer.address ? {
line1: customer.address.line1 || '',
line2: customer.address.line2,
city: customer.address.city || '',
state: customer.address.state,
postalCode: customer.address.postalCode || '',
country: customer.address.country || '',
} : undefined,
}
}
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) {
return config
@@ -26,7 +45,8 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
createCustomersCollection(customerSlug),
createInvoicesCollection(
pluginConfig.collections?.invoices || 'invoices',
pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined
pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined,
pluginConfig.customerInfoExtractor
),
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
)

View File

@@ -93,6 +93,25 @@ export interface TestProviderConfig {
simulateFailures?: boolean
}
// Customer info extractor callback type
export interface CustomerInfoExtractor {
(customer: any): {
name: string
email: string
phone?: string
company?: string
taxId?: string
billingAddress?: {
line1: string
line2?: string
city: string
state?: string
postalCode: string
country: string
}
}
}
// Plugin configuration
export interface BillingPluginConfig {
admin?: {
@@ -106,6 +125,7 @@ export interface BillingPluginConfig {
payments?: string
refunds?: string
}
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
disabled?: boolean
providers?: {
mollie?: MollieConfig