mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
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:
@@ -2,7 +2,7 @@ import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
|||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import { billingPlugin } from '../dist/index.js'
|
import { billingPlugin, defaultCustomerInfoExtractor } from '../dist/index.js'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@@ -61,7 +61,23 @@ const buildConfigWithSQLite = () => {
|
|||||||
refunds: 'refunds',
|
refunds: 'refunds',
|
||||||
// customerRelation: false, // Set to false to disable customer relationship in invoices
|
// customerRelation: false, // Set to false to disable customer relationship in invoices
|
||||||
// customerRelation: 'clients', // Or set to a custom collection slug
|
// 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',
|
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import type {
|
|||||||
InvoiceDocument,
|
InvoiceDocument,
|
||||||
InvoiceItemData
|
InvoiceItemData
|
||||||
} from '../types/payload'
|
} from '../types/payload'
|
||||||
|
import type { CustomerInfoExtractor } from '../types'
|
||||||
|
|
||||||
export function createInvoicesCollection(
|
export function createInvoicesCollection(
|
||||||
slug: string = 'invoices',
|
slug: string = 'invoices',
|
||||||
customerCollectionSlug?: string
|
customerCollectionSlug?: string,
|
||||||
|
customerInfoExtractor?: CustomerInfoExtractor
|
||||||
): CollectionConfig {
|
): CollectionConfig {
|
||||||
return {
|
return {
|
||||||
slug,
|
slug,
|
||||||
@@ -54,8 +56,10 @@ export function createInvoicesCollection(
|
|||||||
name: 'customerInfo',
|
name: 'customerInfo',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Customer billing information',
|
description: customerCollectionSlug && customerInfoExtractor
|
||||||
condition: customerCollectionSlug ? (data: InvoiceData) => !data.customer : undefined,
|
? 'Customer billing information (auto-populated from customer relationship)'
|
||||||
|
: 'Customer billing information',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -63,22 +67,25 @@ export function createInvoicesCollection(
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Customer name',
|
description: 'Customer name',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
required: !customerCollectionSlug,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'email',
|
name: 'email',
|
||||||
type: 'email',
|
type: 'email',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Customer email address',
|
description: 'Customer email address',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
required: !customerCollectionSlug,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'phone',
|
name: 'phone',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Customer phone number',
|
description: 'Customer phone number',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,6 +93,7 @@ export function createInvoicesCollection(
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Company name (optional)',
|
description: 'Company name (optional)',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -93,6 +101,7 @@ export function createInvoicesCollection(
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Tax ID or VAT number',
|
description: 'Tax ID or VAT number',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -101,8 +110,10 @@ export function createInvoicesCollection(
|
|||||||
name: 'billingAddress',
|
name: 'billingAddress',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Billing address',
|
description: customerCollectionSlug && customerInfoExtractor
|
||||||
condition: customerCollectionSlug ? (data: InvoiceData) => !data.customer : undefined,
|
? 'Billing address (auto-populated from customer relationship)'
|
||||||
|
: 'Billing address',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -110,26 +121,32 @@ export function createInvoicesCollection(
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Address line 1',
|
description: 'Address line 1',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
required: !customerCollectionSlug,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'line2',
|
name: 'line2',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Address line 2',
|
description: 'Address line 2',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'city',
|
name: 'city',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: !customerCollectionSlug,
|
admin: {
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'state',
|
name: 'state',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'State or province',
|
description: 'State or province',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -137,17 +154,19 @@ export function createInvoicesCollection(
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Postal or ZIP code',
|
description: 'Postal or ZIP code',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
required: !customerCollectionSlug,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'country',
|
name: 'country',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Country code (e.g., US, GB)',
|
description: 'Country code (e.g., US, GB)',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
},
|
},
|
||||||
maxLength: 2,
|
maxLength: 2,
|
||||||
required: !customerCollectionSlug,
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -299,7 +318,43 @@ export function createInvoicesCollection(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
beforeChange: [
|
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') {
|
if (operation === 'create') {
|
||||||
// Generate invoice number if not provided
|
// Generate invoice number if not provided
|
||||||
if (!data.number) {
|
if (!data.number) {
|
||||||
@@ -333,8 +388,14 @@ export function createInvoicesCollection(
|
|||||||
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
|
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
|
||||||
// Validate customer data: either relationship or embedded info must be provided
|
// If using extractor, customer relationship is required
|
||||||
if (customerCollectionSlug && !data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) {
|
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')
|
throw new Error('Either select a customer or provide customer information')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
src/index.ts
24
src/index.ts
@@ -1,6 +1,6 @@
|
|||||||
import type { Config } from 'payload'
|
import type { Config } from 'payload'
|
||||||
|
|
||||||
import type { BillingPluginConfig } from './types'
|
import type { BillingPluginConfig, CustomerInfoExtractor } from './types'
|
||||||
|
|
||||||
import { createCustomersCollection } from './collections/customers'
|
import { createCustomersCollection } from './collections/customers'
|
||||||
import { createInvoicesCollection } from './collections/invoices'
|
import { createInvoicesCollection } from './collections/invoices'
|
||||||
@@ -9,6 +9,25 @@ import { createRefundsCollection } from './collections/refunds'
|
|||||||
|
|
||||||
export * from './types'
|
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 => {
|
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
||||||
if (pluginConfig.disabled) {
|
if (pluginConfig.disabled) {
|
||||||
return config
|
return config
|
||||||
@@ -26,7 +45,8 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
createCustomersCollection(customerSlug),
|
createCustomersCollection(customerSlug),
|
||||||
createInvoicesCollection(
|
createInvoicesCollection(
|
||||||
pluginConfig.collections?.invoices || 'invoices',
|
pluginConfig.collections?.invoices || 'invoices',
|
||||||
pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined
|
pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined,
|
||||||
|
pluginConfig.customerInfoExtractor
|
||||||
),
|
),
|
||||||
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
|
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,6 +93,25 @@ export interface TestProviderConfig {
|
|||||||
simulateFailures?: boolean
|
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
|
// Plugin configuration
|
||||||
export interface BillingPluginConfig {
|
export interface BillingPluginConfig {
|
||||||
admin?: {
|
admin?: {
|
||||||
@@ -106,6 +125,7 @@ export interface BillingPluginConfig {
|
|||||||
payments?: string
|
payments?: string
|
||||||
refunds?: string
|
refunds?: string
|
||||||
}
|
}
|
||||||
|
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
providers?: {
|
providers?: {
|
||||||
mollie?: MollieConfig
|
mollie?: MollieConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user