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>
This commit is contained in:
2025-11-08 16:20:01 +01:00
parent f096b5f17f
commit 27da194942
16 changed files with 1092 additions and 139 deletions

View File

@@ -3,7 +3,9 @@ import type {
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig, Field,
CollectionConfig,
CollectionSlug,
Field,
} from 'payload'
import type { BillingPluginConfig} from '@/plugin/config';
import { defaults } from '@/plugin/config'
@@ -13,8 +15,12 @@ import type { Invoice } from '@/plugin/types'
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {}
let fields: Field[] = [
// 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',
@@ -33,7 +39,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
position: 'sidebar' as const,
description: 'Link to customer record (optional)',
},
relationTo: extractSlug(customerRelationSlug),
relationTo: customerRelationSlug as any,
required: false,
}] : []),
// Basic customer info fields (embedded)
@@ -277,7 +283,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
condition: (data) => data.status === 'paid',
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
relationTo: paymentsSlug,
},
{
name: 'notes',
@@ -294,11 +300,9 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
},
},
]
if (overrides?.fields) {
fields = overrides.fields({defaultFields: fields})
}
return {
slug: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
const baseConfig: CollectionConfig = {
slug: invoicesSlug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
@@ -313,10 +317,68 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
fields,
hooks: {
afterChange: [
({ doc, operation, req }) => {
async ({ doc, operation, req, previousDoc }) => {
const logger = createContextLogger(req.payload, 'Invoices Collection')
if (operation === 'create') {
const logger = createContextLogger(req.payload, 'Invoices Collection')
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>[],
@@ -353,7 +415,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
}
} catch (error) {
const logger = createContextLogger(req.payload, 'Invoices Collection')
logger.error(`Failed to extract customer info: ${error}`)
logger.error(`Failed to extract customer info: ${String(error)}`)
throw new Error('Failed to extract customer information')
}
}
@@ -429,4 +491,12 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
},
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
}

View File

@@ -1,13 +1,18 @@
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
import type { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
import type { BillingPluginConfig} from '../plugin/config';
import { defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import type { Payment } from '../plugin/types/payments'
import { initProviderPayment } from './hooks'
import { createContextLogger } from '../utils/logger'
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
let fields: Field[] = [
// Get slugs for relationships - these need to be determined before building fields
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const fields: Field[] = [
{
name: 'provider',
type: 'select',
@@ -79,7 +84,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
admin: {
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
relationTo: invoicesSlug,
},
{
name: 'metadata',
@@ -104,7 +109,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
readOnly: true,
},
hasMany: true,
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
relationTo: refundsSlug,
},
{
name: 'version',
@@ -116,12 +121,10 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
index: true, // Index for optimistic locking performance
},
]
if (overrides?.fields) {
fields = overrides?.fields({defaultFields: fields})
}
return {
slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
access: overrides?.access || {
const baseConfig: CollectionConfig = {
slug: paymentsSlug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
@@ -131,10 +134,43 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
group: 'Billing',
useAsTitle: 'id',
...overrides?.admin
},
fields,
hooks: {
afterChange: [
async ({ doc, operation, req, previousDoc }) => {
const logger = createContextLogger(req.payload, 'Payments Collection')
// Only process when payment status changes to a successful state
const successStatuses = ['paid', 'succeeded']
const paymentSucceeded = successStatuses.includes(doc.status)
const statusChanged = operation === 'update' && previousDoc && previousDoc.status !== doc.status
if (paymentSucceeded && (operation === 'create' || statusChanged)) {
// If payment has a linked invoice, update the invoice status to paid
if (doc.invoice) {
try {
const invoiceId = typeof doc.invoice === 'object' ? doc.invoice.id : doc.invoice
logger.info(`Payment ${doc.id} succeeded, updating invoice ${invoiceId} to paid`)
await req.payload.update({
collection: invoicesSlug,
id: invoiceId,
data: {
status: 'paid',
},
})
logger.info(`Invoice ${invoiceId} marked as paid`)
} catch (error) {
logger.error(`Failed to update invoice status: ${error}`)
// Don't throw - we don't want to fail the payment update
}
}
}
},
] satisfies CollectionAfterChangeHook<Payment>[],
beforeChange: [
async ({ data, operation, req, originalDoc }) => {
if (operation === 'create') {
@@ -167,4 +203,12 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
},
timestamps: true,
}
// Apply collection extension function if provided
const collectionConfig = pluginConfig.collections?.payments
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
return collectionConfig.extend(baseConfig)
}
return baseConfig
}

View File

@@ -6,9 +6,12 @@ import type { Payment } from '../plugin/types/index'
import { createContextLogger } from '../utils/logger'
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
// TODO: finish collection overrides
return {
slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
// Get slugs for relationships - these need to be determined before building fields
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
const baseConfig: CollectionConfig = {
slug: refundsSlug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
@@ -37,7 +40,7 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
admin: {
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
relationTo: paymentsSlug,
required: true,
},
{
@@ -120,13 +123,13 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
try {
const payment = await req.payload.findByID({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
collection: paymentsSlug,
}) as Payment
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
await req.payload.update({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
collection: paymentsSlug,
data: {
refunds: [...refundIds, doc.id],
},
@@ -159,4 +162,12 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
},
timestamps: true,
}
// Apply collection extension function if provided
const collectionConfig = pluginConfig.collections?.refunds
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
return collectionConfig.extend(baseConfig)
}
return baseConfig
}

View File

@@ -41,6 +41,14 @@ export interface CustomerInfoExtractor {
}
}
// Collection configuration type
export type CollectionExtension =
| string
| {
slug: string
extend?: (config: CollectionConfig) => CollectionConfig
}
// Plugin configuration
export interface BillingPluginConfig {
admin?: {
@@ -48,9 +56,9 @@ export interface BillingPluginConfig {
dashboard?: boolean
}
collections?: {
invoices?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
payments?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
refunds?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
invoices?: CollectionExtension
payments?: CollectionExtension
refunds?: CollectionExtension
}
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
customerRelationSlug?: string // Customer collection slug for relationship

View File

@@ -1,10 +1,23 @@
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
import type { Id } from './types/index'
import type { CollectionExtension } from './config'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export const extractSlug =
(arg: string | Partial<CollectionConfig>) => (typeof arg === 'string' ? arg : arg.slug!) as CollectionSlug
/**
* Extract the slug from a collection configuration
* Returns the slug from the configuration or the default slug if not provided
*/
export const extractSlug = (arg: CollectionExtension | undefined, defaultSlug: string): CollectionSlug => {
if (!arg) {
return defaultSlug as CollectionSlug
}
if (typeof arg === 'string') {
return arg as CollectionSlug
}
// arg is an object with slug property
return arg.slug as CollectionSlug
}
/**
* Safely cast ID types for PayloadCMS operations

View File

@@ -35,7 +35,7 @@ export async function findPaymentByProviderId(
providerId: string,
pluginConfig: BillingPluginConfig
): Promise<Payment | null> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const payments = await payload.find({
collection: paymentsCollection,
@@ -59,7 +59,7 @@ export async function updatePaymentStatus(
providerData: ProviderData<any>,
pluginConfig: BillingPluginConfig
): Promise<boolean> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
try {
// First, fetch the current payment to get the current version
@@ -141,7 +141,7 @@ export async function updateInvoiceOnPaymentSuccess(
): Promise<void> {
if (!payment.invoice) {return}
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
const invoiceId = typeof payment.invoice === 'object'
? payment.invoice.id
: payment.invoice