Files
payload-billing/src/collections/invoices.ts
Bas van den Aakster 27da194942 feat: add automatic payment/invoice status sync and invoice view page
Core Plugin Enhancements:
- Add afterChange hook to payments collection to auto-update linked invoice status to 'paid' when payment succeeds
- Add afterChange hook to invoices collection for bidirectional payment-invoice relationship management
- Add invoice status sync when manually marked as paid
- Update plugin config types to support collection extension options

Demo Application Features:
- Add professional invoice view page with print-friendly layout (/invoice/[id])
- Add custom message field to payment creation form
- Add invoice API endpoint to fetch complete invoice data with customer info
- Add payment API endpoint to retrieve payment with invoice relationship
- Update payment success page with "View Invoice" button
- Implement beforeChange hook to copy custom message from payment metadata to invoice
- Remove customer collection dependency - use direct customerInfo fields instead

Documentation:
- Update README with automatic status synchronization section
- Add collection extension examples to demo README
- Document new features: bidirectional relationships, status sync, invoice view

Technical Improvements:
- Fix total calculation in invoice API (use 'amount' field instead of 'total')
- Add proper TypeScript types with CollectionSlug casting
- Implement Next.js 15 async params pattern in API routes
- Add customer name/email/company fields to payment creation form

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 16:20:01 +01:00

503 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
CollectionSlug,
Field,
} from 'payload'
import type { BillingPluginConfig} from '@/plugin/config';
import { defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils'
import { createContextLogger } from '@/utils/logger'
import type { Invoice } from '@/plugin/types'
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
// Get slugs for relationships - these need to be determined before building fields
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
const fields: Field[] = [
{
name: 'number',
type: 'text',
admin: {
description: 'Invoice number (e.g., INV-001)',
},
index: true,
required: true,
unique: true,
},
// Optional customer relationship
...(customerRelationSlug ? [{
name: 'customer',
type: 'relationship' as const,
admin: {
position: 'sidebar' as const,
description: 'Link to customer record (optional)',
},
relationTo: customerRelationSlug as any,
required: false,
}] : []),
// Basic customer info fields (embedded)
{
name: 'customerInfo',
type: 'group',
admin: {
description: customerRelationSlug && customerInfoExtractor
? 'Customer billing information (auto-populated from customer relationship)'
: 'Customer billing information',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
fields: [
{
name: 'name',
type: 'text',
admin: {
description: 'Customer name',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'email',
type: 'email',
admin: {
description: 'Customer email address',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'phone',
type: 'text',
admin: {
description: 'Customer phone number',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'company',
type: 'text',
admin: {
description: 'Company name (optional)',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'taxId',
type: 'text',
admin: {
description: 'Tax ID or VAT number',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
],
},
{
name: 'billingAddress',
type: 'group',
admin: {
description: customerRelationSlug && customerInfoExtractor
? 'Billing address (auto-populated from customer relationship)'
: 'Billing address',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
fields: [
{
name: 'line1',
type: 'text',
admin: {
description: 'Address line 1',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'line2',
type: 'text',
admin: {
description: 'Address line 2',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'city',
type: 'text',
admin: {
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'state',
type: 'text',
admin: {
description: 'State or province',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'postalCode',
type: 'text',
admin: {
description: 'Postal or ZIP code',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'country',
type: 'text',
admin: {
description: 'Country code (e.g., US, GB)',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
maxLength: 2,
required: !customerRelationSlug || !customerInfoExtractor,
},
],
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Open', value: 'open' },
{ label: 'Paid', value: 'paid' },
{ label: 'Void', value: 'void' },
{ label: 'Uncollectible', value: 'uncollectible' },
],
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
defaultValue: 'USD',
maxLength: 3,
required: true,
},
{
name: 'items',
type: 'array',
admin: {
// Custom row labeling can be added here when needed
},
fields: [
{
name: 'description',
type: 'text',
admin: {
width: '40%',
},
required: true,
},
{
name: 'quantity',
type: 'number',
admin: {
width: '15%',
},
defaultValue: 1,
min: 1,
required: true,
},
{
name: 'unitAmount',
type: 'number',
admin: {
description: 'Amount in cents',
width: '20%',
},
min: 0,
required: true,
},
{
name: 'totalAmount',
type: 'number',
admin: {
description: 'Calculated: quantity × unitAmount',
readOnly: true,
width: '20%',
},
},
],
minRows: 1,
required: true,
},
{
name: 'subtotal',
type: 'number',
admin: {
description: 'Sum of all line items',
readOnly: true,
},
},
{
name: 'taxAmount',
type: 'number',
admin: {
description: 'Tax amount in cents',
},
defaultValue: 0,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Total amount (subtotal + tax)',
readOnly: true,
},
},
{
name: 'dueDate',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'paidAt',
type: 'date',
admin: {
condition: (data) => data.status === 'paid',
readOnly: true,
},
},
{
name: 'payment',
type: 'relationship',
admin: {
condition: (data) => data.status === 'paid',
position: 'sidebar',
},
relationTo: paymentsSlug,
},
{
name: 'notes',
type: 'textarea',
admin: {
description: 'Internal notes',
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional invoice metadata',
},
},
]
const baseConfig: CollectionConfig = {
slug: invoicesSlug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
update: ({ req: { user } }: AccessArgs) => !!user,
},
admin: {
defaultColumns: ['number', 'customerInfo.name', 'status', 'amount', 'currency', 'dueDate'],
group: 'Billing',
useAsTitle: 'number',
},
fields,
hooks: {
afterChange: [
async ({ doc, operation, req, previousDoc }) => {
const logger = createContextLogger(req.payload, 'Invoices Collection')
if (operation === 'create') {
logger.info(`Invoice created: ${doc.number}`)
// If invoice has a linked payment, update the payment to link back to this invoice
if (doc.payment) {
try {
const paymentId = typeof doc.payment === 'object' ? doc.payment.id : doc.payment
logger.info(`Linking payment ${paymentId} back to invoice ${doc.id}`)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await req.payload.update({
collection: paymentsSlug as CollectionSlug,
id: paymentId,
data: {
invoice: doc.id,
} as any,
})
logger.info(`Payment ${paymentId} linked to invoice ${doc.id}`)
} catch (error) {
logger.error(`Failed to link payment to invoice: ${String(error)}`)
// Don't throw - invoice is already created
}
}
}
// If invoice status changes to paid, ensure linked payment is also marked as paid
const statusChanged = operation === 'update' && previousDoc && previousDoc.status !== doc.status
if (statusChanged && doc.status === 'paid' && doc.payment) {
try {
const paymentId = typeof doc.payment === 'object' ? doc.payment.id : doc.payment
// Fetch the payment to check its status
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payment = await req.payload.findByID({
collection: paymentsSlug as CollectionSlug,
id: paymentId,
}) as any
// Only update if payment is not already in a successful state
if (payment && !['paid', 'succeeded'].includes(payment.status)) {
logger.info(`Invoice ${doc.id} marked as paid, updating payment ${paymentId}`)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await req.payload.update({
collection: paymentsSlug as CollectionSlug,
id: paymentId,
data: {
status: 'succeeded',
} as any,
})
logger.info(`Payment ${paymentId} marked as succeeded`)
}
} catch (error) {
logger.error(`Failed to update payment status: ${String(error)}`)
// Don't throw - invoice update is already complete
}
}
},
] satisfies CollectionAfterChangeHook<Invoice>[],
beforeChange: [
async ({ data, operation, req, originalDoc }) => {
// Sync customer info from relationship if extractor is provided
if (customerRelationSlug && 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: customerRelationSlug as never,
id: data.customer as never,
})
// 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) {
const logger = createContextLogger(req.payload, 'Invoices Collection')
logger.error(`Failed to extract customer info: ${String(error)}`)
throw new Error('Failed to extract customer information')
}
}
}
if (operation === 'create') {
// Generate invoice number if not provided
if (!data.number) {
const timestamp = Date.now()
data.number = `INV-${timestamp}`
}
// Validate currency format
if (data.currency) {
data.currency = data.currency.toUpperCase()
if (!/^[A-Z]{3}$/.test(data.currency)) {
throw new Error('Currency must be a 3-letter ISO code')
}
}
// Set due date if not provided (30 days from now)
if (!data.dueDate) {
const dueDate = new Date()
dueDate.setDate(dueDate.getDate() + 30)
data.dueDate = dueDate.toISOString()
}
}
// Set paid date when status changes to paid
if (data.status === 'paid' && !data.paidAt) {
data.paidAt = new Date().toISOString()
}
},
] satisfies CollectionBeforeChangeHook<Invoice>[],
beforeValidate: [
({ data }) => {
if (!data) {return}
// If using extractor, customer relationship is required
if (customerRelationSlug && 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 (customerRelationSlug && !customerInfoExtractor &&
!data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) {
throw new Error('Either select a customer or provide customer information')
}
// If no customer collection, ensure customer info is provided
if (!customerRelationSlug && (!data.customerInfo?.name || !data.customerInfo?.email)) {
throw new Error('Customer name and email are required')
}
if (data && data.items && Array.isArray(data.items)) {
// Calculate totals for each line item
data.items = data.items.map((item) => ({
...item,
totalAmount: (item.quantity || 0) * (item.unitAmount || 0),
}))
// Calculate subtotal
data.subtotal = data.items.reduce(
(sum: number, item) => sum + (item.totalAmount || 0),
0
)
// Calculate total amount
data.amount = (data.subtotal || 0) + (data.taxAmount || 0)
}
},
] satisfies CollectionBeforeValidateHook<Invoice>[],
},
timestamps: true,
}
// Apply collection extension function if provided
const collectionConfig = pluginConfig.collections?.invoices
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
return collectionConfig.extend(baseConfig)
}
return baseConfig
}