feat: add comprehensive demo application with custom payment UI

- Custom test payment UI with modern Tailwind CSS design
- Payment method selection (iDEAL, Credit Card, PayPal, Apple Pay, Bank Transfer)
- Test scenario selection (6 scenarios: success, delayed, cancelled, declined, expired, pending)
- Real-time payment status polling
- Success and failure result pages with payment details
- Interactive demo homepage at root path
- Sample data seeding (customers, invoices)
- Customers collection with auto-sync to invoices
- Comprehensive documentation (README.md, DEMO_GUIDE.md)
- Proper cursor styles for all interactive elements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 14:03:28 +01:00
parent fa22900db5
commit 3508418698
18 changed files with 2505 additions and 459 deletions

View File

@@ -1,44 +0,0 @@
# @xtr-dev/payload-billing
## 0.1.0 (Initial Release)
### Features
- **Payment Providers**: Initial support for Stripe, Mollie, and Test providers
- **PayloadCMS Integration**: Pre-configured collections for payments, customers, invoices, and refunds
- **Test Provider**: Full-featured test payment provider for local development
- **TypeScript Support**: Complete TypeScript definitions and type safety
- **Webhook Handling**: Robust webhook processing for all supported providers
- **Currency Support**: Multi-currency support with validation and formatting utilities
- **Logging**: Structured logging system for debugging and monitoring
- **Validation**: Comprehensive data validation using Zod schemas
### Collections
- **Payments**: Track payment status, amounts, and provider-specific data
- **Customers**: Customer management with billing information and relationships
- **Invoices**: Invoice generation with line items and status tracking
- **Refunds**: Refund tracking with relationship to original payments
### Provider Features
#### Test Provider
- Configurable auto-completion of payments
- Failure simulation for testing error scenarios
- Delay simulation for testing async operations
- In-memory storage for development
- Full webhook event simulation
#### Extensible Architecture
- Common provider interface for easy extension
- Provider registry system
- Standardized error handling
- Consistent logging across providers
### Developer Experience
- **Testing**: Comprehensive test suite with Jest
- **Build System**: Modern build setup with tsup
- **Linting**: ESLint configuration with TypeScript support
- **Documentation**: Complete API documentation and usage examples
- **Development**: Hot reloading and watch mode support

166
CLAUDE.md
View File

@@ -1,166 +0,0 @@
# PayloadCMS Billing Plugin Development Guidelines
## Project Overview
This is a PayloadCMS plugin that provides billing and payment functionality with flexible customer data management and invoice generation capabilities.
## Architecture Principles
### Core Design
- **TypeScript First**: Full TypeScript support with strict typing throughout
- **PayloadCMS Integration**: Deep integration with Payload collections, hooks, and admin UI
- **Flexible Customer Data**: Support for both relationship-based and embedded customer information
- **Callback-based Syncing**: Use customer info extractors to keep data in sync
### Collections Structure
- **Payments**: Core payment tracking with provider-specific data
- **Customers**: Customer management with billing information (optional)
- **Invoices**: Invoice generation with embedded customer info and optional customer relationship
- **Refunds**: Refund tracking and management
## Code Organization
```
src/
├── collections/ # PayloadCMS collection configurations
├── types/ # TypeScript type definitions
└── index.ts # Main plugin entry point
```
## Customer Data Management
### Customer Info Extractor Pattern
The plugin uses a callback-based approach to extract customer information from customer relationships:
```typescript
// Define how to extract customer info from your customer collection
const customerInfoExtractor: CustomerInfoExtractor = (customer) => ({
name: customer.name,
email: customer.email,
phone: customer.phone,
company: customer.company,
taxId: customer.taxId,
billingAddress: {
line1: customer.address.line1,
line2: customer.address.line2,
city: customer.address.city,
state: customer.address.state,
postalCode: customer.address.postalCode,
country: customer.address.country,
}
})
```
### Invoice Customer Data Options
1. **With Customer Relationship + Extractor**:
- Customer relationship required
- Customer info auto-populated and read-only
- Syncs automatically when customer changes
2. **With Customer Relationship (no extractor)**:
- Customer relationship optional
- Customer info manually editable
- Either relationship OR customer info required
3. **No Customer Collection**:
- Customer info fields always required and editable
- No relationship field available
## Plugin Configuration
### Basic Configuration
```typescript
import { billingPlugin, defaultCustomerInfoExtractor } from '@xtr-dev/payload-billing'
billingPlugin({
collections: {
customers: 'customers', // Customer collection slug
invoices: 'invoices', // Invoice collection slug
payments: 'payments', // Payment collection slug
refunds: 'refunds', // Refund collection slug
customerRelation: false, // Disable customer relationship
// OR
customerRelation: 'clients', // Use custom collection slug
},
customerInfoExtractor: defaultCustomerInfoExtractor, // For built-in customer collection
})
```
### Custom Customer Info Extractor
```typescript
billingPlugin({
customerInfoExtractor: (customer) => ({
name: customer.fullName,
email: customer.contactEmail,
phone: customer.phoneNumber,
company: customer.companyName,
taxId: customer.vatNumber,
billingAddress: {
line1: customer.billing.street,
line2: customer.billing.apartment,
city: customer.billing.city,
state: customer.billing.state,
postalCode: customer.billing.zip,
country: customer.billing.countryCode,
}
})
})
```
## Development Guidelines
### TypeScript Guidelines
- Use strict TypeScript configuration
- All customer info extractors must implement `CustomerInfoExtractor` interface
- Ensure consistent camelCase naming for all address fields
### PayloadCMS Integration
- Follow PayloadCMS plugin patterns and conventions
- Use proper collection configurations with access control
- Utilize PayloadCMS hooks for data syncing and validation
### Field Validation Rules
- When using `customerInfoExtractor`: customer relationship is required, customer info auto-populated
- When not using extractor: either customer relationship OR customer info must be provided
- When no customer collection: customer info is always required
## Collections API
### Invoice Collection Features
- Automatic invoice number generation (INV-{timestamp})
- Currency validation (3-letter ISO codes)
- Automatic due date setting (30 days from creation)
- Line item total calculations
- Customer info syncing via hooks
### Customer Data Syncing
The `beforeChange` hook automatically:
1. Detects when customer relationship changes
2. Fetches customer data from the related collection
3. Extracts customer info using the provided callback
4. Updates invoice with extracted data
5. Maintains data consistency across updates
## Error Handling
### Validation Errors
- Customer relationship required when using extractor
- Customer info required when not using relationship
- Proper error messages for missing required fields
### Data Extraction Errors
- Failed customer fetches are logged and throw user-friendly errors
- Invalid customer data is handled gracefully
## Performance Considerations
- Customer data is only fetched when relationship changes
- Read-only fields prevent unnecessary manual edits
- Efficient hook execution with proper change detection
## Documentation Requirements
- Document all public APIs with examples
- Provide clear customer info extractor examples
- Include configuration guides for different use cases
- Maintain up-to-date TypeScript documentation

223
dev/DEMO_GUIDE.md Normal file
View File

@@ -0,0 +1,223 @@
# Demo Project Quick Start Guide
This guide will help you quickly get started with the billing plugin demo.
## 🚀 Quick Start
1. **Install dependencies** (if not already done):
```bash
pnpm install
```
2. **Start the development server**:
```bash
pnpm dev
```
3. **Access the demo**:
- Open [http://localhost:3000](http://localhost:3000)
- Login with `dev@payloadcms.com` / `test` if prompted
## 🎯 What's Included
### Custom Test Payment UI
A beautiful, modern payment interface built with React and Tailwind CSS that demonstrates:
- Payment method selection (iDEAL, Credit Card, PayPal, Apple Pay, Bank Transfer)
- Test scenario selection (success, failure, cancellation, etc.)
- Real-time payment status updates
- Test mode indicators and warnings
- Responsive design
**Location**: `/dev/app/test-payment/[id]/page.tsx`
### Interactive Demo Page
A landing page that showcases the plugin features and allows you to:
- Create test payments with one click
- Navigate to custom payment UI
- Access admin collections
- Learn about the plugin features
**Location**: `/dev/app/page.tsx`
### Customer Management
Full customer collection with:
- Name, email, phone, company
- Tax ID support
- Complete address fields
- Auto-sync with invoices via `customerInfoExtractor`
**Location**: Configured in `/dev/payload.config.ts`
### Sample Data
Comprehensive seed data including:
- 2 sample customers
- 2 invoices (1 paid, 1 open)
- 4 payments (various statuses)
- 1 refund
**Location**: `/dev/seed.ts`
### Custom API Routes
Demo API endpoint for creating payments:
- `POST /api/demo/create-payment`
**Location**: `/dev/app/api/demo/create-payment/route.ts`
## 🧪 Testing the Flow
### Complete Payment Flow Test
1. **Go to the demo page**: [http://localhost:3000](http://localhost:3000)
2. **Click "Create Demo Payment"** - This creates a test payment
3. **Click "Go to Payment Page"** - Opens the custom payment UI
4. **Select a payment method** - Choose any method (e.g., Credit Card)
5. **Select a test scenario** - Try different scenarios:
- **Instant Success**: See immediate payment success
- **Delayed Success**: See processing indicator, then success
- **Declined Payment**: See failure handling
- **Cancelled Payment**: See cancellation flow
6. **Click "Process Test Payment"** - Watch the payment process
7. **View in admin** - After success, you'll be redirected to the payments list
### Testing with Different Scenarios
Each scenario simulates a different payment outcome:
| Scenario | Delay | Outcome | Use Case |
|----------|-------|---------|----------|
| Instant Success | 0ms | Success | Testing happy path |
| Delayed Success | 3s | Success | Testing async processing |
| Cancelled Payment | 1s | Cancelled | Testing user cancellation |
| Declined Payment | 2s | Failed | Testing payment failures |
| Expired Payment | 5s | Cancelled | Testing timeout handling |
| Pending Payment | 1.5s | Pending | Testing long-running payments |
## 📊 Viewing Data
### Admin Collections
After running the demo, explore the seeded data:
1. **Payments** ([http://localhost:3000/admin/collections/payments](http://localhost:3000/admin/collections/payments))
- View all payment transactions
- See payment statuses and provider data
- Check linked invoices
2. **Invoices** ([http://localhost:3000/admin/collections/invoices](http://localhost:3000/admin/collections/invoices))
- View generated invoices
- See line items and totals
- Check customer relationships
3. **Refunds** ([http://localhost:3000/admin/collections/refunds](http://localhost:3000/admin/collections/refunds))
- View processed refunds
- See refund amounts and reasons
4. **Customers** ([http://localhost:3000/admin/collections/customers](http://localhost:3000/admin/collections/customers))
- View customer information
- Edit customer details (invoices will auto-update!)
## 🔧 Configuration Highlights
### Plugin Configuration
```typescript
billingPlugin({
providers: [
testProvider({
enabled: true,
customUiRoute: '/test-payment', // Custom UI route
testModeIndicators: {
showWarningBanners: true,
showTestBadges: true,
consoleWarnings: true
}
})
],
collections: {
payments: 'payments',
invoices: 'invoices',
refunds: 'refunds',
},
customerRelationSlug: 'customers',
customerInfoExtractor: (customer) => ({
// Auto-extract customer info for invoices
name: customer.name,
email: customer.email,
// ... more fields
}),
})
```
## 🎨 Customization Ideas
### 1. Modify the Payment UI
Edit `/dev/app/test-payment/[id]/page.tsx` to:
- Change colors and styling
- Add your brand logo
- Modify the layout
- Add additional fields
### 2. Add More Test Scenarios
Edit `testProvider` config to add custom scenarios:
```typescript
testProvider({
scenarios: [
{
id: 'custom-scenario',
name: 'Custom Scenario',
description: 'Your custom test scenario',
outcome: 'paid',
delay: 2000
}
]
})
```
### 3. Create Invoice Templates
Add invoice generation endpoints that use specific templates
### 4. Add Webhooks
Create webhook handlers to process real payment events
## 💡 Tips
- **Reset Data**: Delete `dev/payload.sqlite` and restart to re-seed
- **Check Console**: Test provider logs all events to the console
- **Test Mode Warnings**: Notice the warning banners and badges in test mode
- **Auto-sync**: Edit a customer's info and see invoices update automatically
## 🐛 Troubleshooting
### Payment not processing?
- Check browser console for errors
- Check server console for logs
- Verify the test provider is enabled in config
### Custom UI not loading?
- Check that `customUiRoute` matches your page route
- Verify the payment ID is valid (starts with `test_pay_`)
### Types not matching?
Run `pnpm dev:generate-types` to regenerate Payload types
## 📚 Next Steps
1. **Explore the Admin** - Login and browse the collections
2. **Create Custom Invoices** - Try creating invoices with line items
3. **Process Refunds** - Create refunds for successful payments
4. **Add Real Providers** - Configure Stripe or Mollie (see README.md)
5. **Build Your Integration** - Use this as a template for your app
## 🎓 Learning Resources
- Review `/dev/seed.ts` for data structure examples
- Check `/dev/payload.config.ts` for plugin configuration
- See `/dev/app/test-payment/[id]/page.tsx` for UI integration
- Read the main [README.md](../README.md) for API documentation
Happy testing! 🚀

350
dev/README.md Normal file
View File

@@ -0,0 +1,350 @@
# Billing Plugin Demo Application
This is a demo application showcasing the `@xtr-dev/payload-billing` plugin for PayloadCMS 3.x.
## Features
- 🧪 **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
- 🎨 **Custom Payment UI** with modern design
- 📊 **Sample Data** for quick testing
## Getting Started
### Installation
```bash
# Install dependencies
pnpm install
```
### Running the Demo
```bash
# Start the development server
pnpm dev
# The application will be available at http://localhost:3000
```
### Default Credentials
- **Email**: `dev@payloadcms.com`
- **Password**: `test`
## Demo Routes
### Interactive Demo Page
Visit [http://localhost:3000](http://localhost:3000) to access the interactive demo page where you can:
- Create test payments
- View the custom payment UI
- Test different payment scenarios
- Navigate to admin collections
### Custom Payment UI
The custom test payment UI is available at:
```
http://localhost:3000/test-payment/{payment-id}
```
This page demonstrates:
- Modern, responsive payment interface
- Payment method selection
- Test scenario selection (success, failure, cancellation, etc.)
- Real-time payment status updates
- Test mode indicators and warnings
### Admin Routes
- **Payments**: [http://localhost:3000/admin/collections/payments](http://localhost:3000/admin/collections/payments)
- **Invoices**: [http://localhost:3000/admin/collections/invoices](http://localhost:3000/admin/collections/invoices)
- **Refunds**: [http://localhost:3000/admin/collections/refunds](http://localhost:3000/admin/collections/refunds)
- **Customers**: [http://localhost:3000/admin/collections/customers](http://localhost:3000/admin/collections/customers)
## Sample Data
The application includes seed data with:
- **2 Customers**
- John Doe (Acme Corporation)
- Jane Smith (Tech Innovations Inc.)
- **2 Invoices**
- Paid invoice with web development services
- Open invoice with subscription and additional users
- **4 Payments**
- Successful payment linked to invoice
- Pending payment linked to invoice
- Standalone successful payment
- Failed payment example
- **1 Refund**
- Partial refund on a successful payment
To reset the sample data:
```bash
# Delete the database file
rm dev/payload.sqlite
# Restart the server (will re-seed automatically)
pnpm dev
```
## Configuration
The plugin is configured in `dev/payload.config.ts` with:
### Test Provider Setup
```typescript
testProvider({
enabled: true,
testModeIndicators: {
showWarningBanners: true,
showTestBadges: true,
consoleWarnings: true
},
customUiRoute: '/test-payment',
})
```
### Customer Relationship
```typescript
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,
})
```
## Test Payment Scenarios
The test provider includes the following scenarios:
1. **Instant Success** - Payment succeeds immediately
2. **Delayed Success** - Payment succeeds after a delay (3s)
3. **Cancelled Payment** - User cancels the payment (1s)
4. **Declined Payment** - Payment is declined by the provider (2s)
5. **Expired Payment** - Payment expires before completion (5s)
6. **Pending Payment** - Payment remains in pending state (1.5s)
## Payment Methods
The test provider supports these payment methods:
- 🏦 iDEAL
- 💳 Credit Card
- 🅿️ PayPal
- 🍎 Apple Pay
- 🏛️ Bank Transfer
## API Examples
### Creating a Payment (Local API)
```typescript
import { getPayload } from 'payload'
import configPromise from '@payload-config'
const payload = await getPayload({ config: configPromise })
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'test',
amount: 2500, // $25.00 in cents
currency: 'USD',
description: 'Demo payment',
status: 'pending',
}
})
// The payment will have a providerId that can be used in the custom UI
console.log(`Payment URL: /test-payment/${payment.providerId}`)
```
### Creating an Invoice with Customer
```typescript
const invoice = await payload.create({
collection: 'invoices',
data: {
customer: 'customer-id-here',
currency: 'USD',
items: [
{
description: 'Service',
quantity: 1,
unitAmount: 5000 // $50.00
}
],
taxAmount: 500, // $5.00
status: 'open'
}
})
```
### REST API Example
```bash
# Create a payment
curl -X POST http://localhost:3000/api/payments \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"provider": "test",
"amount": 2500,
"currency": "USD",
"description": "Demo payment",
"status": "pending"
}'
```
## Custom Routes
The demo includes custom API routes:
### Create Payment
```
POST /api/demo/create-payment
```
Request body:
```json
{
"amount": 2500,
"currency": "USD",
"description": "Demo payment"
}
```
Response:
```json
{
"success": true,
"payment": {
"id": "test_pay_1234567890_abc123",
"paymentId": "67890",
"amount": 2500,
"currency": "USD",
"description": "Demo payment"
}
}
```
## Development
### File Structure
```
dev/
├── app/
│ ├── page.tsx # Interactive demo page (root)
│ ├── test-payment/
│ │ └── [id]/
│ │ └── page.tsx # Custom payment UI
│ ├── api/
│ │ └── demo/
│ │ └── create-payment/
│ │ └── route.ts # Payment creation endpoint
│ └── (payload)/ # PayloadCMS admin routes
├── helpers/
│ └── credentials.ts # Default user credentials
├── payload.config.ts # PayloadCMS configuration
├── seed.ts # Sample data seeding
└── README.md # This file
```
### Modifying the Demo
To customize the demo:
1. **Add more test scenarios**: Edit the `testProvider` config in `payload.config.ts`
2. **Customize the payment UI**: Edit `app/test-payment/[id]/page.tsx`
3. **Add more sample data**: Edit `seed.ts`
4. **Add custom collections**: Add to `collections` array in `payload.config.ts`
### Testing Different Providers
To test with real payment providers:
```typescript
// Install the provider
pnpm add stripe
// or
pnpm add @mollie/api-client
// Update payload.config.ts
import { stripeProvider, mollieProvider } from '../src/providers'
billingPlugin({
providers: [
stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
mollieProvider({
apiKey: process.env.MOLLIE_API_KEY!,
webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
}),
// Keep test provider for development
testProvider({ enabled: true }),
],
// ... rest of config
})
```
## Troubleshooting
### Database Issues
If you encounter database errors:
```bash
# Delete the database
rm dev/payload.sqlite
# Regenerate types
pnpm dev:generate-types
# Restart the server
pnpm dev
```
### Port Already in Use
If port 3000 is already in use:
```bash
# Use a different port
PORT=3001 pnpm dev
```
### TypeScript Errors
Regenerate Payload types:
```bash
pnpm dev:generate-types
```
## Resources
- [Plugin Documentation](../README.md)
- [PayloadCMS Documentation](https://payloadcms.com/docs)
- [GitHub Repository](https://github.com/xtr-dev/payload-billing)
## License
MIT

View File

@@ -0,0 +1,56 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export async function POST(request: Request) {
try {
const payload = await getPayload({
config: configPromise,
})
const body = await request.json()
const { amount, currency, description } = body
if (!amount || !currency) {
return Response.json(
{ success: false, error: 'Amount and currency are required' },
{ status: 400 }
)
}
// Create a payment using the test provider
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'test',
amount,
currency,
description: description || 'Demo payment',
status: 'pending',
metadata: {
source: 'demo-ui',
createdAt: new Date().toISOString(),
},
},
})
return Response.json({
success: true,
payment: {
id: payment.providerId, // Use the test provider ID for the UI
paymentId: payment.id,
amount: payment.amount,
currency: payment.currency,
description: payment.description,
},
})
} catch (error) {
console.error('Failed to create payment:', error)
return Response.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create payment',
},
{ status: 500 }
)
}
}

1
dev/app/globals.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

19
dev/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Billing Plugin Demo - PayloadCMS',
description: 'Demo application for @xtr-dev/payload-billing plugin',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

183
dev/app/page.tsx Normal file
View File

@@ -0,0 +1,183 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
export default function HomePage() {
const [paymentId, setPaymentId] = useState<string>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
const createDemoPayment = async () => {
setLoading(true)
setError('')
try {
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',
}),
})
const data = await response.json()
if (data.success) {
setPaymentId(data.payment.id)
} else {
setError(data.error || 'Failed to create payment')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-700 p-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-xl shadow-2xl overflow-hidden">
<div className="bg-gradient-to-r from-blue-600 to-purple-600 p-8 text-white">
<h1 className="text-4xl font-bold mb-2">Billing Plugin Demo</h1>
<p className="text-blue-100">
Test the @xtr-dev/payload-billing plugin with the test provider
</p>
</div>
<div className="p-8">
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-800 mb-4">
🎮 Interactive Demo
</h2>
<p className="text-slate-600 mb-6">
This demo shows how to integrate the billing plugin into your application. Click
the button below to create a test payment and see the custom payment UI in action.
</p>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-800 mb-4">
Create Test Payment
</h3>
{!paymentId ? (
<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"
>
{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">
{error}
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-800 font-semibold mb-2">
<span></span>
<span>Payment Created Successfully!</span>
</div>
<p className="text-sm text-green-700">
Payment ID: <code className="bg-green-100 px-2 py-1 rounded">{paymentId}</code>
</p>
</div>
<div className="flex gap-3">
<Link
href={`/test-payment/${paymentId}`}
className="bg-gradient-to-r from-green-600 to-green-700 text-white px-6 py-3 rounded-lg font-semibold hover:shadow-lg transition-all inline-block cursor-pointer"
>
Go to Payment Page
</Link>
<button
onClick={() => {
setPaymentId('')
setError('')
}}
className="bg-slate-200 text-slate-700 px-6 py-3 rounded-lg font-semibold hover:bg-slate-300 transition-all cursor-pointer"
>
Create Another
</button>
</div>
</div>
)}
</div>
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-800 mb-4">
📚 Quick Links
</h2>
<div className="grid md:grid-cols-2 gap-4">
<Link
href="/admin/collections/payments"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">💳 Payments</div>
<div className="text-sm text-slate-600">View all payment transactions</div>
</Link>
<Link
href="/admin/collections/invoices"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">🧾 Invoices</div>
<div className="text-sm text-slate-600">Manage invoices and billing</div>
</Link>
<Link
href="/admin/collections/refunds"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">🔄 Refunds</div>
<div className="text-sm text-slate-600">Process and track refunds</div>
</Link>
<Link
href="/admin/collections/customers"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">👥 Customers</div>
<div className="text-sm text-slate-600">Manage customer information</div>
</Link>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 className="text-xl font-bold text-slate-800 mb-3">
💡 About This Demo
</h2>
<div className="space-y-3 text-slate-700">
<p>
This demo application showcases the <code className="bg-blue-100 px-2 py-1 rounded">@xtr-dev/payload-billing</code> plugin
for PayloadCMS 3.x with the following features:
</p>
<ul className="list-disc list-inside space-y-2 ml-4">
<li>Test payment provider with customizable scenarios</li>
<li>Custom payment UI page with modern design</li>
<li>Customer relationship management with auto-sync</li>
<li>Invoice generation with line items and tax calculation</li>
<li>Refund processing and tracking</li>
<li>Sample data seeding for quick testing</li>
</ul>
<p className="pt-2">
The test provider allows you to simulate different payment outcomes including
success, failure, cancellation, and more - perfect for development and testing!
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
'use client'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function PaymentFailedContent() {
const searchParams = useSearchParams()
const paymentId = searchParams.get('paymentId')
const reason = searchParams.get('reason') || 'unknown'
const amount = searchParams.get('amount')
const currency = searchParams.get('currency')
const getReasonText = (reason: string) => {
switch (reason) {
case 'failed':
return 'Payment was declined'
case 'cancelled':
return 'Payment was cancelled'
case 'expired':
return 'Payment session expired'
default:
return 'Payment could not be completed'
}
}
const getReasonDescription = (reason: string) => {
switch (reason) {
case 'failed':
return 'The payment provider declined the transaction. This is a simulated failure for testing purposes.'
case 'cancelled':
return 'The payment was cancelled before completion. You can try again with a different test scenario.'
case 'expired':
return 'The payment session timed out. Please create a new payment to try again.'
default:
return 'An unexpected error occurred during payment processing.'
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-red-600 to-orange-700 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-xl shadow-2xl overflow-hidden">
<div className="bg-gradient-to-r from-red-600 to-orange-600 p-8 text-white text-center">
<div className="mb-4">
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center mx-auto">
<svg
className="w-12 h-12 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
<h1 className="text-4xl font-bold mb-2">Payment {reason.charAt(0).toUpperCase() + reason.slice(1)}</h1>
<p className="text-red-100 text-lg">
{getReasonText(reason)}
</p>
</div>
<div className="p-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-8">
<h2 className="font-semibold text-red-900 mb-3 text-lg">
What Happened?
</h2>
<p className="text-red-800 mb-4">
{getReasonDescription(reason)}
</p>
<div className="space-y-3 pt-4 border-t border-red-200">
{paymentId && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Payment ID:</span>
<code className="bg-red-100 text-red-800 px-3 py-1 rounded font-mono text-sm">
{paymentId}
</code>
</div>
)}
{amount && currency && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Amount:</span>
<span className="text-red-900 font-bold text-xl">
{currency.toUpperCase()} {(parseInt(amount) / 100).toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-slate-600">Status:</span>
<span className="bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold capitalize">
{reason}
</span>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-slate-800 text-lg">Try Again</h3>
<div className="grid gap-3">
<Link
href="/"
className="flex items-center justify-between p-4 border-2 border-red-300 bg-red-50 rounded-lg hover:border-red-500 hover:bg-red-100 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-red-800 group-hover:text-red-900">
🔄 Try Another Payment
</div>
<div className="text-sm text-red-700">
Create a new test payment with different scenario
</div>
</div>
<svg
className="w-5 h-5 text-red-500 group-hover:text-red-700"
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="/admin/collections/payments"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-blue-700">
💳 View Payment History
</div>
<div className="text-sm text-slate-600">
Check all payments in admin
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
</div>
</div>
<div className="mt-8 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<strong>💡 Testing Tip:</strong> This failure was simulated using the test provider.
Try selecting a different test scenario like "Instant Success" or "Delayed Success"
to see a successful payment flow.
</p>
</div>
</div>
</div>
</div>
)
}
export default function PaymentFailedPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-red-600 to-orange-700 flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
}>
<PaymentFailedContent />
</Suspense>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function PaymentSuccessContent() {
const searchParams = useSearchParams()
const paymentId = searchParams.get('paymentId')
const amount = searchParams.get('amount')
const currency = searchParams.get('currency')
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="max-w-2xl w-full bg-white rounded-xl shadow-2xl overflow-hidden">
<div className="bg-gradient-to-r from-green-600 to-emerald-600 p-8 text-white text-center">
<div className="mb-4">
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center mx-auto">
<svg
className="w-12 h-12 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<h1 className="text-4xl font-bold mb-2">Payment Successful!</h1>
<p className="text-green-100 text-lg">
Your test payment has been processed successfully
</p>
</div>
<div className="p-8">
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-8">
<h2 className="font-semibold text-green-900 mb-4 text-lg">
Payment Details
</h2>
<div className="space-y-3">
{paymentId && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Payment ID:</span>
<code className="bg-green-100 text-green-800 px-3 py-1 rounded font-mono text-sm">
{paymentId}
</code>
</div>
)}
{amount && currency && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Amount:</span>
<span className="text-green-900 font-bold text-xl">
{currency.toUpperCase()} {(parseInt(amount) / 100).toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-slate-600">Status:</span>
<span className="bg-green-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
Succeeded
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Provider:</span>
<span className="text-slate-900 font-medium">Test Provider</span>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-slate-800 text-lg">What's Next?</h3>
<div className="grid gap-3">
<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"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-green-700">
🏠 Back to Demo
</div>
<div className="text-sm text-slate-600">
Try another test payment
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-green-600"
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="/admin/collections/payments"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-blue-700">
💳 View All Payments
</div>
<div className="text-sm text-slate-600">
Check payment history in admin
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-blue-600"
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="/admin/collections/invoices"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-purple-500 hover:bg-purple-50 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-purple-700">
🧾 View Invoices
</div>
<div className="text-sm text-slate-600">
Check invoices in admin
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
</div>
</div>
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>💡 Demo Tip:</strong> This was a simulated payment using the test provider.
In production, you would integrate with real providers like Stripe or Mollie.
</p>
</div>
</div>
</div>
</div>
)
}
export default function PaymentSuccessPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-green-600 to-emerald-700 flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
}>
<PaymentSuccessContent />
</Suspense>
)
}

View File

@@ -0,0 +1,291 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
interface PaymentMethod {
id: string
name: string
icon: string
}
interface Scenario {
id: string
name: string
description: string
outcome: string
delay?: number
}
interface TestProviderConfig {
enabled: boolean
scenarios: Scenario[]
methods: PaymentMethod[]
testModeIndicators: {
showWarningBanners: boolean
showTestBadges: boolean
consoleWarnings: boolean
}
defaultDelay: number
customUiRoute: string
}
interface PaymentSession {
id: string
amount: number
currency: string
description?: string
}
export default function TestPaymentPage() {
const params = useParams()
const router = useRouter()
const paymentId = params.id as string
const [config, setConfig] = useState<TestProviderConfig | null>(null)
const [session, setSession] = useState<PaymentSession | null>(null)
const [selectedMethod, setSelectedMethod] = useState<string | null>(null)
const [selectedScenario, setSelectedScenario] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [status, setStatus] = useState<{
type: 'idle' | 'processing' | 'success' | 'error'
message: string
}>({ type: 'idle', message: '' })
useEffect(() => {
// Load test provider config
fetch('/api/payload-billing/test/config')
.then((res) => res.json())
.then((data) => {
setConfig(data)
if (data.testModeIndicators?.consoleWarnings) {
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface')
}
})
.catch((err) => {
console.error('Failed to load test provider config:', err)
})
// Load payment session (mock data for demo)
setSession({
id: paymentId,
amount: 2500,
currency: 'USD',
description: 'Demo payment for testing the billing plugin',
})
}, [paymentId])
const handleProcessPayment = async () => {
if (!selectedMethod || !selectedScenario) return
setProcessing(true)
setStatus({ type: 'processing', message: 'Initiating payment...' })
try {
const response = await fetch('/api/payload-billing/test/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentId,
scenarioId: selectedScenario,
method: selectedMethod,
}),
})
const result = await response.json()
if (result.success) {
const scenario = config?.scenarios.find((s) => s.id === selectedScenario)
setStatus({
type: 'processing',
message: `Processing payment with ${scenario?.name}...`,
})
// Poll for status updates
setTimeout(() => pollStatus(), result.delay || 1000)
} else {
throw new Error(result.error || 'Failed to process payment')
}
} catch (error) {
setStatus({
type: 'error',
message: error instanceof Error ? error.message : 'An error occurred',
})
setProcessing(false)
}
}
const pollStatus = async () => {
try {
const response = await fetch(`/api/payload-billing/test/status/${paymentId}`)
const result = await response.json()
if (result.status === 'paid') {
setStatus({ type: 'success', message: '✅ Payment successful!' })
setTimeout(() => {
const params = new URLSearchParams({
paymentId: paymentId,
amount: session.amount.toString(),
currency: session.currency,
})
router.push(`/payment-success?${params.toString()}`)
}, 2000)
} else if (['failed', 'cancelled', 'expired'].includes(result.status)) {
setStatus({ type: 'error', message: `❌ Payment ${result.status}` })
setTimeout(() => {
const params = new URLSearchParams({
paymentId: paymentId,
amount: session.amount.toString(),
currency: session.currency,
reason: result.status,
})
router.push(`/payment-failed?${params.toString()}`)
}, 2000)
} else if (result.status === 'pending') {
setStatus({ type: 'processing', message: 'Payment is still pending...' })
setTimeout(() => pollStatus(), 2000)
}
} catch (error) {
console.error('[Test Provider] Failed to poll status:', error)
setStatus({ type: 'error', message: 'Failed to check payment status' })
setProcessing(false)
}
}
if (!config || !session) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-purple-700 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl p-8">
<div className="animate-pulse">Loading...</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-purple-700 p-4 md:p-8">
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-xl shadow-2xl overflow-hidden">
{config.testModeIndicators.showWarningBanners && (
<div className="bg-gradient-to-r from-orange-500 to-red-500 text-white px-6 py-3 text-center font-semibold">
🧪 TEST MODE - This is a simulated payment for development purposes
</div>
)}
<div className="bg-slate-50 px-8 py-6 border-b border-slate-200">
<div className="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-slate-800">Test Payment Checkout</h1>
{config.testModeIndicators.showTestBadges && (
<span className="bg-slate-600 text-white px-3 py-1 rounded text-xs font-bold uppercase">
Test
</span>
)}
</div>
<div className="text-3xl font-bold text-green-600 mb-3">
{session.currency.toUpperCase()} {(session.amount / 100).toFixed(2)}
</div>
{session.description && (
<p className="text-slate-600 text-base">{session.description}</p>
)}
</div>
<div className="p-8">
{/* Payment Methods */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
💳 Select Payment Method
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{config.methods.map((method) => (
<button
key={method.id}
onClick={() => setSelectedMethod(method.id)}
disabled={processing}
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
selectedMethod === method.id
? 'border-blue-500 bg-blue-500 text-white shadow-lg'
: 'border-slate-200 bg-white text-slate-700 hover:border-blue-300 hover:bg-blue-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="text-2xl mb-2">{method.icon}</div>
<div className="text-sm font-medium">{method.name}</div>
</button>
))}
</div>
</div>
{/* Test Scenarios */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
🎭 Select Test Scenario
</h2>
<div className="space-y-3">
{config.scenarios.map((scenario) => (
<button
key={scenario.id}
onClick={() => setSelectedScenario(scenario.id)}
disabled={processing}
className={`w-full p-4 rounded-lg border-2 text-left transition-all cursor-pointer ${
selectedScenario === scenario.id
? 'border-green-500 bg-green-500 text-white shadow-lg'
: 'border-slate-200 bg-white text-slate-700 hover:border-green-300 hover:bg-green-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="font-semibold mb-1">{scenario.name}</div>
<div className={`text-sm ${selectedScenario === scenario.id ? 'text-white/90' : 'text-slate-600'}`}>
{scenario.description}
</div>
</button>
))}
</div>
</div>
{/* Process Button */}
<button
onClick={handleProcessPayment}
disabled={!selectedMethod || !selectedScenario || processing}
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white font-semibold py-4 rounded-lg transition-all hover:shadow-lg hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none cursor-pointer"
>
{processing ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></span>
Processing...
</span>
) : (
'Process Test Payment'
)}
</button>
{/* Status Message */}
{status.type !== 'idle' && (
<div
className={`mt-6 p-4 rounded-lg text-center font-semibold ${
status.type === 'processing'
? 'bg-yellow-50 text-yellow-800 border border-yellow-200'
: status.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{status.type === 'processing' && (
<span className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-yellow-800 mr-2"></span>
)}
{status.message}
</div>
)}
</div>
</div>
{/* Info Card */}
<div className="mt-6 bg-white/10 backdrop-blur-sm text-white rounded-lg p-6">
<h3 className="font-semibold mb-2">💡 Demo Information</h3>
<p className="text-sm text-white/90">
This is a custom test payment UI for the @xtr-dev/payload-billing plugin. Select a
payment method and scenario to simulate different payment outcomes. The payment will be
processed according to the selected scenario.
</p>
</div>
</div>
</div>
)
}

View File

@@ -69,6 +69,7 @@ export interface Config {
collections: { collections: {
posts: Post; posts: Post;
media: Media; media: Media;
customers: Customer;
payments: Payment; payments: Payment;
invoices: Invoice; invoices: Invoice;
refunds: Refund; refunds: Refund;
@@ -81,6 +82,7 @@ export interface Config {
collectionsSelect: { collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>; posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>; media: MediaSelect<false> | MediaSelect<true>;
customers: CustomersSelect<false> | CustomersSelect<true>;
payments: PaymentsSelect<false> | PaymentsSelect<true>; payments: PaymentsSelect<false> | PaymentsSelect<true>;
invoices: InvoicesSelect<false> | InvoicesSelect<true>; invoices: InvoicesSelect<false> | InvoicesSelect<true>;
refunds: RefundsSelect<false> | RefundsSelect<true>; refunds: RefundsSelect<false> | RefundsSelect<true>;
@@ -148,6 +150,28 @@ export interface Media {
focalX?: number | null; focalX?: number | null;
focalY?: number | null; focalY?: number | null;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers".
*/
export interface Customer {
id: number;
name: string;
email: string;
phone?: string | null;
company?: string | null;
taxId?: string | null;
address?: {
line1?: string | null;
line2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payments". * via the `definition` "payments".
@@ -213,17 +237,21 @@ export interface Invoice {
*/ */
number: string; number: string;
/** /**
* Customer billing information * Link to customer record (optional)
*/ */
customerInfo: { customer?: (number | null) | Customer;
/**
* Customer billing information (auto-populated from customer relationship)
*/
customerInfo?: {
/** /**
* Customer name * Customer name
*/ */
name: string; name?: string | null;
/** /**
* Customer email address * Customer email address
*/ */
email: string; email?: string | null;
/** /**
* Customer phone number * Customer phone number
*/ */
@@ -238,18 +266,18 @@ export interface Invoice {
taxId?: string | null; taxId?: string | null;
}; };
/** /**
* Billing address * Billing address (auto-populated from customer relationship)
*/ */
billingAddress: { billingAddress?: {
/** /**
* Address line 1 * Address line 1
*/ */
line1: string; line1?: string | null;
/** /**
* Address line 2 * Address line 2
*/ */
line2?: string | null; line2?: string | null;
city: string; city?: string | null;
/** /**
* State or province * State or province
*/ */
@@ -257,11 +285,11 @@ export interface Invoice {
/** /**
* Postal or ZIP code * Postal or ZIP code
*/ */
postalCode: string; postalCode?: string | null;
/** /**
* Country code (e.g., US, GB) * Country code (e.g., US, GB)
*/ */
country: string; country?: string | null;
}; };
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'; status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
/** /**
@@ -402,6 +430,10 @@ export interface PayloadLockedDocument {
relationTo: 'media'; relationTo: 'media';
value: number | Media; value: number | Media;
} | null) } | null)
| ({
relationTo: 'customers';
value: number | Customer;
} | null)
| ({ | ({
relationTo: 'payments'; relationTo: 'payments';
value: number | Payment; value: number | Payment;
@@ -485,6 +517,29 @@ export interface MediaSelect<T extends boolean = true> {
focalX?: T; focalX?: T;
focalY?: T; focalY?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers_select".
*/
export interface CustomersSelect<T extends boolean = true> {
name?: T;
email?: T;
phone?: T;
company?: T;
taxId?: T;
address?:
| T
| {
line1?: T;
line2?: T;
city?: T;
state?: T;
postalCode?: T;
country?: T;
};
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payments_select". * via the `definition` "payments_select".
@@ -510,6 +565,7 @@ export interface PaymentsSelect<T extends boolean = true> {
*/ */
export interface InvoicesSelect<T extends boolean = true> { export interface InvoicesSelect<T extends boolean = true> {
number?: T; number?: T;
customer?: T;
customerInfo?: customerInfo?:
| T | T
| { | {

View File

@@ -36,6 +36,71 @@ const buildConfigWithSQLite = () => {
staticDir: path.resolve(dirname, 'media'), 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',
},
],
},
],
},
], ],
db: sqliteAdapter({ db: sqliteAdapter({
client: { client: {
@@ -56,7 +121,8 @@ const buildConfigWithSQLite = () => {
showWarningBanners: true, showWarningBanners: true,
showTestBadges: true, showTestBadges: true,
consoleWarnings: true consoleWarnings: true
} },
customUiRoute: '/test-payment',
}) })
], ],
collections: { collections: {
@@ -64,6 +130,22 @@ const buildConfigWithSQLite = () => {
invoices: 'invoices', invoices: 'invoices',
refunds: 'refunds', 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,
}),
}), }),
], ],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key', secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

8
dev/postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

View File

@@ -21,9 +21,189 @@ export const seed = async (payload: Payload) => {
} }
// Seed billing sample data // Seed billing sample data
// await seedBillingData(payload) await seedBillingData(payload)
} }
// async function seedBillingData(payload: Payload): Promise<void> { async function seedBillingData(payload: Payload): Promise<void> {
// payload.logger.info('Seeding billing sample data...') payload.logger.info('Seeding billing sample data...')
// }
// Check if we already have data
const existingPayments = await payload.count({
collection: 'payments',
})
if (existingPayments.totalDocs > 0) {
payload.logger.info('Billing data already exists, skipping seed...')
return
}
// Check if customers collection exists
const hasCustomers = payload.collections['customers'] !== undefined
let customer1Id: string | number | undefined
let customer2Id: string | number | undefined
if (hasCustomers) {
// Seed customers
payload.logger.info('Seeding customers...')
const customer1 = await payload.create({
collection: 'customers',
data: {
name: 'John Doe',
email: 'john.doe@example.com',
phone: '+1 (555) 123-4567',
company: 'Acme Corporation',
taxId: 'US-123456789',
address: {
line1: '123 Main Street',
line2: 'Suite 100',
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US',
},
},
})
customer1Id = customer1.id
const customer2 = await payload.create({
collection: 'customers',
data: {
name: 'Jane Smith',
email: 'jane.smith@example.com',
phone: '+1 (555) 987-6543',
company: 'Tech Innovations Inc.',
address: {
line1: '456 Tech Avenue',
city: 'San Francisco',
state: 'CA',
postalCode: '94102',
country: 'US',
},
},
})
customer2Id = customer2.id
}
// Seed invoices
payload.logger.info('Seeding invoices...')
const invoiceData1 = hasCustomers
? {
customer: customer1Id,
currency: 'USD',
items: [
{
description: 'Web Development Services',
quantity: 40,
unitAmount: 12500, // $125/hour
},
{
description: 'Hosting & Deployment',
quantity: 1,
unitAmount: 5000, // $50
},
],
taxAmount: 52500, // $525 tax (10%)
status: 'paid',
notes: 'Thank you for your business!',
}
: {
customerInfo: {
name: 'John Doe',
email: 'john.doe@example.com',
phone: '+1 (555) 123-4567',
company: 'Acme Corporation',
taxId: 'US-123456789',
},
billingAddress: {
line1: '123 Main Street',
line2: 'Suite 100',
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US',
},
currency: 'USD',
items: [
{
description: 'Web Development Services',
quantity: 40,
unitAmount: 12500,
},
{
description: 'Hosting & Deployment',
quantity: 1,
unitAmount: 5000,
},
],
taxAmount: 52500,
status: 'paid',
notes: 'Thank you for your business!',
}
const invoice1 = await payload.create({
collection: 'invoices',
data: invoiceData1 as any,
})
const invoiceData2 = hasCustomers
? {
customer: customer2Id,
currency: 'USD',
items: [
{
description: 'Monthly Subscription - Pro Plan',
quantity: 1,
unitAmount: 9900, // $99
},
{
description: 'Additional Users (x5)',
quantity: 5,
unitAmount: 2000, // $20 each
},
],
taxAmount: 1990,
status: 'open',
}
: {
customerInfo: {
name: 'Jane Smith',
email: 'jane.smith@example.com',
phone: '+1 (555) 987-6543',
company: 'Tech Innovations Inc.',
},
billingAddress: {
line1: '456 Tech Avenue',
city: 'San Francisco',
state: 'CA',
postalCode: '94102',
country: 'US',
},
currency: 'USD',
items: [
{
description: 'Monthly Subscription - Pro Plan',
quantity: 1,
unitAmount: 9900,
},
{
description: 'Additional Users (x5)',
quantity: 5,
unitAmount: 2000,
},
],
taxAmount: 1990,
status: 'open',
}
const invoice2 = await payload.create({
collection: 'invoices',
data: invoiceData2 as any,
})
// Note: Skip payment seeding during initialization because the billing plugin
// providers aren't fully initialized yet. Payments can be created via the demo UI.
payload.logger.info('✅ Billing sample data seeded successfully!')
}

14
dev/tailwind.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}
export default config

View File

@@ -81,9 +81,11 @@
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@swc-node/register": "1.10.9", "@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0", "@swc/cli": "0.6.0",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"autoprefixer": "^10.4.21",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.23.0", "eslint": "^9.23.0",
@@ -93,6 +95,7 @@
"next": "15.4.4", "next": "15.4.4",
"open": "^10.1.0", "open": "^10.1.0",
"payload": "3.37.0", "payload": "3.37.0",
"postcss": "^8.5.6",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"qs-esm": "7.0.2", "qs-esm": "7.0.2",
"react": "19.1.0", "react": "19.1.0",
@@ -101,6 +104,7 @@
"sharp": "0.34.2", "sharp": "0.34.2",
"sort-package-json": "^2.10.0", "sort-package-json": "^2.10.0",
"stripe": "^18.5.0", "stripe": "^18.5.0",
"tailwindcss": "^4.1.17",
"typescript": "5.7.3", "typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2" "vitest": "^3.1.2"

888
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff