diff --git a/README.md b/README.md index 79e81c8..18f5987 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/dev/README.md b/dev/README.md index 4667eee..a739747 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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 diff --git a/dev/app/api/demo/create-payment/route.ts b/dev/app/api/demo/create-payment/route.ts index 308eec0..ff5d5c8 100644 --- a/dev/app/api/demo/create-payment/route.ts +++ b/dev/app/api/demo/create-payment/route.ts @@ -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) { diff --git a/dev/app/api/demo/invoice/[id]/route.ts b/dev/app/api/demo/invoice/[id]/route.ts new file mode 100644 index 0000000..92fd64a --- /dev/null +++ b/dev/app/api/demo/invoice/[id]/route.ts @@ -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 } + ) + } +} diff --git a/dev/app/api/demo/payment/[id]/route.ts b/dev/app/api/demo/payment/[id]/route.ts new file mode 100644 index 0000000..7f7683c --- /dev/null +++ b/dev/app/api/demo/payment/[id]/route.ts @@ -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 } + ) + } +} diff --git a/dev/app/invoice/[id]/page.tsx b/dev/app/invoice/[id]/page.tsx new file mode 100644 index 0000000..a59a25e --- /dev/null +++ b/dev/app/invoice/[id]/page.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + 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 ( +
+
Loading invoice...
+
+ ) + } + + if (error || !invoice) { + return ( +
+
+
+
โš ๏ธ
+

Invoice Not Found

+

{error || 'The requested invoice could not be found.'}

+ + Back to Demo + +
+
+
+ ) + } + + 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 ( +
+
+ {/* Print Button - Hidden when printing */} +
+ +
+ + {/* Invoice Container */} +
+
+ {/* Header */} +
+
+
+

INVOICE

+

Invoice #{invoice.invoiceNumber}

+
+
+
+ @xtr-dev/payload-billing +
+

Test Provider Demo

+
+
+ +
+ {/* Bill To */} +
+

Bill To

+
+

{invoice.customer.name}

+ {invoice.customer.company && ( +

{invoice.customer.company}

+ )} +

{invoice.customer.email}

+ {invoice.customer.phone && ( +

{invoice.customer.phone}

+ )} + {invoice.customer.billingAddress && ( +
+

{invoice.customer.billingAddress.line1}

+ {invoice.customer.billingAddress.line2 && ( +

{invoice.customer.billingAddress.line2}

+ )} +

+ {invoice.customer.billingAddress.city} + {invoice.customer.billingAddress.state && `, ${invoice.customer.billingAddress.state}`} {invoice.customer.billingAddress.postalCode} +

+

{invoice.customer.billingAddress.country}

+
+ )} + {invoice.customer.taxId && ( +

Tax ID: {invoice.customer.taxId}

+ )} +
+
+ + {/* Invoice Details */} +
+

Invoice Details

+
+
+ Status: + + {invoice.status.toUpperCase()} + +
+
+ Issued: + + {formatDate(invoice.issuedAt || invoice.createdAt)} + +
+ {invoice.dueDate && ( +
+ Due: + {formatDate(invoice.dueDate)} +
+ )} +
+
+
+
+ + {/* Custom Message */} + {invoice.customMessage && ( +
+

Message

+

{invoice.customMessage}

+
+ )} + + {/* Line Items Table */} +
+ + + + + + + + + + + {invoice.items.map((item, index) => ( + + + + + + + ))} + +
DescriptionQtyUnit PriceAmount
{item.description}{item.quantity} + {formatCurrency(item.unitAmount)} + + {formatCurrency(item.unitAmount * item.quantity)} +
+
+ + {/* Totals */} +
+
+
+
+ Subtotal: + {formatCurrency(invoice.subtotal)} +
+ {invoice.taxAmount !== undefined && invoice.taxAmount > 0 && ( +
+ Tax: + {formatCurrency(invoice.taxAmount)} +
+ )} +
+ Total: + {formatCurrency(invoice.total)} +
+
+
+
+ + {/* Footer */} +
+

Thank you for your business!

+

+ This is a demo invoice generated by @xtr-dev/payload-billing plugin +

+
+
+
+ + {/* Back Button - Hidden when printing */} +
+ + โ† Back to Demo + +
+
+ + {/* Print Styles */} + +
+ ) +} diff --git a/dev/app/page.tsx b/dev/app/page.tsx index 1d208ff..5b99375 100644 --- a/dev/app/page.tsx +++ b/dev/app/page.tsx @@ -7,20 +7,39 @@ export default function HomePage() { const [paymentId, setPaymentId] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [customerName, setCustomerName] = useState('Demo Customer') + const [customerEmail, setCustomerEmail] = useState('demo@example.com') + const [customerCompany, setCustomerCompany] = useState('Demo Company') + const [message, setMessage] = useState('') 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() { {!paymentId ? ( -
+
+
+
+ + 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 + /> +
+ +
+ + 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 + /> +
+
+ +
+ + 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" + /> +
+ +
+ +