mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-08 00:33:23 +00:00
feat: add automatic payment/invoice status sync and invoice view page
Core Plugin Enhancements: - Add afterChange hook to payments collection to auto-update linked invoice status to 'paid' when payment succeeds - Add afterChange hook to invoices collection for bidirectional payment-invoice relationship management - Add invoice status sync when manually marked as paid - Update plugin config types to support collection extension options Demo Application Features: - Add professional invoice view page with print-friendly layout (/invoice/[id]) - Add custom message field to payment creation form - Add invoice API endpoint to fetch complete invoice data with customer info - Add payment API endpoint to retrieve payment with invoice relationship - Update payment success page with "View Invoice" button - Implement beforeChange hook to copy custom message from payment metadata to invoice - Remove customer collection dependency - use direct customerInfo fields instead Documentation: - Update README with automatic status synchronization section - Add collection extension examples to demo README - Document new features: bidirectional relationships, status sync, invoice view Technical Improvements: - Fix total calculation in invoice API (use 'amount' field instead of 'total') - Add proper TypeScript types with CollectionSlug casting - Implement Next.js 15 async params pattern in API routes - Add customer name/email/company fields to payment creation form 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
14
README.md
14
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:
|
||||
|
||||
158
dev/README.md
158
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
116
dev/app/api/demo/invoice/[id]/route.ts
Normal file
116
dev/app/api/demo/invoice/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
63
dev/app/api/demo/payment/[id]/route.ts
Normal file
63
dev/app/api/demo/payment/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
317
dev/app/invoice/[id]/page.tsx
Normal file
317
dev/app/invoice/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
dev/app/page.tsx
102
dev/app/page.tsx
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,9 @@ import type {
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionBeforeValidateHook,
|
||||
CollectionConfig, Field,
|
||||
CollectionConfig,
|
||||
CollectionSlug,
|
||||
Field,
|
||||
} from 'payload'
|
||||
import type { BillingPluginConfig} from '@/plugin/config';
|
||||
import { defaults } from '@/plugin/config'
|
||||
@@ -13,8 +15,12 @@ import type { Invoice } from '@/plugin/types'
|
||||
|
||||
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
||||
const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {}
|
||||
let fields: Field[] = [
|
||||
|
||||
// Get slugs for relationships - these need to be determined before building fields
|
||||
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
|
||||
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'number',
|
||||
type: 'text',
|
||||
@@ -33,7 +39,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
position: 'sidebar' as const,
|
||||
description: 'Link to customer record (optional)',
|
||||
},
|
||||
relationTo: extractSlug(customerRelationSlug),
|
||||
relationTo: customerRelationSlug as any,
|
||||
required: false,
|
||||
}] : []),
|
||||
// Basic customer info fields (embedded)
|
||||
@@ -277,7 +283,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
condition: (data) => data.status === 'paid',
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
||||
relationTo: paymentsSlug,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
@@ -294,11 +300,9 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
},
|
||||
},
|
||||
]
|
||||
if (overrides?.fields) {
|
||||
fields = overrides.fields({defaultFields: fields})
|
||||
}
|
||||
return {
|
||||
slug: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
|
||||
|
||||
const baseConfig: CollectionConfig = {
|
||||
slug: invoicesSlug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
@@ -313,10 +317,68 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
fields,
|
||||
hooks: {
|
||||
afterChange: [
|
||||
({ doc, operation, req }) => {
|
||||
async ({ doc, operation, req, previousDoc }) => {
|
||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||
|
||||
if (operation === 'create') {
|
||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||
logger.info(`Invoice created: ${doc.number}`)
|
||||
|
||||
// If invoice has a linked payment, update the payment to link back to this invoice
|
||||
if (doc.payment) {
|
||||
try {
|
||||
const paymentId = typeof doc.payment === 'object' ? doc.payment.id : doc.payment
|
||||
|
||||
logger.info(`Linking payment ${paymentId} back to invoice ${doc.id}`)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await req.payload.update({
|
||||
collection: paymentsSlug as CollectionSlug,
|
||||
id: paymentId,
|
||||
data: {
|
||||
invoice: doc.id,
|
||||
} as any,
|
||||
})
|
||||
|
||||
logger.info(`Payment ${paymentId} linked to invoice ${doc.id}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to link payment to invoice: ${String(error)}`)
|
||||
// Don't throw - invoice is already created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If invoice status changes to paid, ensure linked payment is also marked as paid
|
||||
const statusChanged = operation === 'update' && previousDoc && previousDoc.status !== doc.status
|
||||
if (statusChanged && doc.status === 'paid' && doc.payment) {
|
||||
try {
|
||||
const paymentId = typeof doc.payment === 'object' ? doc.payment.id : doc.payment
|
||||
|
||||
// Fetch the payment to check its status
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const payment = await req.payload.findByID({
|
||||
collection: paymentsSlug as CollectionSlug,
|
||||
id: paymentId,
|
||||
}) as any
|
||||
|
||||
// Only update if payment is not already in a successful state
|
||||
if (payment && !['paid', 'succeeded'].includes(payment.status)) {
|
||||
logger.info(`Invoice ${doc.id} marked as paid, updating payment ${paymentId}`)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await req.payload.update({
|
||||
collection: paymentsSlug as CollectionSlug,
|
||||
id: paymentId,
|
||||
data: {
|
||||
status: 'succeeded',
|
||||
} as any,
|
||||
})
|
||||
|
||||
logger.info(`Payment ${paymentId} marked as succeeded`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update payment status: ${String(error)}`)
|
||||
// Don't throw - invoice update is already complete
|
||||
}
|
||||
}
|
||||
},
|
||||
] satisfies CollectionAfterChangeHook<Invoice>[],
|
||||
@@ -353,7 +415,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
}
|
||||
} catch (error) {
|
||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||
logger.error(`Failed to extract customer info: ${error}`)
|
||||
logger.error(`Failed to extract customer info: ${String(error)}`)
|
||||
throw new Error('Failed to extract customer information')
|
||||
}
|
||||
}
|
||||
@@ -429,4 +491,12 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
|
||||
// Apply collection extension function if provided
|
||||
const collectionConfig = pluginConfig.collections?.invoices
|
||||
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
|
||||
return collectionConfig.extend(baseConfig)
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
|
||||
import type { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
|
||||
import type { BillingPluginConfig} from '../plugin/config';
|
||||
import { defaults } from '../plugin/config'
|
||||
import { extractSlug } from '../plugin/utils'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import { initProviderPayment } from './hooks'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
|
||||
let fields: Field[] = [
|
||||
// Get slugs for relationships - these need to be determined before building fields
|
||||
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
|
||||
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
|
||||
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'select',
|
||||
@@ -79,7 +84,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
|
||||
relationTo: invoicesSlug,
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
@@ -104,7 +109,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
|
||||
relationTo: refundsSlug,
|
||||
},
|
||||
{
|
||||
name: 'version',
|
||||
@@ -116,12 +121,10 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
||||
index: true, // Index for optimistic locking performance
|
||||
},
|
||||
]
|
||||
if (overrides?.fields) {
|
||||
fields = overrides?.fields({defaultFields: fields})
|
||||
}
|
||||
return {
|
||||
slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
||||
access: overrides?.access || {
|
||||
|
||||
const baseConfig: CollectionConfig = {
|
||||
slug: paymentsSlug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
read: ({ req: { user } }: AccessArgs) => !!user,
|
||||
@@ -131,10 +134,43 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
||||
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'id',
|
||||
...overrides?.admin
|
||||
},
|
||||
fields,
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, operation, req, previousDoc }) => {
|
||||
const logger = createContextLogger(req.payload, 'Payments Collection')
|
||||
|
||||
// Only process when payment status changes to a successful state
|
||||
const successStatuses = ['paid', 'succeeded']
|
||||
const paymentSucceeded = successStatuses.includes(doc.status)
|
||||
const statusChanged = operation === 'update' && previousDoc && previousDoc.status !== doc.status
|
||||
|
||||
if (paymentSucceeded && (operation === 'create' || statusChanged)) {
|
||||
// If payment has a linked invoice, update the invoice status to paid
|
||||
if (doc.invoice) {
|
||||
try {
|
||||
const invoiceId = typeof doc.invoice === 'object' ? doc.invoice.id : doc.invoice
|
||||
|
||||
logger.info(`Payment ${doc.id} succeeded, updating invoice ${invoiceId} to paid`)
|
||||
|
||||
await req.payload.update({
|
||||
collection: invoicesSlug,
|
||||
id: invoiceId,
|
||||
data: {
|
||||
status: 'paid',
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Invoice ${invoiceId} marked as paid`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update invoice status: ${error}`)
|
||||
// Don't throw - we don't want to fail the payment update
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
] satisfies CollectionAfterChangeHook<Payment>[],
|
||||
beforeChange: [
|
||||
async ({ data, operation, req, originalDoc }) => {
|
||||
if (operation === 'create') {
|
||||
@@ -167,4 +203,12 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
|
||||
// Apply collection extension function if provided
|
||||
const collectionConfig = pluginConfig.collections?.payments
|
||||
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
|
||||
return collectionConfig.extend(baseConfig)
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import type { Payment } from '../plugin/types/index'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
// TODO: finish collection overrides
|
||||
return {
|
||||
slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
|
||||
// Get slugs for relationships - these need to be determined before building fields
|
||||
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
|
||||
|
||||
const baseConfig: CollectionConfig = {
|
||||
slug: refundsSlug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
@@ -37,7 +40,7 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
||||
relationTo: paymentsSlug,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -120,13 +123,13 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
||||
try {
|
||||
const payment = await req.payload.findByID({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
||||
collection: paymentsSlug,
|
||||
}) as Payment
|
||||
|
||||
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
||||
await req.payload.update({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
||||
collection: paymentsSlug,
|
||||
data: {
|
||||
refunds: [...refundIds, doc.id],
|
||||
},
|
||||
@@ -159,4 +162,12 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
|
||||
// Apply collection extension function if provided
|
||||
const collectionConfig = pluginConfig.collections?.refunds
|
||||
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
|
||||
return collectionConfig.extend(baseConfig)
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ export interface CustomerInfoExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
// Collection configuration type
|
||||
export type CollectionExtension =
|
||||
| string
|
||||
| {
|
||||
slug: string
|
||||
extend?: (config: CollectionConfig) => CollectionConfig
|
||||
}
|
||||
|
||||
// Plugin configuration
|
||||
export interface BillingPluginConfig {
|
||||
admin?: {
|
||||
@@ -48,9 +56,9 @@ export interface BillingPluginConfig {
|
||||
dashboard?: boolean
|
||||
}
|
||||
collections?: {
|
||||
invoices?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
|
||||
payments?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
|
||||
refunds?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
|
||||
invoices?: CollectionExtension
|
||||
payments?: CollectionExtension
|
||||
refunds?: CollectionExtension
|
||||
}
|
||||
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
||||
customerRelationSlug?: string // Customer collection slug for relationship
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
||||
import type { Id } from './types/index'
|
||||
import type { CollectionExtension } from './config'
|
||||
|
||||
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
||||
|
||||
export const extractSlug =
|
||||
(arg: string | Partial<CollectionConfig>) => (typeof arg === 'string' ? arg : arg.slug!) as CollectionSlug
|
||||
/**
|
||||
* Extract the slug from a collection configuration
|
||||
* Returns the slug from the configuration or the default slug if not provided
|
||||
*/
|
||||
export const extractSlug = (arg: CollectionExtension | undefined, defaultSlug: string): CollectionSlug => {
|
||||
if (!arg) {
|
||||
return defaultSlug as CollectionSlug
|
||||
}
|
||||
if (typeof arg === 'string') {
|
||||
return arg as CollectionSlug
|
||||
}
|
||||
// arg is an object with slug property
|
||||
return arg.slug as CollectionSlug
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely cast ID types for PayloadCMS operations
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function findPaymentByProviderId(
|
||||
providerId: string,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<Payment | null> {
|
||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
|
||||
const payments = await payload.find({
|
||||
collection: paymentsCollection,
|
||||
@@ -59,7 +59,7 @@ export async function updatePaymentStatus(
|
||||
providerData: ProviderData<any>,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<boolean> {
|
||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
|
||||
try {
|
||||
// First, fetch the current payment to get the current version
|
||||
@@ -141,7 +141,7 @@ export async function updateInvoiceOnPaymentSuccess(
|
||||
): Promise<void> {
|
||||
if (!payment.invoice) {return}
|
||||
|
||||
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
|
||||
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
|
||||
const invoiceId = typeof payment.invoice === 'object'
|
||||
? payment.invoice.id
|
||||
: payment.invoice
|
||||
|
||||
Reference in New Issue
Block a user