From a340e5d9e7abd30ebc72bb95970d93aab4ddcec9 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Mon, 15 Sep 2025 21:11:42 +0200 Subject: [PATCH] refactor: Replace conditional fields with customer info extractor callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- dev/payload.config.ts | 20 +++++++- src/collections/invoices.ts | 91 +++++++++++++++++++++++++++++++------ src/index.ts | 24 +++++++++- src/types/index.ts | 20 ++++++++ 4 files changed, 136 insertions(+), 19 deletions(-) diff --git a/dev/payload.config.ts b/dev/payload.config.ts index 1626c98..d18ca44 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -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', diff --git a/src/collections/invoices.ts b/src/collections/invoices.ts index 8f57fc9..9d9cc26 100644 --- a/src/collections/invoices.ts +++ b/src/collections/invoices.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' -import type { +import type { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, @@ -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) => { + async ({ data, operation, req, originalDoc }: CollectionBeforeChangeHook) => { + // 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) => { 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') } diff --git a/src/index.ts b/src/index.ts index a1deff2..949a0e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'), ) diff --git a/src/types/index.ts b/src/types/index.ts index 64b22a1..55f264d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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