mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +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
|
- 👥 Flexible customer data management with relationship support
|
||||||
- 📊 Complete payment tracking and history
|
- 📊 Complete payment tracking and history
|
||||||
- 🪝 Secure webhook processing for all providers
|
- 🪝 Secure webhook processing for all providers
|
||||||
|
- 🔄 Automatic payment/invoice status synchronization
|
||||||
- 🧪 Built-in test provider for local development
|
- 🧪 Built-in test provider for local development
|
||||||
- 📱 Payment management in PayloadCMS admin
|
- 📱 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
|
- 🔒 Full TypeScript support
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -197,6 +199,16 @@ The plugin adds these collections:
|
|||||||
- **invoices** - Invoice generation with line items and embedded customer info
|
- **invoices** - Invoice generation with line items and embedded customer info
|
||||||
- **refunds** - Refund tracking and management
|
- **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
|
### Customer Data Management
|
||||||
|
|
||||||
The plugin supports flexible customer data handling:
|
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
|
- 🧪 **Test Payment Provider** with customizable scenarios
|
||||||
- 💳 **Payment Management** with full CRUD operations
|
- 💳 **Payment Management** with full CRUD operations
|
||||||
- 🧾 **Invoice Generation** with line items and tax calculation
|
- 🧾 **Invoice Generation** with line items and tax calculation
|
||||||
- 👥 **Customer Management** with relationship support
|
- 🔄 **Automatic Status Sync** - payments and invoices stay in sync automatically
|
||||||
- 🔄 **Refund Processing** and tracking
|
- 🔗 **Bidirectional Relationships** - payment/invoice links maintained by plugin hooks
|
||||||
- 🎨 **Custom Payment UI** with modern design
|
- 🎨 **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
|
## Getting Started
|
||||||
|
|
||||||
@@ -57,6 +60,20 @@ This page demonstrates:
|
|||||||
- Real-time payment status updates
|
- Real-time payment status updates
|
||||||
- Test mode indicators and warnings
|
- 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
|
### Admin Routes
|
||||||
|
|
||||||
- **Payments**: [http://localhost:3000/admin/collections/payments](http://localhost:3000/admin/collections/payments)
|
- **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
|
### Customer Relationship
|
||||||
```typescript
|
```typescript
|
||||||
customerRelationSlug: 'customers',
|
customerRelationSlug: 'customers',
|
||||||
@@ -228,10 +296,13 @@ Request body:
|
|||||||
{
|
{
|
||||||
"amount": 2500,
|
"amount": 2500,
|
||||||
"currency": "USD",
|
"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:
|
Response:
|
||||||
```json
|
```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
|
## Development
|
||||||
|
|
||||||
### File Structure
|
### File Structure
|
||||||
@@ -257,10 +390,23 @@ dev/
|
|||||||
│ ├── test-payment/
|
│ ├── test-payment/
|
||||||
│ │ └── [id]/
|
│ │ └── [id]/
|
||||||
│ │ └── page.tsx # Custom payment UI
|
│ │ └── 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/
|
│ ├── api/
|
||||||
│ │ └── demo/
|
│ │ └── demo/
|
||||||
│ │ └── create-payment/
|
│ │ ├── create-payment/
|
||||||
│ │ └── route.ts # Payment creation endpoint
|
│ │ │ └── route.ts # Payment creation endpoint
|
||||||
|
│ │ ├── invoice/
|
||||||
|
│ │ │ └── [id]/
|
||||||
|
│ │ │ └── route.ts # Invoice fetch endpoint
|
||||||
|
│ │ └── payment/
|
||||||
|
│ │ └── [id]/
|
||||||
|
│ │ └── route.ts # Payment fetch endpoint
|
||||||
│ └── (payload)/ # PayloadCMS admin routes
|
│ └── (payload)/ # PayloadCMS admin routes
|
||||||
├── helpers/
|
├── helpers/
|
||||||
│ └── credentials.ts # Default user credentials
|
│ └── credentials.ts # Default user credentials
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export async function POST(request: Request) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
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) {
|
if (!amount || !currency) {
|
||||||
return Response.json(
|
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({
|
const payment = await payload.create({
|
||||||
collection: 'payments',
|
collection: 'payments',
|
||||||
data: {
|
data: {
|
||||||
@@ -29,10 +41,42 @@ export async function POST(request: Request) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
source: 'demo-ui',
|
source: 'demo-ui',
|
||||||
createdAt: new Date().toISOString(),
|
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({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
payment: {
|
payment: {
|
||||||
@@ -41,6 +85,7 @@ export async function POST(request: Request) {
|
|||||||
amount: payment.amount,
|
amount: payment.amount,
|
||||||
currency: payment.currency,
|
currency: payment.currency,
|
||||||
description: payment.description,
|
description: payment.description,
|
||||||
|
invoiceId: invoice.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
dev/app/page.tsx
100
dev/app/page.tsx
@@ -7,20 +7,39 @@ export default function HomePage() {
|
|||||||
const [paymentId, setPaymentId] = useState<string>('')
|
const [paymentId, setPaymentId] = useState<string>('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string>('')
|
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 () => {
|
const createDemoPayment = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!customerName || !customerEmail) {
|
||||||
|
setError('Customer name and email are required')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/demo/create-payment', {
|
const requestBody = {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
amount: 2500,
|
amount: 2500,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
description: 'Demo payment from custom UI',
|
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(requestBody),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -64,17 +83,80 @@ export default function HomePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{!paymentId ? (
|
{!paymentId ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<button
|
||||||
onClick={createDemoPayment}
|
onClick={createDemoPayment}
|
||||||
disabled={loading}
|
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'}
|
{loading ? 'Creating Payment...' : 'Create Demo Payment'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -102,6 +184,10 @@ export default function HomePage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPaymentId('')
|
setPaymentId('')
|
||||||
setError('')
|
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"
|
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 Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { Suspense } from 'react'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
|
|
||||||
function PaymentSuccessContent() {
|
function PaymentSuccessContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const paymentId = searchParams.get('paymentId')
|
const paymentId = searchParams.get('paymentId')
|
||||||
const amount = searchParams.get('amount')
|
const amount = searchParams.get('amount')
|
||||||
const currency = searchParams.get('currency')
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-green-600 to-emerald-700 flex items-center justify-center p-4">
|
<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>
|
<h3 className="font-semibold text-slate-800 text-lg">What's Next?</h3>
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<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
|
<Link
|
||||||
href="/"
|
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"
|
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'),
|
staticDir: path.resolve(dirname, 'media'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
// Note: No customers collection - the demo uses direct customerInfo fields on invoices
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
db: sqliteAdapter({
|
db: sqliteAdapter({
|
||||||
client: {
|
client: {
|
||||||
@@ -127,25 +63,62 @@ const buildConfigWithSQLite = () => {
|
|||||||
],
|
],
|
||||||
collections: {
|
collections: {
|
||||||
payments: 'payments',
|
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',
|
refunds: 'refunds',
|
||||||
},
|
},
|
||||||
customerRelationSlug: 'customers',
|
// Note: No customerRelationSlug or customerInfoExtractor configured
|
||||||
customerInfoExtractor: (customer) => ({
|
// This allows the demo to work without a customer collection
|
||||||
name: customer.name,
|
// Invoices will use the direct customerInfo and billingAddress fields
|
||||||
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,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ async function seedBillingData(payload: Payload): Promise<void> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
customer2Id = customer2.id
|
customer2Id = customer2.id
|
||||||
|
} else {
|
||||||
|
payload.logger.info('No customers collection found, will use direct customer info in invoices')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed invoices
|
// Seed invoices
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import type {
|
|||||||
CollectionAfterChangeHook,
|
CollectionAfterChangeHook,
|
||||||
CollectionBeforeChangeHook,
|
CollectionBeforeChangeHook,
|
||||||
CollectionBeforeValidateHook,
|
CollectionBeforeValidateHook,
|
||||||
CollectionConfig, Field,
|
CollectionConfig,
|
||||||
|
CollectionSlug,
|
||||||
|
Field,
|
||||||
} from 'payload'
|
} from 'payload'
|
||||||
import type { BillingPluginConfig} from '@/plugin/config';
|
import type { BillingPluginConfig} from '@/plugin/config';
|
||||||
import { defaults } from '@/plugin/config'
|
import { defaults } from '@/plugin/config'
|
||||||
@@ -13,8 +15,12 @@ import type { Invoice } from '@/plugin/types'
|
|||||||
|
|
||||||
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
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',
|
name: 'number',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -33,7 +39,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
position: 'sidebar' as const,
|
position: 'sidebar' as const,
|
||||||
description: 'Link to customer record (optional)',
|
description: 'Link to customer record (optional)',
|
||||||
},
|
},
|
||||||
relationTo: extractSlug(customerRelationSlug),
|
relationTo: customerRelationSlug as any,
|
||||||
required: false,
|
required: false,
|
||||||
}] : []),
|
}] : []),
|
||||||
// Basic customer info fields (embedded)
|
// Basic customer info fields (embedded)
|
||||||
@@ -277,7 +283,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
condition: (data) => data.status === 'paid',
|
condition: (data) => data.status === 'paid',
|
||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
},
|
},
|
||||||
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
relationTo: paymentsSlug,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'notes',
|
name: 'notes',
|
||||||
@@ -294,11 +300,9 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if (overrides?.fields) {
|
|
||||||
fields = overrides.fields({defaultFields: fields})
|
const baseConfig: CollectionConfig = {
|
||||||
}
|
slug: invoicesSlug,
|
||||||
return {
|
|
||||||
slug: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
|
|
||||||
access: {
|
access: {
|
||||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||||
@@ -313,10 +317,68 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
fields,
|
fields,
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [
|
afterChange: [
|
||||||
({ doc, operation, req }) => {
|
async ({ doc, operation, req, previousDoc }) => {
|
||||||
if (operation === 'create') {
|
|
||||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||||
|
|
||||||
|
if (operation === 'create') {
|
||||||
logger.info(`Invoice created: ${doc.number}`)
|
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>[],
|
] satisfies CollectionAfterChangeHook<Invoice>[],
|
||||||
@@ -353,7 +415,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
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')
|
throw new Error('Failed to extract customer information')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,4 +491,12 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
},
|
},
|
||||||
timestamps: true,
|
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 type { BillingPluginConfig} from '../plugin/config';
|
||||||
import { defaults } from '../plugin/config'
|
import { defaults } from '../plugin/config'
|
||||||
import { extractSlug } from '../plugin/utils'
|
import { extractSlug } from '../plugin/utils'
|
||||||
import type { Payment } from '../plugin/types/payments'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import { initProviderPayment } from './hooks'
|
import { initProviderPayment } from './hooks'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
|
// Get slugs for relationships - these need to be determined before building fields
|
||||||
let fields: Field[] = [
|
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',
|
name: 'provider',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
@@ -79,7 +84,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
admin: {
|
admin: {
|
||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
},
|
},
|
||||||
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
|
relationTo: invoicesSlug,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'metadata',
|
name: 'metadata',
|
||||||
@@ -104,7 +109,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
},
|
},
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
|
relationTo: refundsSlug,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'version',
|
name: 'version',
|
||||||
@@ -116,12 +121,10 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
index: true, // Index for optimistic locking performance
|
index: true, // Index for optimistic locking performance
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if (overrides?.fields) {
|
|
||||||
fields = overrides?.fields({defaultFields: fields})
|
const baseConfig: CollectionConfig = {
|
||||||
}
|
slug: paymentsSlug,
|
||||||
return {
|
access: {
|
||||||
slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
|
||||||
access: overrides?.access || {
|
|
||||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||||
read: ({ 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'],
|
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
|
||||||
group: 'Billing',
|
group: 'Billing',
|
||||||
useAsTitle: 'id',
|
useAsTitle: 'id',
|
||||||
...overrides?.admin
|
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
hooks: {
|
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: [
|
beforeChange: [
|
||||||
async ({ data, operation, req, originalDoc }) => {
|
async ({ data, operation, req, originalDoc }) => {
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
@@ -167,4 +203,12 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
},
|
},
|
||||||
timestamps: true,
|
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'
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
// TODO: finish collection overrides
|
// Get slugs for relationships - these need to be determined before building fields
|
||||||
return {
|
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||||
slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
|
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
|
||||||
|
|
||||||
|
const baseConfig: CollectionConfig = {
|
||||||
|
slug: refundsSlug,
|
||||||
access: {
|
access: {
|
||||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||||
@@ -37,7 +40,7 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
|||||||
admin: {
|
admin: {
|
||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
},
|
},
|
||||||
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
relationTo: paymentsSlug,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -120,13 +123,13 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
|||||||
try {
|
try {
|
||||||
const payment = await req.payload.findByID({
|
const payment = await req.payload.findByID({
|
||||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||||
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
collection: paymentsSlug,
|
||||||
}) as Payment
|
}) as Payment
|
||||||
|
|
||||||
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
||||||
await req.payload.update({
|
await req.payload.update({
|
||||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||||
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
collection: paymentsSlug,
|
||||||
data: {
|
data: {
|
||||||
refunds: [...refundIds, doc.id],
|
refunds: [...refundIds, doc.id],
|
||||||
},
|
},
|
||||||
@@ -159,4 +162,12 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
|||||||
},
|
},
|
||||||
timestamps: true,
|
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
|
// Plugin configuration
|
||||||
export interface BillingPluginConfig {
|
export interface BillingPluginConfig {
|
||||||
admin?: {
|
admin?: {
|
||||||
@@ -48,9 +56,9 @@ export interface BillingPluginConfig {
|
|||||||
dashboard?: boolean
|
dashboard?: boolean
|
||||||
}
|
}
|
||||||
collections?: {
|
collections?: {
|
||||||
invoices?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
|
invoices?: CollectionExtension
|
||||||
payments?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
|
payments?: CollectionExtension
|
||||||
refunds?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
|
refunds?: CollectionExtension
|
||||||
}
|
}
|
||||||
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
||||||
customerRelationSlug?: string // Customer collection slug for relationship
|
customerRelationSlug?: string // Customer collection slug for relationship
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
||||||
import type { Id } from './types/index'
|
import type { Id } from './types/index'
|
||||||
|
import type { CollectionExtension } from './config'
|
||||||
|
|
||||||
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
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
|
* Safely cast ID types for PayloadCMS operations
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function findPaymentByProviderId(
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
pluginConfig: BillingPluginConfig
|
pluginConfig: BillingPluginConfig
|
||||||
): Promise<Payment | null> {
|
): Promise<Payment | null> {
|
||||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||||
|
|
||||||
const payments = await payload.find({
|
const payments = await payload.find({
|
||||||
collection: paymentsCollection,
|
collection: paymentsCollection,
|
||||||
@@ -59,7 +59,7 @@ export async function updatePaymentStatus(
|
|||||||
providerData: ProviderData<any>,
|
providerData: ProviderData<any>,
|
||||||
pluginConfig: BillingPluginConfig
|
pluginConfig: BillingPluginConfig
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, fetch the current payment to get the current version
|
// First, fetch the current payment to get the current version
|
||||||
@@ -141,7 +141,7 @@ export async function updateInvoiceOnPaymentSuccess(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!payment.invoice) {return}
|
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'
|
const invoiceId = typeof payment.invoice === 'object'
|
||||||
? payment.invoice.id
|
? payment.invoice.id
|
||||||
: payment.invoice
|
: payment.invoice
|
||||||
|
|||||||
Reference in New Issue
Block a user