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

@@ -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