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

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