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

@@ -30,9 +30,11 @@ A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Molli
- 👥 Flexible customer data management with relationship support
- 📊 Complete payment tracking and history
- 🪝 Secure webhook processing for all providers
- 🔄 Automatic payment/invoice status synchronization
- 🧪 Built-in test provider for local development
- 📱 Payment management in PayloadCMS admin
- 🔄 Callback-based customer data syncing
- 🔗 Bidirectional payment-invoice relationship management
- 🎨 Collection extension support for custom fields and hooks
- 🔒 Full TypeScript support
## Installation
@@ -197,6 +199,16 @@ The plugin adds these collections:
- **invoices** - Invoice generation with line items and embedded customer info
- **refunds** - Refund tracking and management
### Automatic Status Synchronization
The plugin automatically keeps payments and invoices in sync:
- **Payment → Invoice**: When a payment status changes to `paid` or `succeeded`, any linked invoice is automatically updated to `paid` status
- **Invoice → Payment**: When an invoice is created with a payment link, the payment is automatically linked back (bidirectional relationship)
- **Manual Invoice Payment**: When an invoice status is manually changed to `paid`, the linked payment is updated to `succeeded`
This ensures data consistency without manual intervention and works seamlessly with webhook updates from payment providers.
### Customer Data Management
The plugin supports flexible customer data handling:

View File

@@ -7,10 +7,13 @@ This is a demo application showcasing the `@xtr-dev/payload-billing` plugin for
- 🧪 **Test Payment Provider** with customizable scenarios
- 💳 **Payment Management** with full CRUD operations
- 🧾 **Invoice Generation** with line items and tax calculation
- 👥 **Customer Management** with relationship support
- 🔄 **Refund Processing** and tracking
- 🔄 **Automatic Status Sync** - payments and invoices stay in sync automatically
- 🔗 **Bidirectional Relationships** - payment/invoice links maintained by plugin hooks
- 🎨 **Custom Payment UI** with modern design
- 📊 **Sample Data** for quick testing
- 📄 **Invoice View Page** - professional printable invoice layout
- 🔧 **Collection Extensions** - demonstrates how to extend collections with custom fields and hooks
- 💬 **Custom Message Field** - shows hook-based data copying from payment to invoice
- 📊 **No Customer Collection Required** - uses direct customer info fields
## Getting Started
@@ -57,6 +60,20 @@ This page demonstrates:
- Real-time payment status updates
- Test mode indicators and warnings
### Invoice View Page
View and print invoices at:
```
http://localhost:3000/invoice/{invoice-id}
```
This page demonstrates:
- Professional printable invoice layout
- Customer billing information
- Line items table with quantities and amounts
- Tax calculations and totals
- Custom message field (populated from payment metadata)
- Print-friendly styling
### Admin Routes
- **Payments**: [http://localhost:3000/admin/collections/payments](http://localhost:3000/admin/collections/payments)
@@ -111,6 +128,57 @@ testProvider({
})
```
### Collection Extension Options
This demo showcases how to extend the plugin's collections with custom fields and hooks. The invoices collection is extended to include a `customMessage` field that is automatically populated from payment metadata:
```typescript
collections: {
payments: 'payments',
invoices: {
slug: 'invoices',
extend: (config) => ({
...config,
fields: [
...(config.fields || []),
{
name: 'customMessage',
type: 'textarea',
admin: {
description: 'Custom message from the payment (auto-populated)',
},
},
],
hooks: {
...config.hooks,
beforeChange: [
...(config.hooks?.beforeChange || []),
async ({ data, req, operation }) => {
if (operation === 'create' && data.payment) {
const payment = await req.payload.findByID({
collection: 'payments',
id: typeof data.payment === 'object' ? data.payment.id : data.payment,
})
if (
payment?.metadata &&
typeof payment.metadata === 'object' &&
'customMessage' in payment.metadata &&
payment.metadata.customMessage
) {
data.customMessage = payment.metadata.customMessage as string
}
}
return data
},
],
},
}),
},
refunds: 'refunds',
}
```
### Customer Relationship
```typescript
customerRelationSlug: 'customers',
@@ -228,10 +296,13 @@ Request body:
{
"amount": 2500,
"currency": "USD",
"description": "Demo payment"
"description": "Demo payment",
"message": "Custom message to include in the invoice (optional)"
}
```
The `message` field will be stored in the payment's metadata and automatically copied to the invoice when it's created, thanks to the collection extension hook.
Response:
```json
{
@@ -246,6 +317,68 @@ Response:
}
```
### Get Payment
```
GET /api/demo/payment/{payment-provider-id}
```
Fetches payment details including invoice relationship. Used by the payment success page to find the associated invoice.
Response:
```json
{
"success": true,
"payment": {
"id": "67890",
"providerId": "test_pay_1234567890_abc123",
"amount": 2500,
"currency": "USD",
"status": "paid",
"description": "Demo payment",
"invoice": "invoice-id-here",
"metadata": {
"customMessage": "Your custom message"
}
}
}
```
### Get Invoice
```
GET /api/demo/invoice/{invoice-id}
```
Fetches complete invoice data including customer details, line items, and custom message. Used by the invoice view page.
Response:
```json
{
"success": true,
"invoice": {
"id": "invoice-id",
"invoiceNumber": "INV-2024-001",
"customer": {
"name": "John Doe",
"email": "john@example.com",
"company": "Acme Corp"
},
"currency": "USD",
"items": [
{
"description": "Service",
"quantity": 1,
"unitAmount": 2500
}
],
"subtotal": 2500,
"taxAmount": 250,
"total": 2750,
"status": "paid",
"customMessage": "Your custom message from payment"
}
}
```
## Development
### File Structure
@@ -257,10 +390,23 @@ dev/
│ ├── test-payment/
│ │ └── [id]/
│ │ └── page.tsx # Custom payment UI
│ ├── invoice/
│ │ └── [id]/
│ │ └── page.tsx # Invoice view/print page
│ ├── payment-success/
│ │ └── page.tsx # Payment success page
│ ├── payment-failed/
│ │ └── page.tsx # Payment failed page
│ ├── api/
│ │ └── demo/
│ │ ── create-payment/
│ │ └── route.ts # Payment creation endpoint
│ │ ── create-payment/
│ │ └── route.ts # Payment creation endpoint
│ │ ├── invoice/
│ │ │ └── [id]/
│ │ │ └── route.ts # Invoice fetch endpoint
│ │ └── payment/
│ │ └── [id]/
│ │ └── route.ts # Payment fetch endpoint
│ └── (payload)/ # PayloadCMS admin routes
├── helpers/
│ └── credentials.ts # Default user credentials

View File

@@ -8,7 +8,10 @@ export async function POST(request: Request) {
})
const body = await request.json()
const { amount, currency, description } = body
const { amount, currency, description, message, customerName, customerEmail, customerCompany } = body
// eslint-disable-next-line no-console
console.log('Received payment request:', { amount, currency, customerName, customerEmail, customerCompany })
if (!amount || !currency) {
return Response.json(
@@ -17,7 +20,16 @@ export async function POST(request: Request) {
)
}
// Create a payment using the test provider
if (!customerName || !customerEmail) {
// eslint-disable-next-line no-console
console.log('Missing customer info:', { customerName, customerEmail })
return Response.json(
{ success: false, error: 'Customer name and email are required' },
{ status: 400 }
)
}
// Create a payment first using the test provider
const payment = await payload.create({
collection: 'payments',
data: {
@@ -29,10 +41,42 @@ export async function POST(request: Request) {
metadata: {
source: 'demo-ui',
createdAt: new Date().toISOString(),
customMessage: message, // Store the custom message in metadata
},
},
})
// Create an invoice linked to the payment
// The invoice's afterChange hook will automatically link the payment back to the invoice
const invoice = await payload.create({
collection: 'invoices',
data: {
payment: payment.id, // Link to the payment
customerInfo: {
name: customerName,
email: customerEmail,
company: customerCompany,
},
billingAddress: {
line1: '123 Demo Street',
city: 'Demo City',
state: 'DC',
postalCode: '12345',
country: 'US',
},
currency,
items: [
{
description: description || 'Demo payment',
quantity: 1,
unitAmount: amount,
},
],
taxAmount: 0,
status: 'open',
},
})
return Response.json({
success: true,
payment: {
@@ -41,6 +85,7 @@ export async function POST(request: Request) {
amount: payment.amount,
currency: payment.currency,
description: payment.description,
invoiceId: invoice.id,
},
})
} catch (error) {

View File

@@ -0,0 +1,116 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const payload = await getPayload({
config: configPromise,
})
const { id: invoiceId } = await params
if (!invoiceId) {
return Response.json(
{ success: false, error: 'Invoice ID is required' },
{ status: 400 }
)
}
// Fetch the invoice
const invoice = await payload.findByID({
collection: 'invoices',
id: invoiceId,
})
if (!invoice) {
return Response.json(
{ success: false, error: 'Invoice not found' },
{ status: 404 }
)
}
// Get customer info - either from relationship or direct fields
let customerInfo = null
if (invoice.customer) {
// Try to fetch from customer relationship
try {
const customerData = await payload.findByID({
collection: 'customers',
id: typeof invoice.customer === 'object' ? invoice.customer.id : invoice.customer,
})
customerInfo = {
name: customerData.name,
email: customerData.email,
phone: customerData.phone,
company: customerData.company,
taxId: customerData.taxId,
billingAddress: customerData.address,
}
} catch (error) {
// Customer not found or collection doesn't exist
console.error('Failed to fetch customer:', error)
}
}
// Fall back to direct customerInfo fields if no customer relationship
if (!customerInfo && invoice.customerInfo) {
customerInfo = {
name: invoice.customerInfo.name,
email: invoice.customerInfo.email,
phone: invoice.customerInfo.phone,
company: invoice.customerInfo.company,
taxId: invoice.customerInfo.taxId,
billingAddress: invoice.billingAddress,
}
}
// Default customer if neither is available
if (!customerInfo) {
customerInfo = {
name: 'Unknown Customer',
email: 'unknown@example.com',
}
}
// Calculate subtotal from items (or use stored subtotal)
const subtotal = invoice.subtotal || invoice.items?.reduce((sum: number, item: any) => {
return sum + (item.unitAmount * item.quantity)
}, 0) || 0
const taxAmount = invoice.taxAmount || 0
const total = invoice.amount || (subtotal + taxAmount)
// Prepare the response
const invoiceData = {
id: invoice.id,
invoiceNumber: invoice.number || invoice.invoiceNumber,
customer: customerInfo,
currency: invoice.currency,
items: invoice.items || [],
subtotal,
taxAmount,
total,
status: invoice.status,
customMessage: invoice.customMessage,
issuedAt: invoice.issuedAt,
dueDate: invoice.dueDate,
createdAt: invoice.createdAt,
}
return Response.json({
success: true,
invoice: invoiceData,
})
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch invoice:', error)
return Response.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch invoice',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,63 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const payload = await getPayload({
config: configPromise,
})
const { id: paymentProviderId } = await params
if (!paymentProviderId) {
return Response.json(
{ success: false, error: 'Payment ID is required' },
{ status: 400 }
)
}
// Find payment by providerId (the test provider uses this format)
const payments = await payload.find({
collection: 'payments',
where: {
providerId: {
equals: paymentProviderId,
},
},
limit: 1,
})
if (!payments.docs.length) {
return Response.json(
{ success: false, error: 'Payment not found' },
{ status: 404 }
)
}
const payment = payments.docs[0]
return Response.json({
success: true,
payment: {
id: payment.id,
providerId: payment.providerId,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
description: payment.description,
invoice: payment.invoice,
metadata: payment.metadata,
},
})
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch payment:', error)
return Response.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch payment',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,317 @@
'use client'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
interface InvoiceItem {
description: string
quantity: number
unitAmount: number
id?: string
}
interface Customer {
name: string
email: string
phone?: string
company?: string
taxId?: string
billingAddress?: {
line1: string
line2?: string
city: string
state?: string
postalCode: string
country: string
}
}
interface Invoice {
id: string
invoiceNumber: string
customer: Customer
currency: string
items: InvoiceItem[]
subtotal: number
taxAmount?: number
total: number
status: string
customMessage?: string
issuedAt?: string
dueDate?: string
createdAt: string
}
export default function InvoiceViewPage() {
const params = useParams()
const invoiceId = params.id as string
const [invoice, setInvoice] = useState<Invoice | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string>('')
useEffect(() => {
fetchInvoice()
}, [invoiceId])
const fetchInvoice = async () => {
try {
const response = await fetch(`/api/demo/invoice/${invoiceId}`)
const data = await response.json()
if (data.success) {
setInvoice(data.invoice)
} else {
setError(data.error || 'Failed to load invoice')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="text-slate-600 text-lg">Loading invoice...</div>
</div>
)
}
if (error || !invoice) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-lg shadow-lg p-8">
<div className="text-center">
<div className="text-red-600 text-5xl mb-4"></div>
<h1 className="text-2xl font-bold text-slate-800 mb-2">Invoice Not Found</h1>
<p className="text-slate-600 mb-6">{error || 'The requested invoice could not be found.'}</p>
<a
href="/"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
Back to Demo
</a>
</div>
</div>
</div>
)
}
const formatCurrency = (amount: number) => {
return `${invoice.currency.toUpperCase()} ${(amount / 100).toFixed(2)}`
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
return (
<div className="min-h-screen bg-slate-50 py-8 print:bg-white print:py-0">
<div className="max-w-4xl mx-auto px-4">
{/* Print Button - Hidden when printing */}
<div className="mb-6 flex justify-end print:hidden">
<button
onClick={handlePrint}
className="bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
Print Invoice
</button>
</div>
{/* Invoice Container */}
<div className="bg-white rounded-lg shadow-lg print:shadow-none print:rounded-none">
<div className="p-8 md:p-12">
{/* Header */}
<div className="mb-8 pb-8 border-b-2 border-slate-200">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-4xl font-bold text-slate-800 mb-2">INVOICE</h1>
<p className="text-slate-600">Invoice #{invoice.invoiceNumber}</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-blue-600 mb-1">
@xtr-dev/payload-billing
</div>
<p className="text-slate-600 text-sm">Test Provider Demo</p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-8">
{/* Bill To */}
<div>
<h2 className="text-sm font-semibold text-slate-500 uppercase mb-3">Bill To</h2>
<div className="text-slate-800">
<p className="font-semibold text-lg">{invoice.customer.name}</p>
{invoice.customer.company && (
<p className="text-slate-600">{invoice.customer.company}</p>
)}
<p className="text-slate-600">{invoice.customer.email}</p>
{invoice.customer.phone && (
<p className="text-slate-600">{invoice.customer.phone}</p>
)}
{invoice.customer.billingAddress && (
<div className="mt-2 text-slate-600">
<p>{invoice.customer.billingAddress.line1}</p>
{invoice.customer.billingAddress.line2 && (
<p>{invoice.customer.billingAddress.line2}</p>
)}
<p>
{invoice.customer.billingAddress.city}
{invoice.customer.billingAddress.state && `, ${invoice.customer.billingAddress.state}`} {invoice.customer.billingAddress.postalCode}
</p>
<p>{invoice.customer.billingAddress.country}</p>
</div>
)}
{invoice.customer.taxId && (
<p className="mt-2 text-slate-600">Tax ID: {invoice.customer.taxId}</p>
)}
</div>
</div>
{/* Invoice Details */}
<div className="text-right md:text-left">
<h2 className="text-sm font-semibold text-slate-500 uppercase mb-3">Invoice Details</h2>
<div className="space-y-2 text-slate-800">
<div className="flex justify-between md:justify-start md:gap-4">
<span className="text-slate-600">Status:</span>
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
invoice.status === 'paid'
? 'bg-green-100 text-green-800'
: invoice.status === 'open'
? 'bg-blue-100 text-blue-800'
: invoice.status === 'void'
? 'bg-red-100 text-red-800'
: 'bg-slate-100 text-slate-800'
}`}
>
{invoice.status.toUpperCase()}
</span>
</div>
<div className="flex justify-between md:justify-start md:gap-4">
<span className="text-slate-600">Issued:</span>
<span className="font-medium">
{formatDate(invoice.issuedAt || invoice.createdAt)}
</span>
</div>
{invoice.dueDate && (
<div className="flex justify-between md:justify-start md:gap-4">
<span className="text-slate-600">Due:</span>
<span className="font-medium">{formatDate(invoice.dueDate)}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Custom Message */}
{invoice.customMessage && (
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="text-sm font-semibold text-blue-900 uppercase mb-2">Message</h3>
<p className="text-blue-800 whitespace-pre-wrap">{invoice.customMessage}</p>
</div>
)}
{/* Line Items Table */}
<div className="mb-8">
<table className="w-full">
<thead>
<tr className="border-b-2 border-slate-300">
<th className="text-left py-3 text-slate-700 font-semibold">Description</th>
<th className="text-right py-3 text-slate-700 font-semibold w-24">Qty</th>
<th className="text-right py-3 text-slate-700 font-semibold w-32">Unit Price</th>
<th className="text-right py-3 text-slate-700 font-semibold w-32">Amount</th>
</tr>
</thead>
<tbody>
{invoice.items.map((item, index) => (
<tr key={item.id || index} className="border-b border-slate-200">
<td className="py-4 text-slate-800">{item.description}</td>
<td className="py-4 text-right text-slate-800">{item.quantity}</td>
<td className="py-4 text-right text-slate-800">
{formatCurrency(item.unitAmount)}
</td>
<td className="py-4 text-right text-slate-800 font-medium">
{formatCurrency(item.unitAmount * item.quantity)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Totals */}
<div className="flex justify-end mb-8">
<div className="w-full md:w-80">
<div className="space-y-3">
<div className="flex justify-between py-2 text-slate-700">
<span>Subtotal:</span>
<span className="font-medium">{formatCurrency(invoice.subtotal)}</span>
</div>
{invoice.taxAmount !== undefined && invoice.taxAmount > 0 && (
<div className="flex justify-between py-2 text-slate-700">
<span>Tax:</span>
<span className="font-medium">{formatCurrency(invoice.taxAmount)}</span>
</div>
)}
<div className="flex justify-between py-3 border-t-2 border-slate-300 text-lg font-bold text-slate-900">
<span>Total:</span>
<span>{formatCurrency(invoice.total)}</span>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="pt-8 border-t border-slate-200 text-center text-slate-500 text-sm">
<p>Thank you for your business!</p>
<p className="mt-2">
This is a demo invoice generated by @xtr-dev/payload-billing plugin
</p>
</div>
</div>
</div>
{/* Back Button - Hidden when printing */}
<div className="mt-6 text-center print:hidden">
<a
href="/"
className="inline-block text-blue-600 hover:text-blue-700 font-semibold transition-colors"
>
Back to Demo
</a>
</div>
</div>
{/* Print Styles */}
<style jsx global>{`
@media print {
body {
background: white !important;
}
@page {
margin: 1cm;
}
}
`}</style>
</div>
)
}

View File

@@ -7,20 +7,39 @@ export default function HomePage() {
const [paymentId, setPaymentId] = useState<string>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
const [customerName, setCustomerName] = useState<string>('Demo Customer')
const [customerEmail, setCustomerEmail] = useState<string>('demo@example.com')
const [customerCompany, setCustomerCompany] = useState<string>('Demo Company')
const [message, setMessage] = useState<string>('')
const createDemoPayment = async () => {
setLoading(true)
setError('')
// Validate required fields
if (!customerName || !customerEmail) {
setError('Customer name and email are required')
setLoading(false)
return
}
try {
const requestBody = {
amount: 2500,
currency: 'USD',
description: 'Demo payment from custom UI',
customerName,
customerEmail,
customerCompany: customerCompany || undefined,
message: message || undefined,
}
console.log('Sending payment request:', requestBody)
const response = await fetch('/api/demo/create-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 2500,
currency: 'USD',
description: 'Demo payment from custom UI',
}),
body: JSON.stringify(requestBody),
})
const data = await response.json()
@@ -64,17 +83,80 @@ export default function HomePage() {
</h3>
{!paymentId ? (
<div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="customerName" className="block text-sm font-medium text-slate-700 mb-2">
Customer Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="customerName"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="John Doe"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label htmlFor="customerEmail" className="block text-sm font-medium text-slate-700 mb-2">
Customer Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="customerEmail"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
placeholder="john@example.com"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
</div>
<div>
<label htmlFor="customerCompany" className="block text-sm font-medium text-slate-700 mb-2">
Company Name (Optional)
</label>
<input
type="text"
id="customerCompany"
value={customerCompany}
onChange={(e) => setCustomerCompany(e.target.value)}
placeholder="Acme Corporation"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-slate-700 mb-2">
Custom Message (Optional)
</label>
<textarea
id="message"
rows={3}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Enter a message to include in the invoice..."
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
<p className="mt-1 text-xs text-slate-500">
This message will be added to the invoice using collection extension options
</p>
</div>
<button
onClick={createDemoPayment}
disabled={loading}
className="bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-lg font-semibold hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-lg font-semibold hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{loading ? 'Creating Payment...' : 'Create Demo Payment'}
</button>
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 text-red-800 rounded-lg">
<div className="p-4 bg-red-50 border border-red-200 text-red-800 rounded-lg">
{error}
</div>
)}
@@ -102,6 +184,10 @@ export default function HomePage() {
onClick={() => {
setPaymentId('')
setError('')
setCustomerName('Demo Customer')
setCustomerEmail('demo@example.com')
setCustomerCompany('Demo Company')
setMessage('')
}}
className="bg-slate-200 text-slate-700 px-6 py-3 rounded-lg font-semibold hover:bg-slate-300 transition-all cursor-pointer"
>

View File

@@ -2,13 +2,31 @@
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
import { Suspense, useEffect, useState } from 'react'
function PaymentSuccessContent() {
const searchParams = useSearchParams()
const paymentId = searchParams.get('paymentId')
const amount = searchParams.get('amount')
const currency = searchParams.get('currency')
const [invoiceId, setInvoiceId] = useState<string | null>(null)
useEffect(() => {
// Fetch the payment to get the invoice ID
if (paymentId) {
fetch(`/api/demo/payment/${paymentId}`)
.then((res) => res.json())
.then((data) => {
if (data.success && data.payment?.invoice) {
const invId = typeof data.payment.invoice === 'object' ? data.payment.invoice.id : data.payment.invoice
setInvoiceId(invId)
}
})
.catch((err) => {
console.error('Failed to fetch payment invoice:', err)
})
}
}, [paymentId])
return (
<div className="min-h-screen bg-gradient-to-br from-green-600 to-emerald-700 flex items-center justify-center p-4">
@@ -76,6 +94,35 @@ function PaymentSuccessContent() {
<h3 className="font-semibold text-slate-800 text-lg">What's Next?</h3>
<div className="grid gap-3">
{invoiceId && (
<Link
href={`/invoice/${invoiceId}`}
className="flex items-center justify-between p-4 border-2 border-green-500 bg-green-50 rounded-lg hover:bg-green-100 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-green-800 group-hover:text-green-900">
📄 View Invoice
</div>
<div className="text-sm text-green-700">
See your invoice with custom message
</div>
</div>
<svg
className="w-5 h-5 text-green-600 group-hover:text-green-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
)}
<Link
href="/"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-green-500 hover:bg-green-50 transition-all group cursor-pointer"

View File

@@ -36,71 +36,7 @@ const buildConfigWithSQLite = () => {
staticDir: path.resolve(dirname, 'media'),
},
},
{
slug: 'customers',
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'email',
type: 'email',
required: true,
},
{
name: 'phone',
type: 'text',
},
{
name: 'company',
type: 'text',
},
{
name: 'taxId',
type: 'text',
label: 'Tax ID',
},
{
name: 'address',
type: 'group',
fields: [
{
name: 'line1',
type: 'text',
label: 'Address Line 1',
},
{
name: 'line2',
type: 'text',
label: 'Address Line 2',
},
{
name: 'city',
type: 'text',
},
{
name: 'state',
type: 'text',
label: 'State/Province',
},
{
name: 'postalCode',
type: 'text',
label: 'Postal Code',
},
{
name: 'country',
type: 'text',
},
],
},
],
},
// Note: No customers collection - the demo uses direct customerInfo fields on invoices
],
db: sqliteAdapter({
client: {
@@ -127,25 +63,62 @@ const buildConfigWithSQLite = () => {
],
collections: {
payments: 'payments',
invoices: 'invoices',
invoices: {
slug: 'invoices',
// Use extend to add custom fields and hooks to the invoice collection
extend: (config) => ({
...config,
fields: [
...(config.fields || []),
// Add a custom message field to invoices
{
name: 'customMessage',
type: 'textarea',
admin: {
description: 'Custom message from the payment (auto-populated)',
},
},
],
hooks: {
...config.hooks,
beforeChange: [
...(config.hooks?.beforeChange || []),
// Hook to copy the message from payment metadata to invoice
async ({ data, req, operation }) => {
// Only run on create operations
if (operation === 'create' && data.payment) {
try {
// Fetch the related payment
const payment = await req.payload.findByID({
collection: 'payments',
id: typeof data.payment === 'object' ? data.payment.id : data.payment,
})
// Copy the custom message from payment metadata to invoice
if (
payment?.metadata &&
typeof payment.metadata === 'object' &&
'customMessage' in payment.metadata &&
payment.metadata.customMessage
) {
data.customMessage = payment.metadata.customMessage as string
}
} catch (error) {
// Log error but don't fail the invoice creation
req.payload.logger.error('Failed to copy custom message to invoice:', error)
}
}
return data
},
],
},
}),
},
refunds: 'refunds',
},
customerRelationSlug: 'customers',
customerInfoExtractor: (customer) => ({
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,
}),
// Note: No customerRelationSlug or customerInfoExtractor configured
// This allows the demo to work without a customer collection
// Invoices will use the direct customerInfo and billingAddress fields
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

View File

@@ -83,6 +83,8 @@ async function seedBillingData(payload: Payload): Promise<void> {
},
})
customer2Id = customer2.id
} else {
payload.logger.info('No customers collection found, will use direct customer info in invoices')
}
// Seed invoices

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