mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 19:03:23 +00:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6f546371 | |||
| 7e4ec86e00 | |||
| 79de7910d4 | |||
| bb5ba83bc3 | |||
| 79166f7edf | |||
| 6de405d07f | |||
| 7c0b42e35d | |||
| 25b340d818 | |||
| 46bec6bd2e | |||
| 4fde492e0f | |||
| a37757ffa1 | |||
| 1867bb2f96 | |||
| 89578aeba2 | |||
| f50b0c663a | |||
| 246c547a4c | |||
| 27da194942 | |||
| f096b5f17f | |||
| da24fa05d9 | |||
| 3508418698 | |||
|
|
fa22900db5 | ||
| 857fc663b3 | |||
|
|
6a1e6e77ad | ||
| 552ec700c2 | |||
|
|
7d069e5cf1 | ||
| f7d6066d9a | |||
|
|
c5442f9ce2 | ||
| b27b5806b1 | |||
| da96a0a838 | |||
| 2374dbcec8 | |||
|
|
2907d0fa9d | ||
| 05d612e606 | |||
| dc9bc2db57 | |||
| 7590a5445c | |||
| ed27501afc | |||
|
|
56bd4fc7ce | ||
|
|
eaf54ae893 | ||
|
|
f89ffb2c7e | ||
| d5a47a05b1 | |||
| 64c58552cb | |||
| be57924525 | |||
| 2d10bd82e7 | |||
| 8e6385caa3 | |||
| 83251bb404 | |||
|
|
7b8c89a0a2 | ||
| d651e8199c | |||
| f77719716f | |||
|
|
c6e51892e6 | ||
|
|
38c8c3677d | ||
|
|
e74a2410e6 | ||
|
|
27b86132e9 | ||
| ec635fb707 | |||
| cabe6eda96 | |||
| a3108a0f49 | |||
|
|
113a0d36c0 | ||
| 8ac328e14f | |||
| 7a3d6ec26e | |||
| 534b0e440f | |||
|
|
669a9decd5 | ||
| bfa214aed6 | |||
| c083ae183c | |||
| d09fe3054a | |||
|
|
50ab001e94 | ||
| 29db6635b8 |
7
.github/workflows/claude-code-review.yml
vendored
7
.github/workflows/claude-code-review.yml
vendored
@@ -12,11 +12,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude-review:
|
claude-review:
|
||||||
# Optional: Filter by PR author
|
# Only allow bvdaakster to trigger reviews
|
||||||
# if: |
|
if: github.event.pull_request.user.login == 'bvdaakster'
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -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
166
CLAUDE.md
@@ -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
223
dev/DEMO_GUIDE.md
Normal 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! 🚀
|
||||||
496
dev/README.md
Normal file
496
dev/README.md
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
# 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
|
||||||
|
- 🔄 **Automatic Status Sync** - payments and invoices stay in sync automatically
|
||||||
|
- 🔗 **Bidirectional Relationships** - payment/invoice links maintained by plugin hooks
|
||||||
|
- 🎨 **Custom Payment UI** with modern design
|
||||||
|
- 📄 **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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Invoice View Page
|
||||||
|
View and print invoices at:
|
||||||
|
```
|
||||||
|
http://localhost:3000/invoice/{invoice-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
This page demonstrates:
|
||||||
|
- Professional printable invoice layout
|
||||||
|
- Customer billing information
|
||||||
|
- Line items table with quantities and amounts
|
||||||
|
- Tax calculations and totals
|
||||||
|
- Custom message field (populated from payment metadata)
|
||||||
|
- Print-friendly styling
|
||||||
|
|
||||||
|
### Admin Routes
|
||||||
|
|
||||||
|
- **Payments**: [http://localhost:3000/admin/collections/payments](http://localhost:3000/admin/collections/payments)
|
||||||
|
- **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',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collection Extension Options
|
||||||
|
|
||||||
|
This demo showcases how to extend the plugin's collections with custom fields and hooks. The invoices collection is extended to include a `customMessage` field that is automatically populated from payment metadata:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
collections: {
|
||||||
|
payments: 'payments',
|
||||||
|
invoices: {
|
||||||
|
slug: 'invoices',
|
||||||
|
extend: (config) => ({
|
||||||
|
...config,
|
||||||
|
fields: [
|
||||||
|
...(config.fields || []),
|
||||||
|
{
|
||||||
|
name: 'customMessage',
|
||||||
|
type: 'textarea',
|
||||||
|
admin: {
|
||||||
|
description: 'Custom message from the payment (auto-populated)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hooks: {
|
||||||
|
...config.hooks,
|
||||||
|
beforeChange: [
|
||||||
|
...(config.hooks?.beforeChange || []),
|
||||||
|
async ({ data, req, operation }) => {
|
||||||
|
if (operation === 'create' && data.payment) {
|
||||||
|
const payment = await req.payload.findByID({
|
||||||
|
collection: 'payments',
|
||||||
|
id: typeof data.payment === 'object' ? data.payment.id : data.payment,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
payment?.metadata &&
|
||||||
|
typeof payment.metadata === 'object' &&
|
||||||
|
'customMessage' in payment.metadata &&
|
||||||
|
payment.metadata.customMessage
|
||||||
|
) {
|
||||||
|
data.customMessage = payment.metadata.customMessage as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
refunds: 'refunds',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customer Relationship
|
||||||
|
```typescript
|
||||||
|
customerRelationSlug: 'customers',
|
||||||
|
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",
|
||||||
|
"message": "Custom message to include in the invoice (optional)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `message` field will be stored in the payment's metadata and automatically copied to the invoice when it's created, thanks to the collection extension hook.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payment": {
|
||||||
|
"id": "test_pay_1234567890_abc123",
|
||||||
|
"paymentId": "67890",
|
||||||
|
"amount": 2500,
|
||||||
|
"currency": "USD",
|
||||||
|
"description": "Demo payment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Payment
|
||||||
|
```
|
||||||
|
GET /api/demo/payment/{payment-provider-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetches payment details including invoice relationship. Used by the payment success page to find the associated invoice.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payment": {
|
||||||
|
"id": "67890",
|
||||||
|
"providerId": "test_pay_1234567890_abc123",
|
||||||
|
"amount": 2500,
|
||||||
|
"currency": "USD",
|
||||||
|
"status": "paid",
|
||||||
|
"description": "Demo payment",
|
||||||
|
"invoice": "invoice-id-here",
|
||||||
|
"metadata": {
|
||||||
|
"customMessage": "Your custom message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Invoice
|
||||||
|
```
|
||||||
|
GET /api/demo/invoice/{invoice-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetches complete invoice data including customer details, line items, and custom message. Used by the invoice view page.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"invoice": {
|
||||||
|
"id": "invoice-id",
|
||||||
|
"invoiceNumber": "INV-2024-001",
|
||||||
|
"customer": {
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"company": "Acme Corp"
|
||||||
|
},
|
||||||
|
"currency": "USD",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"description": "Service",
|
||||||
|
"quantity": 1,
|
||||||
|
"unitAmount": 2500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subtotal": 2500,
|
||||||
|
"taxAmount": 250,
|
||||||
|
"total": 2750,
|
||||||
|
"status": "paid",
|
||||||
|
"customMessage": "Your custom message from payment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dev/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # Interactive demo page (root)
|
||||||
|
│ ├── test-payment/
|
||||||
|
│ │ └── [id]/
|
||||||
|
│ │ └── page.tsx # Custom payment UI
|
||||||
|
│ ├── invoice/
|
||||||
|
│ │ └── [id]/
|
||||||
|
│ │ └── page.tsx # Invoice view/print page
|
||||||
|
│ ├── payment-success/
|
||||||
|
│ │ └── page.tsx # Payment success page
|
||||||
|
│ ├── payment-failed/
|
||||||
|
│ │ └── page.tsx # Payment failed page
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── demo/
|
||||||
|
│ │ ├── create-payment/
|
||||||
|
│ │ │ └── route.ts # Payment creation endpoint
|
||||||
|
│ │ ├── invoice/
|
||||||
|
│ │ │ └── [id]/
|
||||||
|
│ │ │ └── route.ts # Invoice fetch endpoint
|
||||||
|
│ │ └── payment/
|
||||||
|
│ │ └── [id]/
|
||||||
|
│ │ └── route.ts # Payment fetch 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
|
||||||
102
dev/app/api/demo/create-payment/route.ts
Normal file
102
dev/app/api/demo/create-payment/route.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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, message, customerName, customerEmail, customerCompany } = body
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Received payment request:', { amount, currency, customerName, customerEmail, customerCompany })
|
||||||
|
|
||||||
|
if (!amount || !currency) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: 'Amount and currency are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerName || !customerEmail) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Missing customer info:', { customerName, customerEmail })
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: 'Customer name and email are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a payment first using the test provider
|
||||||
|
const payment = await payload.create({
|
||||||
|
collection: 'payments',
|
||||||
|
data: {
|
||||||
|
provider: 'test',
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
description: description || 'Demo payment',
|
||||||
|
status: 'pending',
|
||||||
|
metadata: {
|
||||||
|
source: 'demo-ui',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
customMessage: message, // Store the custom message in metadata
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create an invoice linked to the payment
|
||||||
|
// The invoice's afterChange hook will automatically link the payment back to the invoice
|
||||||
|
const invoice = await payload.create({
|
||||||
|
collection: 'invoices',
|
||||||
|
data: {
|
||||||
|
payment: payment.id, // Link to the payment
|
||||||
|
customerInfo: {
|
||||||
|
name: customerName,
|
||||||
|
email: customerEmail,
|
||||||
|
company: customerCompany,
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
line1: '123 Demo Street',
|
||||||
|
city: 'Demo City',
|
||||||
|
state: 'DC',
|
||||||
|
postalCode: '12345',
|
||||||
|
country: 'US',
|
||||||
|
},
|
||||||
|
currency,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: description || 'Demo payment',
|
||||||
|
quantity: 1,
|
||||||
|
unitAmount: amount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
taxAmount: 0,
|
||||||
|
status: 'open',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
payment: {
|
||||||
|
id: payment.providerId, // Use the test provider ID for the UI
|
||||||
|
paymentId: payment.id,
|
||||||
|
amount: payment.amount,
|
||||||
|
currency: payment.currency,
|
||||||
|
description: payment.description,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to create payment:', error)
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create payment',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
dev/app/globals.css
Normal file
1
dev/app/globals.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
dev/app/layout.tsx
Normal file
19
dev/app/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
import configPromise from '@payload-config'
|
export const GET = async () => {
|
||||||
import { getPayload } from 'payload'
|
|
||||||
import { useBillingPlugin } from '../../../src/plugin'
|
|
||||||
|
|
||||||
export const GET = async (request: Request) => {
|
|
||||||
const payload = await getPayload({
|
|
||||||
config: configPromise,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
message: 'This is an example of a custom route.',
|
message: 'This is an example of a custom route.',
|
||||||
})
|
})
|
||||||
|
|||||||
269
dev/app/page.tsx
Normal file
269
dev/app/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
'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 [customerName, setCustomerName] = useState<string>('Demo Customer')
|
||||||
|
const [customerEmail, setCustomerEmail] = useState<string>('demo@example.com')
|
||||||
|
const [customerCompany, setCustomerCompany] = useState<string>('Demo Company')
|
||||||
|
const [message, setMessage] = useState<string>('')
|
||||||
|
|
||||||
|
const createDemoPayment = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!customerName || !customerEmail) {
|
||||||
|
setError('Customer name and email are required')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody = {
|
||||||
|
amount: 2500,
|
||||||
|
currency: 'USD',
|
||||||
|
description: 'Demo payment from custom UI',
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerCompany: customerCompany || undefined,
|
||||||
|
message: message || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sending payment request:', requestBody)
|
||||||
|
|
||||||
|
const response = await fetch('/api/demo/create-payment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
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 className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="customerName" className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
Customer Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="customerName"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="customerEmail" className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
Customer Email <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="customerEmail"
|
||||||
|
value={customerEmail}
|
||||||
|
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||||
|
placeholder="john@example.com"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="customerCompany" className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
Company Name (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="customerCompany"
|
||||||
|
value={customerCompany}
|
||||||
|
onChange={(e) => setCustomerCompany(e.target.value)}
|
||||||
|
placeholder="Acme Corporation"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
Custom Message (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
rows={3}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Enter a message to include in the invoice..."
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
This message will be added to the invoice using collection extension options
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={createDemoPayment}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-lg font-semibold hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating Payment...' : 'Create Demo Payment'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="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('')
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
185
dev/app/payment-failed/page.tsx
Normal file
185
dev/app/payment-failed/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
dev/app/payment-success/page.tsx
Normal file
231
dev/app/payment-success/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
function PaymentSuccessContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const paymentId = searchParams.get('paymentId')
|
||||||
|
const amount = searchParams.get('amount')
|
||||||
|
const currency = searchParams.get('currency')
|
||||||
|
const [invoiceId, setInvoiceId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch the payment to get the invoice ID
|
||||||
|
if (paymentId) {
|
||||||
|
fetch(`/api/demo/payment/${paymentId}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success && data.payment?.invoice) {
|
||||||
|
const invId = typeof data.payment.invoice === 'object' ? data.payment.invoice.id : data.payment.invoice
|
||||||
|
setInvoiceId(invId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to fetch payment invoice:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [paymentId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-green-600 to-emerald-700 flex items-center justify-center p-4">
|
||||||
|
<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">
|
||||||
|
{invoiceId && (
|
||||||
|
<Link
|
||||||
|
href={`/invoice/${invoiceId}`}
|
||||||
|
className="flex items-center justify-between p-4 border-2 border-green-500 bg-green-50 rounded-lg hover:bg-green-100 transition-all group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-green-800 group-hover:text-green-900">
|
||||||
|
📄 View Invoice
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-green-700">
|
||||||
|
See your invoice with custom message
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-green-600 group-hover:text-green-800"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-green-500 hover:bg-green-50 transition-all group cursor-pointer"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
293
dev/app/test-payment/[id]/page.tsx
Normal file
293
dev/app/test-payment/[id]/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'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(() => {
|
||||||
|
if (!session) return
|
||||||
|
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(() => {
|
||||||
|
if (!session) return
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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".
|
||||||
@@ -158,7 +182,7 @@ export interface Payment {
|
|||||||
/**
|
/**
|
||||||
* The payment ID from the payment provider
|
* The payment ID from the payment provider
|
||||||
*/
|
*/
|
||||||
providerId: string;
|
providerId?: string | null;
|
||||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
|
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
|
||||||
/**
|
/**
|
||||||
* Amount in cents (e.g., 2000 = $20.00)
|
* Amount in cents (e.g., 2000 = $20.00)
|
||||||
@@ -198,6 +222,7 @@ export interface Payment {
|
|||||||
| boolean
|
| boolean
|
||||||
| null;
|
| null;
|
||||||
refunds?: (number | Refund)[] | null;
|
refunds?: (number | Refund)[] | null;
|
||||||
|
version?: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -212,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
|
||||||
*/
|
*/
|
||||||
@@ -237,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
|
||||||
*/
|
*/
|
||||||
@@ -256,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';
|
||||||
/**
|
/**
|
||||||
@@ -401,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;
|
||||||
@@ -484,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".
|
||||||
@@ -499,6 +555,7 @@ export interface PaymentsSelect<T extends boolean = true> {
|
|||||||
metadata?: T;
|
metadata?: T;
|
||||||
providerData?: T;
|
providerData?: T;
|
||||||
refunds?: T;
|
refunds?: T;
|
||||||
|
version?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
@@ -508,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
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'url'
|
|||||||
import { testEmailAdapter } from './helpers/testEmailAdapter'
|
import { testEmailAdapter } from './helpers/testEmailAdapter'
|
||||||
import { seed } from './seed'
|
import { seed } from './seed'
|
||||||
import billingPlugin from '../src/plugin'
|
import billingPlugin from '../src/plugin'
|
||||||
import { mollieProvider } from '../src/providers'
|
import { testProvider } from '../src/providers'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -36,6 +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
|
||||||
],
|
],
|
||||||
db: sqliteAdapter({
|
db: sqliteAdapter({
|
||||||
client: {
|
client: {
|
||||||
@@ -50,15 +51,74 @@ const buildConfigWithSQLite = () => {
|
|||||||
plugins: [
|
plugins: [
|
||||||
billingPlugin({
|
billingPlugin({
|
||||||
providers: [
|
providers: [
|
||||||
mollieProvider({
|
testProvider({
|
||||||
apiKey: process.env.MOLLIE_KEY!
|
enabled: true,
|
||||||
|
testModeIndicators: {
|
||||||
|
showWarningBanners: true,
|
||||||
|
showTestBadges: true,
|
||||||
|
consoleWarnings: true
|
||||||
|
},
|
||||||
|
customUiRoute: '/test-payment',
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
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',
|
||||||
},
|
},
|
||||||
|
// Note: No customerRelationSlug or customerInfoExtractor configured
|
||||||
|
// This allows the demo to work without a customer collection
|
||||||
|
// Invoices will use the direct customerInfo and billingAddress fields
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||||
|
|||||||
8
dev/postcss.config.mjs
Normal file
8
dev/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
190
dev/seed.ts
190
dev/seed.ts
@@ -21,9 +21,191 @@ 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
|
||||||
|
} else {
|
||||||
|
payload.logger.info('No customers collection found, will use direct customer info in invoices')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
14
dev/tailwind.config.ts
Normal 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
|
||||||
147
docs/test-provider-example.md
Normal file
147
docs/test-provider-example.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Advanced Test Provider Example
|
||||||
|
|
||||||
|
The advanced test provider allows you to test complex payment scenarios with an interactive UI for development purposes.
|
||||||
|
|
||||||
|
## Basic Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { billingPlugin, testProvider } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
|
// Configure the test provider
|
||||||
|
const testProviderConfig = {
|
||||||
|
enabled: true, // Enable the test provider
|
||||||
|
defaultDelay: 2000, // Default delay in milliseconds
|
||||||
|
baseUrl: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000',
|
||||||
|
customUiRoute: '/test-payment', // Custom route for test payment UI
|
||||||
|
testModeIndicators: {
|
||||||
|
showWarningBanners: true, // Show warning banners in test mode
|
||||||
|
showTestBadges: true, // Show test badges
|
||||||
|
consoleWarnings: true, // Show console warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to your payload config
|
||||||
|
export default buildConfig({
|
||||||
|
plugins: [
|
||||||
|
billingPlugin({
|
||||||
|
providers: [
|
||||||
|
testProvider(testProviderConfig)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Scenarios
|
||||||
|
|
||||||
|
You can define custom payment scenarios:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const customScenarios = [
|
||||||
|
{
|
||||||
|
id: 'quick-success',
|
||||||
|
name: 'Quick Success',
|
||||||
|
description: 'Payment succeeds in 1 second',
|
||||||
|
outcome: 'paid' as const,
|
||||||
|
delay: 1000,
|
||||||
|
method: 'creditcard' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'network-timeout',
|
||||||
|
name: 'Network Timeout',
|
||||||
|
description: 'Simulates network timeout',
|
||||||
|
outcome: 'failed' as const,
|
||||||
|
delay: 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-abandonment',
|
||||||
|
name: 'User Abandonment',
|
||||||
|
description: 'User closes payment window',
|
||||||
|
outcome: 'cancelled' as const,
|
||||||
|
delay: 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const testProviderConfig = {
|
||||||
|
enabled: true,
|
||||||
|
scenarios: customScenarios,
|
||||||
|
// ... other config
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Payment Outcomes
|
||||||
|
|
||||||
|
- `paid` - Payment succeeds
|
||||||
|
- `failed` - Payment fails
|
||||||
|
- `cancelled` - Payment is cancelled by user
|
||||||
|
- `expired` - Payment expires
|
||||||
|
- `pending` - Payment remains pending
|
||||||
|
|
||||||
|
## Available Payment Methods
|
||||||
|
|
||||||
|
- `ideal` - iDEAL (Dutch banking)
|
||||||
|
- `creditcard` - Credit/Debit Cards
|
||||||
|
- `paypal` - PayPal
|
||||||
|
- `applepay` - Apple Pay
|
||||||
|
- `banktransfer` - Bank Transfer
|
||||||
|
|
||||||
|
## Using the Test UI
|
||||||
|
|
||||||
|
1. Create a payment using the test provider
|
||||||
|
2. The payment will return a `paymentUrl` in the provider data
|
||||||
|
3. Navigate to this URL to access the interactive test interface
|
||||||
|
4. Select a payment method and scenario
|
||||||
|
5. Click "Process Test Payment" to simulate the payment
|
||||||
|
6. The payment status will update automatically based on the selected scenario
|
||||||
|
|
||||||
|
## React Components
|
||||||
|
|
||||||
|
Use the provided React components in your admin interface:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TestModeWarningBanner, TestModeBadge, TestPaymentControls } from '@xtr-dev/payload-billing/client'
|
||||||
|
|
||||||
|
// Show warning banner when in test mode
|
||||||
|
<TestModeWarningBanner visible={isTestMode} />
|
||||||
|
|
||||||
|
// Add test badge to payment status
|
||||||
|
<div>
|
||||||
|
Payment Status: {status}
|
||||||
|
<TestModeBadge visible={isTestMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Payment testing controls
|
||||||
|
<TestPaymentControls
|
||||||
|
paymentId={paymentId}
|
||||||
|
onScenarioSelect={(scenario) => console.log('Selected scenario:', scenario)}
|
||||||
|
onMethodSelect={(method) => console.log('Selected method:', method)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The test provider automatically registers these endpoints:
|
||||||
|
|
||||||
|
- `GET /api/payload-billing/test/payment/:id` - Test payment UI
|
||||||
|
- `POST /api/payload-billing/test/process` - Process test payment
|
||||||
|
- `GET /api/payload-billing/test/status/:id` - Get payment status
|
||||||
|
|
||||||
|
## Development Tips
|
||||||
|
|
||||||
|
1. **Console Warnings**: Keep `consoleWarnings: true` to get notifications about test mode
|
||||||
|
2. **Visual Indicators**: Use warning banners and badges to clearly mark test payments
|
||||||
|
3. **Custom Scenarios**: Create scenarios that match your specific use cases
|
||||||
|
4. **Automated Testing**: Use the test provider in your e2e tests for predictable payment outcomes
|
||||||
|
5. **Method Testing**: Test different payment methods to ensure your UI handles them correctly
|
||||||
|
|
||||||
|
## Production Safety
|
||||||
|
|
||||||
|
The test provider includes several safety mechanisms:
|
||||||
|
|
||||||
|
- Must be explicitly enabled with `enabled: true`
|
||||||
|
- Clearly marked with test indicators
|
||||||
|
- Console warnings when active
|
||||||
|
- Separate endpoint namespace (`/payload-billing/test/`)
|
||||||
|
- No real payment processing
|
||||||
|
|
||||||
|
**Important**: Never use the test provider in production environments!
|
||||||
@@ -20,9 +20,13 @@ export const defaultESLintIgnores = [
|
|||||||
'**/build/',
|
'**/build/',
|
||||||
'**/node_modules/',
|
'**/node_modules/',
|
||||||
'**/temp/',
|
'**/temp/',
|
||||||
|
'**/dev/**', // Ignore dev demo directory
|
||||||
]
|
]
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
{
|
||||||
|
ignores: defaultESLintIgnores,
|
||||||
|
},
|
||||||
...payloadEsLintConfig,
|
...payloadEsLintConfig,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-billing",
|
"name": "@xtr-dev/payload-billing",
|
||||||
"version": "0.1.4",
|
"version": "0.1.23",
|
||||||
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -81,9 +81,12 @@
|
|||||||
"@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",
|
||||||
|
"@swc/plugin-transform-imports": "^11.0.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 +96,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,12 +105,14 @@
|
|||||||
"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",
|
||||||
|
"tsc-alias": "^1.8.16",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mollie/api-client": "^3.7.0",
|
"@mollie/api-client": "^3.7.0 || ^4.0.0",
|
||||||
"payload": "^3.37.0",
|
"payload": "^3.37.0",
|
||||||
"stripe": "^18.5.0"
|
"stripe": "^18.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
76
playwright-report/index.html
Normal file
76
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
942
pnpm-lock.yaml
generated
942
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,21 @@
|
|||||||
import type { Payment } from '@/plugin/types'
|
import type { Payment } from '../plugin/types/index'
|
||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import { useBillingPlugin } from '@/plugin'
|
import { useBillingPlugin } from '../plugin/index'
|
||||||
|
|
||||||
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => {
|
export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
|
||||||
const billing = useBillingPlugin(payload)
|
const billing = useBillingPlugin(payload)
|
||||||
|
|
||||||
|
if (!billing) {
|
||||||
|
throw new Error(
|
||||||
|
'Billing plugin not initialized. Make sure the billingPlugin is properly configured in your Payload config and that Payload has finished initializing. ' +
|
||||||
|
'If you are calling this from a Next.js API route or Server Component, ensure you are using getPayload() with the same config instance used in your Payload configuration.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!payment.provider || !billing.providerConfig[payment.provider]) {
|
if (!payment.provider || !billing.providerConfig[payment.provider]) {
|
||||||
throw new Error(`Provider ${payment.provider} not found.`)
|
throw new Error(`Provider ${payment.provider} not found.`)
|
||||||
}
|
}
|
||||||
return billing.providerConfig[payment.provider].initPayment(payload, payment)
|
// Handle both async and non-async initPayment functions
|
||||||
|
const result = billing.providerConfig[payment.provider].initPayment(payload, payment)
|
||||||
|
return await Promise.resolve(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import {
|
import type {
|
||||||
AccessArgs,
|
AccessArgs,
|
||||||
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.js';
|
||||||
import { defaults } from '@/plugin/config'
|
import { defaults } from '../plugin/config.js'
|
||||||
import { extractSlug } from '@/plugin/utils'
|
import { extractSlug } from '../plugin/utils.js'
|
||||||
import type { Invoice } from '@/plugin/types/invoices'
|
import { createContextLogger } from '../utils/logger.js'
|
||||||
|
import type { Invoice } from '../plugin/types/index.js'
|
||||||
|
|
||||||
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',
|
||||||
@@ -32,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)
|
||||||
@@ -276,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',
|
||||||
@@ -293,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,
|
||||||
@@ -312,9 +317,68 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
fields,
|
fields,
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [
|
afterChange: [
|
||||||
({ doc, operation, req }) => {
|
async ({ doc, operation, req, previousDoc }) => {
|
||||||
|
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||||
|
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
req.payload.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>[],
|
||||||
@@ -350,7 +414,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
data.billingAddress = extractedInfo.billingAddress
|
data.billingAddress = extractedInfo.billingAddress
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.payload.logger.error(`Failed to extract customer info: ${error}`)
|
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||||
|
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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,7 +452,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
] satisfies CollectionBeforeChangeHook<Invoice>[],
|
] satisfies CollectionBeforeChangeHook<Invoice>[],
|
||||||
beforeValidate: [
|
beforeValidate: [
|
||||||
({ data }) => {
|
({ data }) => {
|
||||||
if (!data) return
|
if (!data) {return}
|
||||||
|
|
||||||
// If using extractor, customer relationship is required
|
// If using extractor, customer relationship is required
|
||||||
if (customerRelationSlug && customerInfoExtractor && !data.customer) {
|
if (customerRelationSlug && customerInfoExtractor && !data.customer) {
|
||||||
@@ -426,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 '@/collections/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',
|
||||||
@@ -73,13 +78,28 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
description: 'Payment description',
|
description: 'Payment description',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'checkoutUrl',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Checkout URL where user can complete payment (if applicable)',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'redirectUrl',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'URL to redirect user after payment completion',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'invoice',
|
name: 'invoice',
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
admin: {
|
admin: {
|
||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
},
|
},
|
||||||
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
|
relationTo: invoicesSlug,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'metadata',
|
name: 'metadata',
|
||||||
@@ -104,7 +124,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 +136,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 +149,55 @@ 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,
|
||||||
|
defaultPopulate: {
|
||||||
|
id: true,
|
||||||
|
provider: true,
|
||||||
|
status: true,
|
||||||
|
amount: true,
|
||||||
|
currency: true,
|
||||||
|
description: true,
|
||||||
|
checkoutUrl: true,
|
||||||
|
providerId: true,
|
||||||
|
metadata: true,
|
||||||
|
providerData: true,
|
||||||
|
},
|
||||||
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 +230,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||||
import { BillingPluginConfig, defaults } from '@/plugin/config'
|
import type { BillingPluginConfig} from '../plugin/config';
|
||||||
import { extractSlug } from '@/plugin/utils'
|
import { defaults } from '../plugin/config'
|
||||||
import { Payment } from '@/plugin/types'
|
import { extractSlug } from '../plugin/utils'
|
||||||
|
import type { Payment } from '../plugin/types/index'
|
||||||
|
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,
|
||||||
@@ -35,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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -111,25 +116,27 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
|||||||
afterChange: [
|
afterChange: [
|
||||||
async ({ doc, operation, req }) => {
|
async ({ doc, operation, req }) => {
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
|
const logger = createContextLogger(req.payload, 'Refunds Collection')
|
||||||
|
logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
|
||||||
|
|
||||||
// Update the related payment's refund relationship
|
// Update the related payment's refund relationship
|
||||||
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],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.payload.logger.error(`Failed to update payment refunds: ${error}`)
|
const logger = createContextLogger(req.payload, 'Refunds Collection')
|
||||||
|
logger.error(`Failed to update payment refunds: ${error}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -155,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +60,130 @@ export const PaymentStatusBadge: React.FC<{ status: string }> = ({ status }) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test mode indicator components
|
||||||
|
export const TestModeWarningBanner: React.FC<{ visible?: boolean }> = ({ visible = true }) => {
|
||||||
|
if (!visible) {return null}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(90deg, #ff6b6b, #ffa726)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '12px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<span role="img" aria-label="test tube">🧪</span> TEST MODE - Payment system is running in test mode for development
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestModeBadge: React.FC<{ visible?: boolean }> = ({ visible = true }) => {
|
||||||
|
if (!visible) {return null}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginLeft: '8px'
|
||||||
|
}}>
|
||||||
|
Test
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestPaymentControls: React.FC<{
|
||||||
|
paymentId?: string
|
||||||
|
onScenarioSelect?: (scenario: string) => void
|
||||||
|
onMethodSelect?: (method: string) => void
|
||||||
|
}> = ({ paymentId, onScenarioSelect, onMethodSelect }) => {
|
||||||
|
const [selectedScenario, setSelectedScenario] = React.useState('')
|
||||||
|
const [selectedMethod, setSelectedMethod] = React.useState('')
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{ id: 'instant-success', name: 'Instant Success', description: 'Payment succeeds immediately' },
|
||||||
|
{ id: 'delayed-success', name: 'Delayed Success', description: 'Payment succeeds after delay' },
|
||||||
|
{ id: 'cancelled-payment', name: 'Cancelled Payment', description: 'User cancels payment' },
|
||||||
|
{ id: 'declined-payment', name: 'Declined Payment', description: 'Payment declined' },
|
||||||
|
{ id: 'expired-payment', name: 'Expired Payment', description: 'Payment expires' },
|
||||||
|
{ id: 'pending-payment', name: 'Pending Payment', description: 'Payment stays pending' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const methods = [
|
||||||
|
{ id: 'ideal', name: 'iDEAL', icon: '🏦' },
|
||||||
|
{ id: 'creditcard', name: 'Credit Card', icon: '💳' },
|
||||||
|
{ id: 'paypal', name: 'PayPal', icon: '🅿️' },
|
||||||
|
{ id: 'applepay', name: 'Apple Pay', icon: '🍎' },
|
||||||
|
{ id: 'banktransfer', name: 'Bank Transfer', icon: '🏛️' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid #e9ecef', borderRadius: '8px', padding: '16px', margin: '16px 0' }}>
|
||||||
|
<h4 style={{ marginBottom: '12px', color: '#2c3e50' }}><span role="img" aria-label="test tube">🧪</span> Test Payment Controls</h4>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Payment Method:</label>
|
||||||
|
<select
|
||||||
|
value={selectedMethod}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedMethod(e.target.value)
|
||||||
|
onMethodSelect?.(e.target.value)
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<option value="">Select payment method...</option>
|
||||||
|
{methods.map(method => (
|
||||||
|
<option key={method.id} value={method.id}>
|
||||||
|
{method.icon} {method.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Test Scenario:</label>
|
||||||
|
<select
|
||||||
|
value={selectedScenario}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedScenario(e.target.value)
|
||||||
|
onScenarioSelect?.(e.target.value)
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<option value="">Select test scenario...</option>
|
||||||
|
{scenarios.map(scenario => (
|
||||||
|
<option key={scenario.id} value={scenario.id}>
|
||||||
|
{scenario.name} - {scenario.description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paymentId && (
|
||||||
|
<div style={{ marginTop: '12px', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
|
||||||
|
<small style={{ color: '#6c757d' }}>
|
||||||
|
Payment ID: <code>{paymentId}</code>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
BillingDashboardWidget,
|
BillingDashboardWidget,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
getPaymentStatusColor,
|
getPaymentStatusColor,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
|
TestModeWarningBanner,
|
||||||
|
TestModeBadge,
|
||||||
|
TestPaymentControls,
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,8 @@ interface BillingServerStatsProps {
|
|||||||
payloadInstance?: unknown
|
payloadInstance?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BillingServerStats: React.FC<BillingServerStatsProps> = async ({
|
export const BillingServerStats: React.FC<BillingServerStatsProps> = ({
|
||||||
payloadInstance
|
payloadInstance: _payloadInstance
|
||||||
}) => {
|
}) => {
|
||||||
// In a real implementation, this would fetch data from the database
|
// In a real implementation, this would fetch data from the database
|
||||||
// const stats = await payloadInstance?.find({
|
// const stats = await payloadInstance?.find({
|
||||||
|
|||||||
23
src/index.ts
23
src/index.ts
@@ -1,4 +1,21 @@
|
|||||||
|
|
||||||
export { billingPlugin } from './plugin'
|
export { billingPlugin } from './plugin/index.js'
|
||||||
export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config'
|
export { mollieProvider, stripeProvider } from './providers/index.js'
|
||||||
export type { Invoice, Payment, Refund } from './plugin/types'
|
export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderConfig } from './plugin/config.js'
|
||||||
|
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
|
||||||
|
export type { PaymentProvider, ProviderData } from './providers/types.js'
|
||||||
|
|
||||||
|
// Export logging utilities
|
||||||
|
export { getPluginLogger, createContextLogger } from './utils/logger.js'
|
||||||
|
|
||||||
|
// Export all providers
|
||||||
|
export { testProvider } from './providers/test.js'
|
||||||
|
export type {
|
||||||
|
StripeProviderConfig,
|
||||||
|
MollieProviderConfig,
|
||||||
|
TestProviderConfig,
|
||||||
|
TestProviderConfigResponse,
|
||||||
|
PaymentOutcome,
|
||||||
|
PaymentMethod,
|
||||||
|
PaymentScenario
|
||||||
|
} from './providers/index.js'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import { FieldsOverride } from '@/plugin/utils'
|
import type { FieldsOverride } from './utils'
|
||||||
import { PaymentProvider } from '@/plugin/types'
|
import type { PaymentProvider } from './types/index'
|
||||||
|
|
||||||
export const defaults = {
|
export const defaults = {
|
||||||
paymentsCollection: 'payments',
|
paymentsCollection: 'payments',
|
||||||
@@ -19,6 +19,9 @@ export interface TestProviderConfig {
|
|||||||
simulateFailures?: boolean
|
simulateFailures?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export the actual test provider config instead of duplicating
|
||||||
|
export type { TestProviderConfig as AdvancedTestProviderConfig } from '../providers/test'
|
||||||
|
|
||||||
// Customer info extractor callback type
|
// Customer info extractor callback type
|
||||||
export interface CustomerInfoExtractor {
|
export interface CustomerInfoExtractor {
|
||||||
(customer: any): {
|
(customer: any): {
|
||||||
@@ -38,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?: {
|
||||||
@@ -45,13 +56,13 @@ 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
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
providers?: PaymentProvider[]
|
providers?: (PaymentProvider | undefined | null)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '@/collections'
|
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index'
|
||||||
import type { BillingPluginConfig } from '@/plugin/config'
|
import type { BillingPluginConfig } from './config'
|
||||||
import type { Config, Payload } from 'payload'
|
import type { Config, Payload } from 'payload'
|
||||||
import { createSingleton } from '@/plugin/singleton'
|
import { createSingleton } from './singleton'
|
||||||
import type { PaymentProvider } from '@/providers'
|
import type { PaymentProvider } from '../providers/index'
|
||||||
|
|
||||||
const singleton = createSingleton(Symbol('billingPlugin'))
|
const singleton = createSingleton(Symbol.for('@xtr-dev/payload-billing'))
|
||||||
|
|
||||||
type BillingPlugin = {
|
type BillingPlugin = {
|
||||||
config: BillingPluginConfig
|
config: BillingPluginConfig
|
||||||
@@ -13,7 +13,7 @@ type BillingPlugin = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBillingPlugin = (payload: Payload) => singleton.get(payload) as BillingPlugin
|
export const useBillingPlugin = (payload: Payload) => singleton.get(payload) as BillingPlugin | undefined
|
||||||
|
|
||||||
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
||||||
if (pluginConfig.disabled) {
|
if (pluginConfig.disabled) {
|
||||||
@@ -28,8 +28,8 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
];
|
];
|
||||||
|
|
||||||
(pluginConfig.providers || [])
|
(pluginConfig.providers || [])
|
||||||
.filter(provider => provider.onConfig)
|
.filter(provider => provider?.onConfig)
|
||||||
.forEach(provider => provider.onConfig!(config, pluginConfig))
|
.forEach(provider => provider?.onConfig!(config, pluginConfig))
|
||||||
|
|
||||||
const incomingOnInit = config.onInit
|
const incomingOnInit = config.onInit
|
||||||
config.onInit = async (payload) => {
|
config.onInit = async (payload) => {
|
||||||
@@ -38,17 +38,17 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
}
|
}
|
||||||
singleton.set(payload, {
|
singleton.set(payload, {
|
||||||
config: pluginConfig,
|
config: pluginConfig,
|
||||||
providerConfig: (pluginConfig.providers || []).reduce(
|
providerConfig: (pluginConfig.providers || []).filter(Boolean).reduce(
|
||||||
(record, provider) => {
|
(record, provider) => {
|
||||||
record[provider.key] = provider
|
record[provider!.key] = provider as PaymentProvider
|
||||||
return record
|
return record
|
||||||
},
|
},
|
||||||
{} as Record<string, PaymentProvider>
|
{} as Record<string, PaymentProvider>
|
||||||
)
|
)
|
||||||
} satisfies BillingPlugin)
|
} satisfies BillingPlugin)
|
||||||
await Promise.all((pluginConfig.providers || [])
|
await Promise.all((pluginConfig.providers || [])
|
||||||
.filter(provider => provider.onInit)
|
.filter(provider => provider?.onInit)
|
||||||
.map(provider => provider.onInit!(payload)))
|
.map(provider => provider?.onInit!(payload)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export * from './id'
|
export * from './id.js'
|
||||||
export * from './invoices'
|
export * from './invoices.js'
|
||||||
export * from './payments'
|
export * from './payments.js'
|
||||||
export * from './refunds'
|
export * from './refunds.js'
|
||||||
export * from '../../providers/types'
|
export * from '../../providers/types.js'
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Payment } from '@/plugin/types/payments'
|
import type { Payment } from './payments'
|
||||||
|
import type { Id } from './id'
|
||||||
import { Id } from '@/plugin/types/id'
|
|
||||||
|
|
||||||
export interface Invoice<TCustomer = unknown> {
|
export interface Invoice<TCustomer = unknown> {
|
||||||
id: Id;
|
id: Id;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Refund } from '@/plugin/types/refunds'
|
import type { Refund } from './refunds'
|
||||||
import { Invoice } from '@/plugin/types/invoices'
|
import type { Invoice } from './invoices'
|
||||||
import { Id } from '@/plugin/types/id'
|
import type { Id } from './id'
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: Id;
|
id: Id;
|
||||||
@@ -22,6 +22,14 @@ export interface Payment {
|
|||||||
* Payment description
|
* Payment description
|
||||||
*/
|
*/
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
/**
|
||||||
|
* Checkout URL where user can complete payment (if applicable)
|
||||||
|
*/
|
||||||
|
checkoutUrl?: string | null;
|
||||||
|
/**
|
||||||
|
* URL to redirect user after payment completion
|
||||||
|
*/
|
||||||
|
redirectUrl?: string | null;
|
||||||
invoice?: (Id | null) | Invoice;
|
invoice?: (Id | null) | Invoice;
|
||||||
/**
|
/**
|
||||||
* Additional metadata for the payment
|
* Additional metadata for the payment
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Payment } from '@/plugin/types/payments'
|
import type { Payment } from './payments'
|
||||||
|
|
||||||
export interface Refund {
|
export interface Refund {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
||||||
import type { Id } from '@/plugin/types'
|
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
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export * from './mollie'
|
export * from './mollie'
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
|
export * from './test'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export * from './currency'
|
export * from './currency'
|
||||||
|
|
||||||
|
// Re-export provider configurations and types
|
||||||
|
export type { StripeProviderConfig } from './stripe'
|
||||||
|
export type { MollieProviderConfig } from './mollie'
|
||||||
|
export type { TestProviderConfig, TestProviderConfigResponse, PaymentOutcome, PaymentMethod, PaymentScenario } from './test'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Payment } from '@/plugin/types/payments'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { PaymentProvider } from '@/plugin/types'
|
import type { PaymentProvider } from '../plugin/types/index'
|
||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import { createSingleton } from '@/plugin/singleton'
|
import { createSingleton } from '../plugin/singleton'
|
||||||
import type { createMollieClient, MollieClient } from '@mollie/api-client'
|
import type { createMollieClient, MollieClient } from '@mollie/api-client'
|
||||||
import {
|
import {
|
||||||
webhookResponses,
|
webhookResponses,
|
||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
validateProductionUrl
|
validateProductionUrl
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
const symbol = Symbol('mollie')
|
const symbol = Symbol.for('@xtr-dev/payload-billing/mollie')
|
||||||
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,11 +85,15 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
|
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
|
||||||
|
|
||||||
// Update the payment status and provider data
|
// Update the payment status and provider data
|
||||||
|
// Use toPlainObject if available, otherwise spread the object
|
||||||
|
const providerData = typeof molliePayment.toPlainObject === 'function'
|
||||||
|
? molliePayment.toPlainObject()
|
||||||
|
: { ...molliePayment }
|
||||||
const updateSuccess = await updatePaymentStatus(
|
const updateSuccess = await updatePaymentStatus(
|
||||||
payload,
|
payload,
|
||||||
payment.id,
|
payment.id,
|
||||||
status,
|
status,
|
||||||
molliePayment.toPlainObject(),
|
providerData,
|
||||||
pluginConfig
|
pluginConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,12 +101,13 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
if (status === 'succeeded' && updateSuccess) {
|
if (status === 'succeeded' && updateSuccess) {
|
||||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||||
} else if (!updateSuccess) {
|
} else if (!updateSuccess) {
|
||||||
console.warn(`[Mollie Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
|
const logger = createContextLogger(payload, 'Mollie Webhook')
|
||||||
|
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return webhookResponses.success()
|
return webhookResponses.success()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleWebhookError('Mollie', error)
|
return handleWebhookError('Mollie', error, undefined, req.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,11 +138,24 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup URLs with development defaults
|
// Setup URLs with development defaults
|
||||||
|
// Only use localhost fallbacks in non-production environments
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
const redirectUrl = mollieConfig.redirectUrl ||
|
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL
|
||||||
(!isProduction ? 'https://localhost:3000/payment/success' : undefined)
|
|
||||||
const webhookUrl = mollieConfig.webhookUrl ||
|
// Priority: payment.redirectUrl > config.redirectUrl > dev fallback
|
||||||
`${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook`
|
let redirectUrl = payment.redirectUrl || mollieConfig.redirectUrl
|
||||||
|
if (!redirectUrl && !isProduction) {
|
||||||
|
redirectUrl = 'https://localhost:3000/payment/success'
|
||||||
|
}
|
||||||
|
|
||||||
|
let webhookUrl = mollieConfig.webhookUrl
|
||||||
|
if (!webhookUrl) {
|
||||||
|
if (serverUrl) {
|
||||||
|
webhookUrl = `${serverUrl}/api/payload-billing/mollie/webhook`
|
||||||
|
} else if (!isProduction) {
|
||||||
|
webhookUrl = 'https://localhost:3000/api/payload-billing/mollie/webhook'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate URLs for production
|
// Validate URLs for production
|
||||||
validateProductionUrl(redirectUrl, 'Redirect')
|
validateProductionUrl(redirectUrl, 'Redirect')
|
||||||
@@ -152,7 +171,11 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
webhookUrl,
|
webhookUrl,
|
||||||
});
|
});
|
||||||
payment.providerId = molliePayment.id
|
payment.providerId = molliePayment.id
|
||||||
payment.providerData = molliePayment.toPlainObject()
|
// Use toPlainObject if available, otherwise spread the object (for compatibility with different Mollie client versions)
|
||||||
|
payment.providerData = typeof molliePayment.toPlainObject === 'function'
|
||||||
|
? molliePayment.toPlainObject()
|
||||||
|
: { ...molliePayment }
|
||||||
|
payment.checkoutUrl = molliePayment._links?.checkout?.href || null
|
||||||
return payment
|
return payment
|
||||||
},
|
},
|
||||||
} satisfies PaymentProvider
|
} satisfies PaymentProvider
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Payment } from '@/plugin/types/payments'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { PaymentProvider, ProviderData } from '@/plugin/types'
|
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import { createSingleton } from '@/plugin/singleton'
|
import { createSingleton } from '../plugin/singleton'
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from 'stripe'
|
||||||
import {
|
import {
|
||||||
webhookResponses,
|
webhookResponses,
|
||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
logWebhookEvent
|
logWebhookEvent
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { isValidAmount, isValidCurrencyCode } from './currency'
|
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
const symbol = Symbol('stripe')
|
const symbol = Symbol.for('@xtr-dev/payload-billing/stripe')
|
||||||
|
|
||||||
export interface StripeProviderConfig {
|
export interface StripeProviderConfig {
|
||||||
secretKey: string
|
secretKey: string
|
||||||
@@ -60,13 +61,13 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
return webhookResponses.missingBody()
|
return webhookResponses.missingBody()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleWebhookError('Stripe', error, 'Failed to read request body')
|
return handleWebhookError('Stripe', error, 'Failed to read request body', req.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
const signature = req.headers.get('stripe-signature')
|
const signature = req.headers.get('stripe-signature')
|
||||||
|
|
||||||
if (!signature) {
|
if (!signature) {
|
||||||
return webhookResponses.error('Missing webhook signature', 400)
|
return webhookResponses.error('Missing webhook signature', 400, req.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
|
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
|
||||||
@@ -76,7 +77,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
|
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleWebhookError('Stripe', err, 'Signature verification failed')
|
return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different event types
|
// Handle different event types
|
||||||
@@ -90,7 +91,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
||||||
|
|
||||||
if (!payment) {
|
if (!payment) {
|
||||||
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`)
|
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`, undefined, req.payload)
|
||||||
return webhookResponses.success() // Still return 200 to acknowledge receipt
|
return webhookResponses.success() // Still return 200 to acknowledge receipt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
if (status === 'succeeded' && updateSuccess) {
|
if (status === 'succeeded' && updateSuccess) {
|
||||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||||
} else if (!updateSuccess) {
|
} else if (!updateSuccess) {
|
||||||
console.warn(`[Stripe Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
|
const logger = createContextLogger(payload, 'Stripe Webhook')
|
||||||
|
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -172,7 +174,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!updateSuccess) {
|
if (!updateSuccess) {
|
||||||
console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`)
|
const logger = createContextLogger(payload, 'Stripe Webhook')
|
||||||
|
logger.warn(`Failed to update refund status for payment ${payment.id}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -180,19 +183,16 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
// Unhandled event type
|
// Unhandled event type
|
||||||
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
|
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
return webhookResponses.success()
|
return webhookResponses.success()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleWebhookError('Stripe', error)
|
return handleWebhookError('Stripe', error, undefined, req.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
} else {
|
|
||||||
// Log that webhook endpoint is not registered
|
|
||||||
console.warn('[Stripe Provider] Webhook endpoint not registered - webhookSecret not configured')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onInit: async (payload: Payload) => {
|
onInit: async (payload: Payload) => {
|
||||||
@@ -201,6 +201,12 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
|
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
|
||||||
})
|
})
|
||||||
singleton.set(payload, stripe)
|
singleton.set(payload, stripe)
|
||||||
|
|
||||||
|
// Log webhook registration status
|
||||||
|
if (!stripeConfig.webhookSecret) {
|
||||||
|
const logger = createContextLogger(payload, 'Stripe Provider')
|
||||||
|
logger.warn('Webhook endpoint not registered - webhookSecret not configured')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
initPayment: async (payload, payment) => {
|
initPayment: async (payload, payment) => {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -228,6 +234,9 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
|
|
||||||
const stripe = singleton.get(payload)
|
const stripe = singleton.get(payload)
|
||||||
|
|
||||||
|
// Priority: payment.redirectUrl > config.returnUrl
|
||||||
|
const returnUrl = payment.redirectUrl || stripeConfig.returnUrl
|
||||||
|
|
||||||
// Create a payment intent
|
// Create a payment intent
|
||||||
const paymentIntent = await stripe.paymentIntents.create({
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
amount: payment.amount, // Stripe handles currency conversion internally
|
amount: payment.amount, // Stripe handles currency conversion internally
|
||||||
@@ -244,6 +253,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
automatic_payment_methods: {
|
automatic_payment_methods: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
...(returnUrl && { return_url: returnUrl }),
|
||||||
})
|
})
|
||||||
|
|
||||||
payment.providerId = paymentIntent.id
|
payment.providerId = paymentIntent.id
|
||||||
|
|||||||
1064
src/providers/test.ts
Normal file
1064
src/providers/test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
import type { Payment } from '@/plugin/types/payments'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { Config, Payload } from 'payload'
|
import type { Config, Payload } from 'payload'
|
||||||
import type { BillingPluginConfig } from '@/plugin/config'
|
import type { BillingPluginConfig } from '../plugin/config'
|
||||||
|
|
||||||
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
|
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
|
||||||
|
|
||||||
export type PaymentProvider = {
|
export type PaymentProvider = {
|
||||||
key: string
|
key: string
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import type { Payment } from '@/plugin/types/payments'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { BillingPluginConfig } from '@/plugin/config'
|
import type { BillingPluginConfig } from '../plugin/config'
|
||||||
import type { ProviderData } from './types'
|
import type { ProviderData } from './types'
|
||||||
import { defaults } from '@/plugin/config'
|
import { defaults } from '../plugin/config'
|
||||||
import { extractSlug, toPayloadId } from '@/plugin/utils'
|
import { extractSlug, toPayloadId } from '../plugin/utils'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common webhook response utilities
|
* Common webhook response utilities
|
||||||
@@ -11,9 +12,14 @@ import { extractSlug, toPayloadId } from '@/plugin/utils'
|
|||||||
*/
|
*/
|
||||||
export const webhookResponses = {
|
export const webhookResponses = {
|
||||||
success: () => Response.json({ received: true }, { status: 200 }),
|
success: () => Response.json({ received: true }, { status: 200 }),
|
||||||
error: (message: string, status = 400) => {
|
error: (message: string, status = 400, payload?: Payload) => {
|
||||||
// Log error internally but don't expose details
|
// Log error internally but don't expose details
|
||||||
console.error('[Webhook] Error:', message)
|
if (payload) {
|
||||||
|
const logger = createContextLogger(payload, 'Webhook')
|
||||||
|
logger.error('Error:', message)
|
||||||
|
} else {
|
||||||
|
console.error('[Webhook] Error:', message)
|
||||||
|
}
|
||||||
return Response.json({ error: 'Invalid request' }, { status })
|
return Response.json({ error: 'Invalid request' }, { status })
|
||||||
},
|
},
|
||||||
missingBody: () => Response.json({ received: true }, { status: 200 }),
|
missingBody: () => Response.json({ received: true }, { status: 200 }),
|
||||||
@@ -29,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,
|
||||||
@@ -53,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
|
||||||
@@ -63,7 +69,8 @@ export async function updatePaymentStatus(
|
|||||||
}) as Payment
|
}) as Payment
|
||||||
|
|
||||||
if (!currentPayment) {
|
if (!currentPayment) {
|
||||||
console.error(`[Payment Update] Payment ${paymentId} not found`)
|
const logger = createContextLogger(payload, 'Payment Update')
|
||||||
|
logger.error(`Payment ${paymentId} not found`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +81,8 @@ export async function updatePaymentStatus(
|
|||||||
const transactionID = await payload.db.beginTransaction()
|
const transactionID = await payload.db.beginTransaction()
|
||||||
|
|
||||||
if (!transactionID) {
|
if (!transactionID) {
|
||||||
console.error(`[Payment Update] Failed to begin transaction`)
|
const logger = createContextLogger(payload, 'Payment Update')
|
||||||
|
logger.error('Failed to begin transaction')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +91,14 @@ export async function updatePaymentStatus(
|
|||||||
const paymentInTransaction = await payload.findByID({
|
const paymentInTransaction = await payload.findByID({
|
||||||
collection: paymentsCollection,
|
collection: paymentsCollection,
|
||||||
id: toPayloadId(paymentId),
|
id: toPayloadId(paymentId),
|
||||||
req: { transactionID: transactionID }
|
req: { transactionID }
|
||||||
}) as Payment
|
}) as Payment
|
||||||
|
|
||||||
// Check if version still matches
|
// Check if version still matches
|
||||||
if ((paymentInTransaction.version || 1) !== currentVersion) {
|
if ((paymentInTransaction.version || 1) !== currentVersion) {
|
||||||
// Version conflict detected - payment was modified by another process
|
// Version conflict detected - payment was modified by another process
|
||||||
console.warn(`[Payment Update] Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
|
const logger = createContextLogger(payload, 'Payment Update')
|
||||||
|
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
|
||||||
await payload.db.rollbackTransaction(transactionID)
|
await payload.db.rollbackTransaction(transactionID)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -106,7 +115,7 @@ export async function updatePaymentStatus(
|
|||||||
},
|
},
|
||||||
version: currentVersion + 1
|
version: currentVersion + 1
|
||||||
},
|
},
|
||||||
req: { transactionID: transactionID }
|
req: { transactionID }
|
||||||
})
|
})
|
||||||
|
|
||||||
await payload.db.commitTransaction(transactionID)
|
await payload.db.commitTransaction(transactionID)
|
||||||
@@ -116,7 +125,8 @@ export async function updatePaymentStatus(
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
|
const logger = createContextLogger(payload, 'Payment Update')
|
||||||
|
logger.error(`Failed to update payment ${paymentId}:`, error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,9 +139,9 @@ export async function updateInvoiceOnPaymentSuccess(
|
|||||||
payment: Payment,
|
payment: Payment,
|
||||||
pluginConfig: BillingPluginConfig
|
pluginConfig: BillingPluginConfig
|
||||||
): 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
|
||||||
@@ -152,13 +162,19 @@ export async function updateInvoiceOnPaymentSuccess(
|
|||||||
export function handleWebhookError(
|
export function handleWebhookError(
|
||||||
provider: string,
|
provider: string,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
context?: string
|
context?: string,
|
||||||
|
payload?: Payload
|
||||||
): Response {
|
): Response {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]`
|
const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook`
|
||||||
|
|
||||||
// Log detailed error internally for debugging
|
// Log detailed error internally for debugging
|
||||||
console.error(`${fullContext} Error:`, error)
|
if (payload) {
|
||||||
|
const logger = createContextLogger(payload, fullContext)
|
||||||
|
logger.error('Error:', error)
|
||||||
|
} else {
|
||||||
|
console.error(`[${fullContext}] Error:`, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Return generic response to avoid information disclosure
|
// Return generic response to avoid information disclosure
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@@ -173,9 +189,15 @@ export function handleWebhookError(
|
|||||||
export function logWebhookEvent(
|
export function logWebhookEvent(
|
||||||
provider: string,
|
provider: string,
|
||||||
event: string,
|
event: string,
|
||||||
details?: any
|
details?: any,
|
||||||
|
payload?: Payload
|
||||||
): void {
|
): void {
|
||||||
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
|
if (payload) {
|
||||||
|
const logger = createContextLogger(payload, `${provider} Webhook`)
|
||||||
|
logger.info(event, details ? JSON.stringify(details) : '')
|
||||||
|
} else {
|
||||||
|
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,7 +206,7 @@ export function logWebhookEvent(
|
|||||||
export function validateProductionUrl(url: string | undefined, urlType: string): void {
|
export function validateProductionUrl(url: string | undefined, urlType: string): void {
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
if (!isProduction) return
|
if (!isProduction) {return}
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error(`${urlType} URL is required for production`)
|
throw new Error(`${urlType} URL is required for production`)
|
||||||
|
|||||||
48
src/utils/logger.ts
Normal file
48
src/utils/logger.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
|
let pluginLogger: any = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the plugin logger instance
|
||||||
|
* Uses PAYLOAD_BILLING_LOG_LEVEL environment variable to configure log level
|
||||||
|
* Defaults to 'info' if not set
|
||||||
|
*/
|
||||||
|
export function getPluginLogger(payload: Payload) {
|
||||||
|
if (!pluginLogger && payload.logger) {
|
||||||
|
const logLevel = process.env.PAYLOAD_BILLING_LOG_LEVEL || 'info'
|
||||||
|
|
||||||
|
pluginLogger = payload.logger.child({
|
||||||
|
level: logLevel,
|
||||||
|
plugin: '@xtr-dev/payload-billing'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log the configured log level on first initialization
|
||||||
|
pluginLogger.info(`Logger initialized with level: ${logLevel}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to console if logger not available (shouldn't happen in normal operation)
|
||||||
|
if (!pluginLogger) {
|
||||||
|
return {
|
||||||
|
debug: (...args: any[]) => console.log('[BILLING DEBUG]', ...args),
|
||||||
|
info: (...args: any[]) => console.log('[BILLING INFO]', ...args),
|
||||||
|
warn: (...args: any[]) => console.warn('[BILLING WARN]', ...args),
|
||||||
|
error: (...args: any[]) => console.error('[BILLING ERROR]', ...args),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pluginLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a context-specific logger for a particular operation
|
||||||
|
*/
|
||||||
|
export function createContextLogger(payload: Payload, context: string) {
|
||||||
|
const logger = getPluginLogger(payload)
|
||||||
|
|
||||||
|
return {
|
||||||
|
debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args),
|
||||||
|
info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args),
|
||||||
|
warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args),
|
||||||
|
error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user