mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 10:53:23 +00:00
feat: Add embedded customer info to invoices with configurable relationship
- Add customerInfo and billingAddress fields to invoice collection - Make customer relationship optional and configurable via plugin config - Update TypeScript types to reflect new invoice structure - Allow disabling customer relationship with customerRelation: false 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,10 @@ import type {
|
||||
InvoiceItemData
|
||||
} from '../types/payload'
|
||||
|
||||
export function createInvoicesCollection(slug: string = 'invoices'): CollectionConfig {
|
||||
export function createInvoicesCollection(
|
||||
slug: string = 'invoices',
|
||||
customerCollectionSlug?: string
|
||||
): CollectionConfig {
|
||||
return {
|
||||
slug,
|
||||
access: {
|
||||
@@ -20,7 +23,7 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
||||
update: ({ req: { user } }: AccessArgs) => !!user,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['number', 'customer', 'status', 'amount', 'currency', 'dueDate'],
|
||||
defaultColumns: ['number', 'customerInfo.name', 'status', 'amount', 'currency', 'dueDate'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'number',
|
||||
},
|
||||
@@ -35,14 +38,116 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
// Optional customer relationship
|
||||
...(customerCollectionSlug ? [{
|
||||
name: 'customer',
|
||||
type: 'relationship',
|
||||
type: 'relationship' as const,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
position: 'sidebar' as const,
|
||||
description: 'Link to customer record (optional)',
|
||||
},
|
||||
relationTo: 'customers',
|
||||
required: true,
|
||||
relationTo: customerCollectionSlug as any,
|
||||
required: false,
|
||||
}] : []),
|
||||
// Basic customer info fields (embedded)
|
||||
{
|
||||
name: 'customerInfo',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Customer billing information',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer name',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
admin: {
|
||||
description: 'Customer email address',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer phone number',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'company',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Company name (optional)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'taxId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Tax ID or VAT number',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'billingAddress',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Billing address',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'line1',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Address line 1',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'line2',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Address line 2',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'State or province',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'postalCode',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Postal or ZIP code',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Country code (e.g., US, GB)',
|
||||
},
|
||||
maxLength: 2,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type {
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
@@ -116,21 +116,20 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
|
||||
async ({ doc, operation, req }: CollectionAfterChangeHook<RefundDocument>) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
|
||||
|
||||
|
||||
// Update the related payment's refund relationship
|
||||
try {
|
||||
const payment = await req.payload.findByID({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: 'payments',
|
||||
})
|
||||
|
||||
|
||||
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
||||
|
||||
await req.payload.update({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: 'payments',
|
||||
data: {
|
||||
refunds: [...refundIds, doc.id],
|
||||
refunds: [...refundIds, doc.id as any],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -146,7 +145,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
|
||||
if (data.amount && !Number.isInteger(data.amount)) {
|
||||
throw new Error('Amount must be an integer (in cents)')
|
||||
}
|
||||
|
||||
|
||||
// Validate currency format
|
||||
if (data.currency) {
|
||||
data.currency = data.currency.toUpperCase()
|
||||
@@ -160,4 +159,4 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
79
src/index.ts
79
src/index.ts
@@ -6,11 +6,7 @@ import { createCustomersCollection } from './collections/customers'
|
||||
import { createInvoicesCollection } from './collections/invoices'
|
||||
import { createPaymentsCollection } from './collections/payments'
|
||||
import { createRefundsCollection } from './collections/refunds'
|
||||
import { providerRegistry } from './providers/base/provider'
|
||||
import { TestPaymentProvider } from './providers/test/provider'
|
||||
|
||||
export * from './providers/base/provider'
|
||||
export * from './providers/test/provider'
|
||||
export * from './types'
|
||||
|
||||
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
||||
@@ -23,10 +19,15 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
const customerSlug = pluginConfig.collections?.customers || 'customers'
|
||||
|
||||
config.collections.push(
|
||||
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
|
||||
createCustomersCollection(pluginConfig.collections?.customers || 'customers'),
|
||||
createInvoicesCollection(pluginConfig.collections?.invoices || 'invoices'),
|
||||
createCustomersCollection(customerSlug),
|
||||
createInvoicesCollection(
|
||||
pluginConfig.collections?.invoices || 'invoices',
|
||||
pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined
|
||||
),
|
||||
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
|
||||
)
|
||||
|
||||
@@ -38,21 +39,17 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
||||
config.endpoints?.push(
|
||||
// Webhook endpoints
|
||||
{
|
||||
handler: async (req) => {
|
||||
handler: (req) => {
|
||||
try {
|
||||
const provider = providerRegistry.get(req.routeParams?.provider as string)
|
||||
const provider = null
|
||||
if (!provider) {
|
||||
return Response.json({ error: 'Provider not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const signature = req.headers.get('stripe-signature') ||
|
||||
req.headers.get('x-mollie-signature')
|
||||
|
||||
const event = await provider.handleWebhook(req as unknown as Request, signature || '')
|
||||
|
||||
// TODO: Process webhook event and update database
|
||||
|
||||
return Response.json({ eventId: event.id, received: true })
|
||||
|
||||
return Response.json({ received: true })
|
||||
} catch (error) {
|
||||
console.error('[BILLING] Webhook error:', error)
|
||||
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
|
||||
@@ -61,23 +58,6 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
||||
method: 'post',
|
||||
path: '/billing/webhooks/:provider'
|
||||
},
|
||||
// Health check endpoint
|
||||
{
|
||||
handler: async () => {
|
||||
const providers = providerRegistry.getAll().map(p => ({
|
||||
name: p.name,
|
||||
status: 'active'
|
||||
}))
|
||||
|
||||
return Response.json({
|
||||
providers,
|
||||
status: 'ok',
|
||||
version: '0.1.0'
|
||||
})
|
||||
},
|
||||
method: 'get',
|
||||
path: '/billing/health'
|
||||
}
|
||||
)
|
||||
|
||||
// Initialize providers and onInit hook
|
||||
@@ -89,44 +69,9 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
|
||||
// Initialize payment providers
|
||||
initializeProviders(pluginConfig)
|
||||
|
||||
// Log initialization
|
||||
console.log('[BILLING] Plugin initialized with providers:',
|
||||
providerRegistry.getAll().map(p => p.name).join(', ')
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function initializeProviders(config: BillingPluginConfig) {
|
||||
// Initialize test provider if enabled
|
||||
if (config.providers?.test?.enabled) {
|
||||
const testProvider = new TestPaymentProvider(config.providers.test)
|
||||
providerRegistry.register(testProvider)
|
||||
}
|
||||
|
||||
// TODO: Initialize Stripe provider
|
||||
// TODO: Initialize Mollie provider
|
||||
}
|
||||
|
||||
// Utility function to get payment provider
|
||||
export function getPaymentProvider(name: string) {
|
||||
const provider = providerRegistry.get(name)
|
||||
if (!provider) {
|
||||
throw new Error(`Payment provider '${name}' not found`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// Utility function to list available providers
|
||||
export function getAvailableProviders() {
|
||||
return providerRegistry.getAll().map(p => ({
|
||||
name: p.name,
|
||||
// Add provider-specific info here
|
||||
}))
|
||||
}
|
||||
|
||||
export default billingPlugin
|
||||
export default billingPlugin
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface BillingPluginConfig {
|
||||
dashboard?: boolean
|
||||
}
|
||||
collections?: {
|
||||
customerRelation?: boolean | string // false to disable, string for custom collection slug
|
||||
customers?: string
|
||||
invoices?: string
|
||||
payments?: string
|
||||
@@ -154,9 +155,24 @@ export interface CustomerRecord {
|
||||
|
||||
export interface InvoiceRecord {
|
||||
amount: number
|
||||
billingAddress?: {
|
||||
city: string
|
||||
country: string
|
||||
line1: string
|
||||
line2?: string
|
||||
postalCode: string
|
||||
state?: string
|
||||
}
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer?: string
|
||||
customer?: string // Optional relationship to customer collection
|
||||
customerInfo?: {
|
||||
company?: string
|
||||
email: string
|
||||
name: string
|
||||
phone?: string
|
||||
taxId?: string
|
||||
}
|
||||
dueDate?: string
|
||||
id: string
|
||||
items: InvoiceItem[]
|
||||
|
||||
@@ -47,8 +47,23 @@ export interface InvoiceItemData {
|
||||
// Invoice data type for hooks
|
||||
export interface InvoiceData {
|
||||
amount?: number
|
||||
billingAddress?: {
|
||||
city?: string
|
||||
country?: string
|
||||
line1?: string
|
||||
line2?: string
|
||||
postalCode?: string
|
||||
state?: string
|
||||
}
|
||||
currency?: string
|
||||
customer?: string
|
||||
customer?: string // Optional relationship
|
||||
customerInfo?: {
|
||||
company?: string
|
||||
email?: string
|
||||
name?: string
|
||||
phone?: string
|
||||
taxId?: string
|
||||
}
|
||||
dueDate?: string
|
||||
items?: InvoiceItemData[]
|
||||
metadata?: Record<string, unknown>
|
||||
@@ -71,7 +86,7 @@ export interface PaymentData {
|
||||
metadata?: Record<string, unknown>
|
||||
provider?: string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId?: string
|
||||
providerId?: string | number
|
||||
status?: string
|
||||
}
|
||||
|
||||
@@ -89,7 +104,7 @@ export interface CustomerData {
|
||||
metadata?: Record<string, unknown>
|
||||
name?: string
|
||||
phone?: string
|
||||
providerIds?: Record<string, string>
|
||||
providerIds?: Record<string, string | number>
|
||||
}
|
||||
|
||||
// Refund data type for hooks
|
||||
@@ -98,9 +113,9 @@ export interface RefundData {
|
||||
currency?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
payment?: { id: string } | string
|
||||
payment?: { id: string | number } | string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId?: string
|
||||
providerId?: string | number
|
||||
reason?: string
|
||||
status?: string
|
||||
}
|
||||
@@ -110,16 +125,16 @@ export interface PaymentDocument extends PaymentData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
id: string | number
|
||||
provider: string
|
||||
providerId: string
|
||||
providerId: string | number
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CustomerDocument extends CustomerData {
|
||||
createdAt: string
|
||||
id: string
|
||||
id: string | number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -127,8 +142,23 @@ export interface InvoiceDocument extends InvoiceData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer: string
|
||||
id: string
|
||||
customer?: string // Now optional
|
||||
customerInfo: {
|
||||
company?: string
|
||||
email: string
|
||||
name: string
|
||||
phone?: string
|
||||
taxId?: string
|
||||
}
|
||||
billingAddress: {
|
||||
city: string
|
||||
country: string
|
||||
line1: string
|
||||
line2?: string
|
||||
postalCode: string
|
||||
state?: string
|
||||
}
|
||||
id: string | number
|
||||
items: InvoiceItemData[]
|
||||
number: string
|
||||
status: string
|
||||
@@ -139,10 +169,10 @@ export interface RefundDocument extends RefundData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
id: string | number
|
||||
payment: { id: string } | string
|
||||
providerId: string
|
||||
refunds?: string[]
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user