mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user