mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
Compare commits
94 Commits
add-claude
...
v0.1.16
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
|
|
b1c1a11225 | ||
| de30372453 | |||
| 4fbab7942f | |||
|
|
84099196b1 | ||
| a25111444a | |||
| b6c27ff3a3 | |||
| 479f1bbd0e | |||
| 876501d94f | |||
| a5b6bb9bfd | |||
| 10f9b4f47b | |||
| 555e52f0b8 | |||
| d757c6942c | |||
|
|
03b3451b02 | ||
| 07dbda12e8 | |||
| 031350ec6b | |||
| 50f1267941 | |||
| a000fd3753 | |||
| bf9940924c | |||
| 209b683a8a | |||
| d08bb221ec | |||
| 9fbc720d6a | |||
| 2aad0d2538 | |||
|
|
6dd419c745 | ||
| e3a58fe6bc | |||
| 0308e30ebd | |||
| f17b4c064e | |||
| 28e9e8d208 | |||
|
|
3cb2b33b6e | ||
| c14299e1fb | |||
| 5f8fee33bb | |||
| a340e5d9e7 | |||
| 7fb45570a7 | |||
| b3368ba34f | |||
| c561dcb026 | |||
|
|
d97cac4c38 | ||
| 2c5459e457 | |||
| e0a10213fa | |||
| 74f2c99c7c | |||
| 6a49e00e7a | |||
|
|
069fb9d170 |
7
.github/workflows/claude-code-review.yml
vendored
7
.github/workflows/claude-code-review.yml
vendored
@@ -12,11 +12,8 @@ on:
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# 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'
|
||||
# Only allow bvdaakster to trigger reviews
|
||||
if: github.event.pull_request.user.login == 'bvdaakster'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
43
.github/workflows/pr-version-check.yml
vendored
Normal file
43
.github/workflows/pr-version-check.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: PR Version Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get PR branch package.json version
|
||||
id: pr-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get main branch package.json version
|
||||
id: main-version
|
||||
run: |
|
||||
git checkout main
|
||||
echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Compare versions
|
||||
run: |
|
||||
PR_VERSION="${{ steps.pr-version.outputs.version }}"
|
||||
MAIN_VERSION="${{ steps.main-version.outputs.version }}"
|
||||
|
||||
echo "PR branch version: $PR_VERSION"
|
||||
echo "Main branch version: $MAIN_VERSION"
|
||||
|
||||
if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
|
||||
echo "❌ Version must be updated in package.json"
|
||||
echo "Current version: $MAIN_VERSION"
|
||||
echo "Please increment the version number before merging to main"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Version has been updated from $MAIN_VERSION to $PR_VERSION"
|
||||
fi
|
||||
49
.github/workflows/version-and-publish.yml
vendored
Normal file
49
.github/workflows/version-and-publish.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Publish to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Run build
|
||||
run: pnpm build
|
||||
|
||||
- name: Get package version
|
||||
id: package-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create and push git tag
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
git tag -a "v${{ steps.package-version.outputs.version }}" -m "Release v${{ steps.package-version.outputs.version }}"
|
||||
git push origin "v${{ steps.package-version.outputs.version }}"
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --access public --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -97,4 +97,5 @@ tmp/
|
||||
temp/
|
||||
|
||||
# Local development
|
||||
.local
|
||||
.local
|
||||
/dev/payload.sqlite
|
||||
|
||||
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
|
||||
162
CLAUDE.md
162
CLAUDE.md
@@ -1,162 +0,0 @@
|
||||
# PayloadCMS Billing Plugin Development Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a PayloadCMS plugin that provides billing and payment functionality with multiple payment provider integrations (Stripe, Mollie) and a test payment provider for local development.
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Core Design
|
||||
- **Provider Abstraction**: All payment providers implement a common interface for consistency
|
||||
- **TypeScript First**: Full TypeScript support with strict typing throughout
|
||||
- **PayloadCMS Integration**: Deep integration with Payload collections, hooks, and admin UI
|
||||
- **Extensible**: Easy to add new payment providers through the common interface
|
||||
- **Developer Experience**: Comprehensive testing tools and local development support
|
||||
|
||||
### Payment Provider Interface
|
||||
All payment providers must implement the `PaymentProvider` interface:
|
||||
```typescript
|
||||
interface PaymentProvider {
|
||||
createPayment(options: CreatePaymentOptions): Promise<Payment>
|
||||
retrievePayment(id: string): Promise<Payment>
|
||||
cancelPayment(id: string): Promise<Payment>
|
||||
refundPayment(id: string, amount?: number): Promise<Refund>
|
||||
handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
|
||||
}
|
||||
```
|
||||
|
||||
### Collections Structure
|
||||
- **Payments**: Core payment tracking with provider-specific data
|
||||
- **Customers**: Customer management with billing information
|
||||
- **Invoices**: Invoice generation and management
|
||||
- **Refunds**: Refund tracking and management
|
||||
|
||||
## Code Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── providers/ # Payment provider implementations
|
||||
│ ├── stripe/ # Stripe integration
|
||||
│ ├── mollie/ # Mollie integration
|
||||
│ ├── test/ # Test provider for development
|
||||
│ └── base/ # Base provider interface and utilities
|
||||
├── collections/ # PayloadCMS collection configurations
|
||||
├── endpoints/ # API endpoints (webhooks, etc.)
|
||||
├── hooks/ # PayloadCMS lifecycle hooks
|
||||
├── admin/ # Admin UI components and extensions
|
||||
├── types/ # TypeScript type definitions
|
||||
└── utils/ # Shared utilities and helpers
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Payment Provider Development
|
||||
1. **Implement Base Interface**: All providers must implement `PaymentProvider`
|
||||
2. **Error Handling**: Use consistent error types and proper error propagation
|
||||
3. **Webhook Security**: Always verify webhook signatures and implement replay protection
|
||||
4. **Idempotency**: Support idempotent operations where possible
|
||||
5. **Logging**: Use structured logging for debugging and monitoring
|
||||
|
||||
### Testing Strategy
|
||||
- **Unit Tests**: Test individual provider methods and utilities
|
||||
- **Integration Tests**: Test provider integrations with mock APIs
|
||||
- **E2E Tests**: Test complete payment flows using test provider
|
||||
- **Webhook Tests**: Test webhook handling with various scenarios
|
||||
|
||||
### TypeScript Guidelines
|
||||
- Use strict TypeScript configuration
|
||||
- Define proper interfaces for all external API responses
|
||||
- Use discriminated unions for provider-specific data
|
||||
- Implement proper generic types for extensibility
|
||||
|
||||
### PayloadCMS Integration
|
||||
- Follow PayloadCMS plugin patterns and conventions
|
||||
- Use proper collection configurations with access control
|
||||
- Implement admin UI components using PayloadCMS patterns
|
||||
- Utilize PayloadCMS hooks for business logic
|
||||
|
||||
### Security Considerations
|
||||
- **Webhook Verification**: Always verify webhook signatures
|
||||
- **API Key Storage**: Use environment variables for sensitive data
|
||||
- **Access Control**: Implement proper PayloadCMS access control
|
||||
- **Input Validation**: Validate all inputs and sanitize data
|
||||
- **Audit Logging**: Log all payment operations for audit trails
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
```bash
|
||||
# Stripe Configuration
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Mollie Configuration
|
||||
MOLLIE_API_KEY=test_...
|
||||
MOLLIE_WEBHOOK_URL=https://yourapp.com/api/billing/webhooks/mollie
|
||||
|
||||
# Test Provider Configuration
|
||||
NODE_ENV=development # Enables test provider
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
1. Use test provider for local development
|
||||
2. Configure webhook forwarding tools (ngrok, etc.) for local webhook testing
|
||||
3. Use provider sandbox/test modes during development
|
||||
4. Implement comprehensive logging for debugging
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
### Basic Configuration
|
||||
```typescript
|
||||
billingPlugin({
|
||||
providers: {
|
||||
// Provider configurations
|
||||
},
|
||||
collections: {
|
||||
// Collection name overrides
|
||||
},
|
||||
webhooks: {
|
||||
// Webhook configuration
|
||||
},
|
||||
admin: {
|
||||
// Admin UI configuration
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
- Custom collection schemas
|
||||
- Provider-specific options
|
||||
- Webhook endpoint customization
|
||||
- Admin UI customization
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Provider Errors
|
||||
- Map provider-specific errors to common error types
|
||||
- Preserve original error information for debugging
|
||||
- Implement proper retry logic for transient failures
|
||||
|
||||
### Webhook Errors
|
||||
- Handle duplicate webhooks gracefully
|
||||
- Implement proper error responses for webhook failures
|
||||
- Log webhook processing errors with context
|
||||
|
||||
## Performance Considerations
|
||||
- Implement proper caching where appropriate
|
||||
- Use database indexes for payment queries
|
||||
- Optimize webhook processing for high throughput
|
||||
- Consider rate limiting for API endpoints
|
||||
|
||||
## Monitoring and Observability
|
||||
- Log all payment operations with structured data
|
||||
- Track payment success/failure rates
|
||||
- Monitor webhook processing times
|
||||
- Implement health check endpoints
|
||||
|
||||
## Documentation Requirements
|
||||
- Document all public APIs with examples
|
||||
- Provide integration guides for each payment provider
|
||||
- Include troubleshooting guides for common issues
|
||||
- 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,11 +1,4 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
export const GET = async () => {
|
||||
return Response.json({
|
||||
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,8 +69,8 @@ export interface Config {
|
||||
collections: {
|
||||
posts: Post;
|
||||
media: Media;
|
||||
payments: Payment;
|
||||
customers: Customer;
|
||||
payments: Payment;
|
||||
invoices: Invoice;
|
||||
refunds: Refund;
|
||||
users: User;
|
||||
@@ -82,8 +82,8 @@ export interface Config {
|
||||
collectionsSelect: {
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
payments: PaymentsSelect<false> | PaymentsSelect<true>;
|
||||
customers: CustomersSelect<false> | CustomersSelect<true>;
|
||||
payments: PaymentsSelect<false> | PaymentsSelect<true>;
|
||||
invoices: InvoicesSelect<false> | InvoicesSelect<true>;
|
||||
refunds: RefundsSelect<false> | RefundsSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
@@ -92,7 +92,7 @@ export interface Config {
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
defaultIDType: number;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
@@ -128,7 +128,7 @@ export interface UserAuthOperations {
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export interface Post {
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: string;
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
@@ -150,17 +150,39 @@ export interface Media {
|
||||
focalX?: 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
|
||||
* via the `definition` "payments".
|
||||
*/
|
||||
export interface Payment {
|
||||
id: string;
|
||||
id: number;
|
||||
provider: 'stripe' | 'mollie' | 'test';
|
||||
/**
|
||||
* The payment ID from the payment provider
|
||||
*/
|
||||
providerId: string;
|
||||
providerId?: string | null;
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
|
||||
/**
|
||||
* Amount in cents (e.g., 2000 = $20.00)
|
||||
@@ -174,8 +196,7 @@ export interface Payment {
|
||||
* Payment description
|
||||
*/
|
||||
description?: string | null;
|
||||
customer?: (string | null) | Customer;
|
||||
invoice?: (string | null) | Invoice;
|
||||
invoice?: (number | null) | Invoice;
|
||||
/**
|
||||
* Additional metadata for the payment
|
||||
*/
|
||||
@@ -200,71 +221,8 @@ export interface Payment {
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
refunds?: (string | Refund)[] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "customers".
|
||||
*/
|
||||
export interface Customer {
|
||||
id: string;
|
||||
/**
|
||||
* Customer email address
|
||||
*/
|
||||
email?: string | null;
|
||||
/**
|
||||
* Customer full name
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Customer phone number
|
||||
*/
|
||||
phone?: string | null;
|
||||
address?: {
|
||||
line1?: string | null;
|
||||
line2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postal_code?: string | null;
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 country code
|
||||
*/
|
||||
country?: string | null;
|
||||
};
|
||||
/**
|
||||
* Customer IDs from payment providers
|
||||
*/
|
||||
providerIds?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Additional customer metadata
|
||||
*/
|
||||
metadata?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Customer payments
|
||||
*/
|
||||
payments?: (string | Payment)[] | null;
|
||||
/**
|
||||
* Customer invoices
|
||||
*/
|
||||
invoices?: (string | Invoice)[] | null;
|
||||
refunds?: (number | Refund)[] | null;
|
||||
version?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -273,12 +231,66 @@ export interface Customer {
|
||||
* via the `definition` "invoices".
|
||||
*/
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
id: number;
|
||||
/**
|
||||
* Invoice number (e.g., INV-001)
|
||||
*/
|
||||
number: string;
|
||||
customer: string | Customer;
|
||||
/**
|
||||
* Link to customer record (optional)
|
||||
*/
|
||||
customer?: (number | null) | Customer;
|
||||
/**
|
||||
* Customer billing information (auto-populated from customer relationship)
|
||||
*/
|
||||
customerInfo?: {
|
||||
/**
|
||||
* Customer name
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Customer email address
|
||||
*/
|
||||
email?: string | null;
|
||||
/**
|
||||
* Customer phone number
|
||||
*/
|
||||
phone?: string | null;
|
||||
/**
|
||||
* Company name (optional)
|
||||
*/
|
||||
company?: string | null;
|
||||
/**
|
||||
* Tax ID or VAT number
|
||||
*/
|
||||
taxId?: string | null;
|
||||
};
|
||||
/**
|
||||
* Billing address (auto-populated from customer relationship)
|
||||
*/
|
||||
billingAddress?: {
|
||||
/**
|
||||
* Address line 1
|
||||
*/
|
||||
line1?: string | null;
|
||||
/**
|
||||
* Address line 2
|
||||
*/
|
||||
line2?: string | null;
|
||||
city?: string | null;
|
||||
/**
|
||||
* State or province
|
||||
*/
|
||||
state?: string | null;
|
||||
/**
|
||||
* Postal or ZIP code
|
||||
*/
|
||||
postalCode?: string | null;
|
||||
/**
|
||||
* Country code (e.g., US, GB)
|
||||
*/
|
||||
country?: string | null;
|
||||
};
|
||||
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
|
||||
/**
|
||||
* ISO 4217 currency code (e.g., USD, EUR)
|
||||
@@ -311,7 +323,7 @@ export interface Invoice {
|
||||
amount?: number | null;
|
||||
dueDate?: string | null;
|
||||
paidAt?: string | null;
|
||||
payment?: (string | null) | Payment;
|
||||
payment?: (number | null) | Payment;
|
||||
/**
|
||||
* Internal notes
|
||||
*/
|
||||
@@ -336,12 +348,12 @@ export interface Invoice {
|
||||
* via the `definition` "refunds".
|
||||
*/
|
||||
export interface Refund {
|
||||
id: string;
|
||||
id: number;
|
||||
/**
|
||||
* The refund ID from the payment provider
|
||||
*/
|
||||
providerId: string;
|
||||
payment: string | Payment;
|
||||
payment: number | Payment;
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled';
|
||||
/**
|
||||
* Refund amount in cents
|
||||
@@ -391,7 +403,7 @@ export interface Refund {
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
@@ -408,40 +420,40 @@ export interface User {
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: string | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'payments';
|
||||
value: string | Payment;
|
||||
value: number | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'customers';
|
||||
value: string | Customer;
|
||||
value: number | Customer;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'payments';
|
||||
value: number | Payment;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'invoices';
|
||||
value: string | Invoice;
|
||||
value: number | Invoice;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'refunds';
|
||||
value: string | Refund;
|
||||
value: number | Refund;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -451,10 +463,10 @@ export interface PayloadLockedDocument {
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: string;
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
@@ -474,7 +486,7 @@ export interface PayloadPreference {
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: string;
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
@@ -505,6 +517,29 @@ export interface MediaSelect<T extends boolean = true> {
|
||||
focalX?: 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
|
||||
* via the `definition` "payments_select".
|
||||
@@ -516,36 +551,11 @@ export interface PaymentsSelect<T extends boolean = true> {
|
||||
amount?: T;
|
||||
currency?: T;
|
||||
description?: T;
|
||||
customer?: T;
|
||||
invoice?: T;
|
||||
metadata?: T;
|
||||
providerData?: T;
|
||||
refunds?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "customers_select".
|
||||
*/
|
||||
export interface CustomersSelect<T extends boolean = true> {
|
||||
email?: T;
|
||||
name?: T;
|
||||
phone?: T;
|
||||
address?:
|
||||
| T
|
||||
| {
|
||||
line1?: T;
|
||||
line2?: T;
|
||||
city?: T;
|
||||
state?: T;
|
||||
postal_code?: T;
|
||||
country?: T;
|
||||
};
|
||||
providerIds?: T;
|
||||
metadata?: T;
|
||||
payments?: T;
|
||||
invoices?: T;
|
||||
version?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
@@ -556,6 +566,25 @@ export interface CustomersSelect<T extends boolean = true> {
|
||||
export interface InvoicesSelect<T extends boolean = true> {
|
||||
number?: T;
|
||||
customer?: T;
|
||||
customerInfo?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
email?: T;
|
||||
phone?: T;
|
||||
company?: T;
|
||||
taxId?: T;
|
||||
};
|
||||
billingAddress?:
|
||||
| T
|
||||
| {
|
||||
line1?: T;
|
||||
line2?: T;
|
||||
city?: T;
|
||||
state?: T;
|
||||
postalCode?: T;
|
||||
country?: T;
|
||||
};
|
||||
status?: T;
|
||||
currency?: T;
|
||||
items?:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { MongoMemoryReplSet } from 'mongodb-memory-server'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { billingPlugin } from '../src/index.js'
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
||||
import { seed } from './seed.js'
|
||||
import { testEmailAdapter } from './helpers/testEmailAdapter'
|
||||
import { seed } from './seed'
|
||||
import billingPlugin from '../src/plugin'
|
||||
import { testProvider } from '../src/providers'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -17,18 +17,7 @@ if (!process.env.ROOT_DIR) {
|
||||
process.env.ROOT_DIR = dirname
|
||||
}
|
||||
|
||||
const buildConfigWithMemoryDB = async () => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
const memoryDB = await MongoMemoryReplSet.create({
|
||||
replSet: {
|
||||
count: 3,
|
||||
dbName: 'payloadmemory',
|
||||
},
|
||||
})
|
||||
|
||||
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
|
||||
}
|
||||
|
||||
const buildConfigWithSQLite = () => {
|
||||
return buildConfig({
|
||||
admin: {
|
||||
importMap: {
|
||||
@@ -47,10 +36,12 @@ const buildConfigWithMemoryDB = async () => {
|
||||
staticDir: path.resolve(dirname, 'media'),
|
||||
},
|
||||
},
|
||||
// Note: No customers collection - the demo uses direct customerInfo fields on invoices
|
||||
],
|
||||
db: mongooseAdapter({
|
||||
ensureIndexes: true,
|
||||
url: process.env.DATABASE_URI || '',
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: `file:${path.resolve(dirname, 'payload.sqlite')}`,
|
||||
},
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
email: testEmailAdapter,
|
||||
@@ -59,18 +50,75 @@ const buildConfigWithMemoryDB = async () => {
|
||||
},
|
||||
plugins: [
|
||||
billingPlugin({
|
||||
providers: {
|
||||
test: {
|
||||
providers: [
|
||||
testProvider({
|
||||
enabled: true,
|
||||
autoComplete: true,
|
||||
}
|
||||
},
|
||||
testModeIndicators: {
|
||||
showWarningBanners: true,
|
||||
showTestBadges: true,
|
||||
consoleWarnings: true
|
||||
},
|
||||
customUiRoute: '/test-payment',
|
||||
})
|
||||
],
|
||||
collections: {
|
||||
payments: 'payments',
|
||||
customers: 'customers',
|
||||
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',
|
||||
}
|
||||
},
|
||||
// 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',
|
||||
@@ -81,4 +129,4 @@ const buildConfigWithMemoryDB = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
export default buildConfigWithMemoryDB()
|
||||
export default buildConfigWithSQLite()
|
||||
|
||||
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
|
||||
256
dev/seed.ts
256
dev/seed.ts
@@ -1,6 +1,6 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { devUser } from './helpers/credentials.js'
|
||||
import { devUser } from './helpers/credentials'
|
||||
|
||||
export const seed = async (payload: Payload) => {
|
||||
// Seed default user first
|
||||
@@ -27,123 +27,185 @@ export const seed = async (payload: Payload) => {
|
||||
async function seedBillingData(payload: Payload): Promise<void> {
|
||||
payload.logger.info('Seeding billing sample data...')
|
||||
|
||||
try {
|
||||
// Check if we already have sample data
|
||||
const existingCustomers = await payload.count({
|
||||
// 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',
|
||||
where: {
|
||||
email: {
|
||||
equals: 'john.doe@example.com',
|
||||
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
|
||||
|
||||
if (existingCustomers.totalDocs > 0) {
|
||||
payload.logger.info('Sample billing data already exists, skipping seed')
|
||||
return
|
||||
}
|
||||
|
||||
// Create a sample customer
|
||||
const customer = await payload.create({
|
||||
const customer2 = await payload.create({
|
||||
collection: 'customers',
|
||||
data: {
|
||||
email: 'john.doe@example.com',
|
||||
name: 'John Doe',
|
||||
phone: '+1-555-0123',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane.smith@example.com',
|
||||
phone: '+1 (555) 987-6543',
|
||||
company: 'Tech Innovations Inc.',
|
||||
address: {
|
||||
line1: '123 Main St',
|
||||
city: 'New York',
|
||||
state: 'NY',
|
||||
postal_code: '10001',
|
||||
country: 'US'
|
||||
line1: '456 Tech Avenue',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
postalCode: '94102',
|
||||
country: 'US',
|
||||
},
|
||||
metadata: {
|
||||
source: 'seed',
|
||||
created_by: 'system'
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
customer2Id = customer2.id
|
||||
} else {
|
||||
payload.logger.info('No customers collection found, will use direct customer info in invoices')
|
||||
}
|
||||
|
||||
payload.logger.info(`Created sample customer: ${customer.id}`)
|
||||
// Seed invoices
|
||||
payload.logger.info('Seeding invoices...')
|
||||
|
||||
// Create a sample invoice
|
||||
const invoice = await payload.create({
|
||||
collection: 'invoices',
|
||||
data: {
|
||||
number: 'INV-001-SAMPLE',
|
||||
customer: customer.id,
|
||||
const invoiceData1 = hasCustomers
|
||||
? {
|
||||
customer: customer1Id,
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
description: 'Web Development Services',
|
||||
quantity: 10,
|
||||
unitAmount: 5000, // $50.00 per hour
|
||||
totalAmount: 50000 // $500.00 total
|
||||
quantity: 40,
|
||||
unitAmount: 12500, // $125/hour
|
||||
},
|
||||
{
|
||||
description: 'Design Consultation',
|
||||
quantity: 2,
|
||||
unitAmount: 7500, // $75.00 per hour
|
||||
totalAmount: 15000 // $150.00 total
|
||||
}
|
||||
description: 'Hosting & Deployment',
|
||||
quantity: 1,
|
||||
unitAmount: 5000, // $50
|
||||
},
|
||||
],
|
||||
subtotal: 65000, // $650.00
|
||||
taxAmount: 5200, // $52.00 (8% tax)
|
||||
amount: 70200, // $702.00 total
|
||||
status: 'open',
|
||||
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
notes: 'Payment terms: Net 30 days. This is sample data for development.',
|
||||
metadata: {
|
||||
project: 'website-redesign',
|
||||
billable_hours: 12,
|
||||
sample: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
payload.logger.info(`Created sample invoice: ${invoice.number}`)
|
||||
|
||||
// Create a sample payment using test provider
|
||||
const payment = await payload.create({
|
||||
collection: 'payments',
|
||||
data: {
|
||||
provider: 'test',
|
||||
providerId: `test_pay_sample_${Date.now()}`,
|
||||
status: 'succeeded',
|
||||
amount: 70200, // $702.00
|
||||
currency: 'USD',
|
||||
description: `Sample payment for invoice ${invoice.number}`,
|
||||
customer: customer.id,
|
||||
invoice: invoice.id,
|
||||
metadata: {
|
||||
invoice_number: invoice.number,
|
||||
payment_method: 'test_card',
|
||||
sample: true
|
||||
},
|
||||
providerData: {
|
||||
testMode: true,
|
||||
simulatedPayment: true,
|
||||
autoCompleted: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
payload.logger.info(`Created sample payment: ${payment.id}`)
|
||||
|
||||
// Update invoice status to paid
|
||||
await payload.update({
|
||||
collection: 'invoices',
|
||||
id: invoice.id,
|
||||
data: {
|
||||
taxAmount: 52500, // $525 tax (10%)
|
||||
status: 'paid',
|
||||
payment: payment.id,
|
||||
paidAt: new Date().toISOString()
|
||||
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!',
|
||||
}
|
||||
})
|
||||
|
||||
payload.logger.info('Billing sample data seeded successfully!')
|
||||
const invoice1 = await payload.create({
|
||||
collection: 'invoices',
|
||||
data: invoiceData1 as any,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
payload.logger.error('Error seeding billing data:', error)
|
||||
}
|
||||
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,13 +20,35 @@ export const defaultESLintIgnores = [
|
||||
'**/build/',
|
||||
'**/node_modules/',
|
||||
'**/temp/',
|
||||
'**/dev/**', // Ignore dev demo directory
|
||||
]
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: defaultESLintIgnores,
|
||||
},
|
||||
...payloadEsLintConfig,
|
||||
{
|
||||
rules: {
|
||||
'no-restricted-exports': 'off',
|
||||
// Disable all perfectionist rules
|
||||
'perfectionist/sort-array-includes': 'off',
|
||||
'perfectionist/sort-classes': 'off',
|
||||
'perfectionist/sort-enums': 'off',
|
||||
'perfectionist/sort-exports': 'off',
|
||||
'perfectionist/sort-imports': 'off',
|
||||
'perfectionist/sort-interfaces': 'off',
|
||||
'perfectionist/sort-jsx-props': 'off',
|
||||
'perfectionist/sort-maps': 'off',
|
||||
'perfectionist/sort-named-exports': 'off',
|
||||
'perfectionist/sort-named-imports': 'off',
|
||||
'perfectionist/sort-object-types': 'off',
|
||||
'perfectionist/sort-objects': 'off',
|
||||
'perfectionist/sort-sets': 'off',
|
||||
'perfectionist/sort-switch-case': 'off',
|
||||
'perfectionist/sort-union-types': 'off',
|
||||
'perfectionist/sort-variable-declarations': 'off',
|
||||
'perfectionist/sort-intersection-types': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-billing",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.16",
|
||||
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -41,7 +41,7 @@
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm build",
|
||||
"test": "pnpm test:int && pnpm test:e2e",
|
||||
"test": "echo 'Tests disabled for now'",
|
||||
"test:e2e": "playwright test",
|
||||
"test:int": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -70,6 +70,7 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.1",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@mollie/api-client": "^3.7.0",
|
||||
"@payloadcms/db-mongodb": "3.37.0",
|
||||
"@payloadcms/db-postgres": "3.37.0",
|
||||
"@payloadcms/db-sqlite": "3.37.0",
|
||||
@@ -80,9 +81,12 @@
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@swc-node/register": "1.10.9",
|
||||
"@swc/cli": "0.6.0",
|
||||
"@swc/plugin-transform-imports": "^11.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.23.0",
|
||||
@@ -92,6 +96,7 @@
|
||||
"next": "15.4.4",
|
||||
"open": "^10.1.0",
|
||||
"payload": "3.37.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.4.2",
|
||||
"qs-esm": "7.0.2",
|
||||
"react": "19.1.0",
|
||||
@@ -99,16 +104,19 @@
|
||||
"rimraf": "3.0.2",
|
||||
"sharp": "0.34.2",
|
||||
"sort-package-json": "^2.10.0",
|
||||
"stripe": "^18.5.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "5.7.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.37.0"
|
||||
"@mollie/api-client": "^3.7.0 || ^4.0.0",
|
||||
"payload": "^3.37.0",
|
||||
"stripe": "^18.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"stripe": "^14.15.0",
|
||||
"@mollie/api-client": "^3.7.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
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
968
pnpm-lock.yaml
generated
968
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,283 +0,0 @@
|
||||
import type { TestProviderConfig} from '../types';
|
||||
|
||||
import { TestPaymentProvider } from '../providers/test/provider'
|
||||
import { PaymentStatus } from '../types'
|
||||
|
||||
describe('TestPaymentProvider', () => {
|
||||
let provider: TestPaymentProvider
|
||||
let config: TestProviderConfig
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
autoComplete: true,
|
||||
defaultDelay: 0,
|
||||
enabled: true,
|
||||
}
|
||||
provider = new TestPaymentProvider(config)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
provider.clearStoredData()
|
||||
})
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should create a payment with succeeded status when autoComplete is true', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
description: 'Test payment',
|
||||
})
|
||||
|
||||
expect(payment).toMatchObject({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
description: 'Test payment',
|
||||
provider: 'test',
|
||||
status: 'succeeded',
|
||||
})
|
||||
expect(payment.id).toBeDefined()
|
||||
expect(payment.createdAt).toBeDefined()
|
||||
expect(payment.updatedAt).toBeDefined()
|
||||
expect(payment.providerData?.testMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should create a payment with pending status when autoComplete is false', async () => {
|
||||
config.autoComplete = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const payment = await provider.createPayment({
|
||||
amount: 1500,
|
||||
currency: 'EUR',
|
||||
})
|
||||
|
||||
expect(payment).toMatchObject({
|
||||
amount: 1500,
|
||||
currency: 'EUR',
|
||||
status: 'pending',
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a failed payment when simulateFailure is true', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 1000,
|
||||
currency: 'USD',
|
||||
metadata: {
|
||||
test: { simulateFailure: true },
|
||||
},
|
||||
})
|
||||
|
||||
expect(payment.status).toBe('failed')
|
||||
expect(payment.providerData?.simulatedFailure).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply delay when specified', async () => {
|
||||
const startTime = Date.now()
|
||||
|
||||
await provider.createPayment({
|
||||
amount: 1000,
|
||||
currency: 'USD',
|
||||
metadata: {
|
||||
test: { delayMs: 100 },
|
||||
},
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should store payment data', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const stored = provider.getStoredPayment(payment.id)
|
||||
expect(stored).toEqual(payment)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retrievePayment', () => {
|
||||
it('should retrieve an existing payment', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const retrieved = await provider.retrievePayment(payment.id)
|
||||
expect(retrieved).toEqual(payment)
|
||||
})
|
||||
|
||||
it('should throw error for non-existent payment', async () => {
|
||||
await expect(provider.retrievePayment('non-existent')).rejects.toThrow(
|
||||
'Payment non-existent not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelPayment', () => {
|
||||
it('should cancel a pending payment', async () => {
|
||||
config.autoComplete = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const canceled = await provider.cancelPayment(payment.id)
|
||||
expect(canceled.status).toBe('canceled')
|
||||
expect(canceled.updatedAt).not.toBe(payment.updatedAt)
|
||||
})
|
||||
|
||||
it('should not cancel a succeeded payment', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
await expect(provider.cancelPayment(payment.id)).rejects.toThrow(
|
||||
'Cannot cancel a succeeded payment'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error for non-existent payment', async () => {
|
||||
await expect(provider.cancelPayment('non-existent')).rejects.toThrow(
|
||||
'Payment non-existent not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refundPayment', () => {
|
||||
it('should create a full refund for succeeded payment', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const refund = await provider.refundPayment(payment.id)
|
||||
|
||||
expect(refund).toMatchObject({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
paymentId: payment.id,
|
||||
status: 'succeeded',
|
||||
})
|
||||
expect(refund.id).toBeDefined()
|
||||
expect(refund.createdAt).toBeDefined()
|
||||
|
||||
// Check payment status is updated
|
||||
const updatedPayment = await provider.retrievePayment(payment.id)
|
||||
expect(updatedPayment.status).toBe('refunded')
|
||||
})
|
||||
|
||||
it('should create a partial refund', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const refund = await provider.refundPayment(payment.id, 1000)
|
||||
|
||||
expect(refund.amount).toBe(1000)
|
||||
|
||||
// Check payment status is updated to partially_refunded
|
||||
const updatedPayment = await provider.retrievePayment(payment.id)
|
||||
expect(updatedPayment.status).toBe('partially_refunded')
|
||||
})
|
||||
|
||||
it('should not refund a non-succeeded payment', async () => {
|
||||
config.autoComplete = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
await expect(provider.refundPayment(payment.id)).rejects.toThrow(
|
||||
'Can only refund succeeded payments'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not refund more than payment amount', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
await expect(provider.refundPayment(payment.id, 3000)).rejects.toThrow(
|
||||
'Refund amount cannot exceed payment amount'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleWebhook', () => {
|
||||
it('should handle webhook event', async () => {
|
||||
const mockRequest = {
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
type: 'payment.succeeded',
|
||||
data: { paymentId: 'test_pay_123' }
|
||||
}))
|
||||
} as Request
|
||||
|
||||
const event = await provider.handleWebhook(mockRequest)
|
||||
|
||||
expect(event).toMatchObject({
|
||||
type: 'payment.succeeded',
|
||||
data: { paymentId: 'test_pay_123' },
|
||||
provider: 'test',
|
||||
verified: true,
|
||||
})
|
||||
expect(event.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('should throw error for invalid JSON', async () => {
|
||||
const mockRequest = {
|
||||
text: () => Promise.resolve('invalid json')
|
||||
} as Request
|
||||
|
||||
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
|
||||
'Invalid JSON in webhook body'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when provider is disabled', async () => {
|
||||
config.enabled = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const mockRequest = {
|
||||
text: () => Promise.resolve('{}')
|
||||
} as Request
|
||||
|
||||
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
|
||||
'Test provider is not enabled'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data management', () => {
|
||||
it('should clear all stored data', async () => {
|
||||
await provider.createPayment({ amount: 1000, currency: 'USD' })
|
||||
|
||||
expect(provider.getAllPayments()).toHaveLength(1)
|
||||
|
||||
provider.clearStoredData()
|
||||
|
||||
expect(provider.getAllPayments()).toHaveLength(0)
|
||||
expect(provider.getAllRefunds()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should return all payments and refunds', async () => {
|
||||
const payment1 = await provider.createPayment({ amount: 1000, currency: 'USD' })
|
||||
const payment2 = await provider.createPayment({ amount: 2000, currency: 'EUR' })
|
||||
const refund = await provider.refundPayment(payment1.id)
|
||||
|
||||
const payments = provider.getAllPayments()
|
||||
const refunds = provider.getAllRefunds()
|
||||
|
||||
expect(payments).toHaveLength(2)
|
||||
expect(refunds).toHaveLength(1)
|
||||
expect(refunds[0]).toEqual(refund)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
CustomerData,
|
||||
CustomerDocument
|
||||
} from '../types/payload'
|
||||
|
||||
export function createCustomersCollection(slug: string = 'customers'): CollectionConfig {
|
||||
return {
|
||||
slug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
read: ({ req: { user } }: AccessArgs) => !!user,
|
||||
update: ({ req: { user } }: AccessArgs) => !!user,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['email', 'name', 'createdAt'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
admin: {
|
||||
description: 'Customer email address',
|
||||
},
|
||||
index: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer full name',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer phone number',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'line1',
|
||||
type: 'text',
|
||||
label: 'Address Line 1',
|
||||
},
|
||||
{
|
||||
name: 'line2',
|
||||
type: 'text',
|
||||
label: 'Address Line 2',
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
label: 'City',
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
label: 'State/Province',
|
||||
},
|
||||
{
|
||||
name: 'postal_code',
|
||||
type: 'text',
|
||||
label: 'Postal Code',
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 3166-1 alpha-2 country code',
|
||||
},
|
||||
label: 'Country',
|
||||
maxLength: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'providerIds',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Customer IDs from payment providers',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional customer metadata',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'payments',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
description: 'Customer payments',
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'payments',
|
||||
},
|
||||
{
|
||||
name: 'invoices',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
description: 'Customer invoices',
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'invoices',
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [
|
||||
({ doc, operation, req }: CollectionAfterChangeHook<CustomerDocument>) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Customer created: ${doc.id} (${doc.email})`)
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeChange: [
|
||||
({ data, operation }: CollectionBeforeChangeHook<CustomerData>) => {
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
// Normalize country code
|
||||
if (data.address?.country) {
|
||||
data.address.country = data.address.country.toUpperCase()
|
||||
if (!/^[A-Z]{2}$/.test(data.address.country)) {
|
||||
throw new Error('Country must be a 2-letter ISO code')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
}
|
||||
20
src/collections/hooks.ts
Normal file
20
src/collections/hooks.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Payment } from '../plugin/types/index'
|
||||
import type { Payload } from 'payload'
|
||||
import { useBillingPlugin } from '../plugin/index'
|
||||
|
||||
export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
|
||||
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 (!payment.provider || !billing.providerConfig[payment.provider]) {
|
||||
throw new Error(`Provider ${payment.provider} not found.`)
|
||||
}
|
||||
// Handle both async and non-async initPayment functions
|
||||
const result = billing.providerConfig[payment.provider].initPayment(payload, payment)
|
||||
return await Promise.resolve(result)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { createCustomersCollection } from './customers'
|
||||
export { createInvoicesCollection } from './invoices'
|
||||
export { createPaymentsCollection } from './payments'
|
||||
export { createRefundsCollection } from './refunds'
|
||||
export { createRefundsCollection } from './refunds'
|
||||
|
||||
@@ -1,18 +1,308 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type {
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionBeforeValidateHook,
|
||||
InvoiceData,
|
||||
InvoiceDocument,
|
||||
InvoiceItemData
|
||||
} from '../types/payload'
|
||||
CollectionConfig,
|
||||
CollectionSlug,
|
||||
Field,
|
||||
} from 'payload'
|
||||
import type { BillingPluginConfig} from '../plugin/config.js';
|
||||
import { defaults } from '../plugin/config.js'
|
||||
import { extractSlug } from '../plugin/utils.js'
|
||||
import { createContextLogger } from '../utils/logger.js'
|
||||
import type { Invoice } from '../plugin/types/index.js'
|
||||
|
||||
export function createInvoicesCollection(slug: string = 'invoices'): CollectionConfig {
|
||||
return {
|
||||
slug,
|
||||
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
||||
|
||||
// Get slugs for relationships - these need to be determined before building fields
|
||||
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
|
||||
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'number',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Invoice number (e.g., INV-001)',
|
||||
},
|
||||
index: true,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
// Optional customer relationship
|
||||
...(customerRelationSlug ? [{
|
||||
name: 'customer',
|
||||
type: 'relationship' as const,
|
||||
admin: {
|
||||
position: 'sidebar' as const,
|
||||
description: 'Link to customer record (optional)',
|
||||
},
|
||||
relationTo: customerRelationSlug as any,
|
||||
required: false,
|
||||
}] : []),
|
||||
// Basic customer info fields (embedded)
|
||||
{
|
||||
name: 'customerInfo',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: customerRelationSlug && customerInfoExtractor
|
||||
? 'Customer billing information (auto-populated from customer relationship)'
|
||||
: 'Customer billing information',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer name',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
required: !customerRelationSlug || !customerInfoExtractor,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
admin: {
|
||||
description: 'Customer email address',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
required: !customerRelationSlug || !customerInfoExtractor,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer phone number',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'company',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Company name (optional)',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'taxId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Tax ID or VAT number',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'billingAddress',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: customerRelationSlug && customerInfoExtractor
|
||||
? 'Billing address (auto-populated from customer relationship)'
|
||||
: 'Billing address',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'line1',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Address line 1',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
required: !customerRelationSlug || !customerInfoExtractor,
|
||||
},
|
||||
{
|
||||
name: 'line2',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Address line 2',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
required: !customerRelationSlug || !customerInfoExtractor,
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'State or province',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'postalCode',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Postal or ZIP code',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
required: !customerRelationSlug || !customerInfoExtractor,
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Country code (e.g., US, GB)',
|
||||
readOnly: !!(customerRelationSlug && customerInfoExtractor),
|
||||
},
|
||||
maxLength: 2,
|
||||
required: !customerRelationSlug || !customerInfoExtractor,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Open', value: 'open' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
{ label: 'Void', value: 'void' },
|
||||
{ label: 'Uncollectible', value: 'uncollectible' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
||||
},
|
||||
defaultValue: 'USD',
|
||||
maxLength: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
admin: {
|
||||
// Custom row labeling can be added here when needed
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
admin: {
|
||||
width: '40%',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'number',
|
||||
admin: {
|
||||
width: '15%',
|
||||
},
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'unitAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Amount in cents',
|
||||
width: '20%',
|
||||
},
|
||||
min: 0,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'totalAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Calculated: quantity × unitAmount',
|
||||
readOnly: true,
|
||||
width: '20%',
|
||||
},
|
||||
},
|
||||
],
|
||||
minRows: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'subtotal',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Sum of all line items',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'taxAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Tax amount in cents',
|
||||
},
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Total amount (subtotal + tax)',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dueDate',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paidAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
condition: (data) => data.status === 'paid',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'payment',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (data) => data.status === 'paid',
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: paymentsSlug,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Internal notes',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional invoice metadata',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const baseConfig: CollectionConfig = {
|
||||
slug: invoicesSlug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
@@ -20,179 +310,117 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
||||
update: ({ req: { user } }: AccessArgs) => !!user,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['number', 'customer', 'status', 'amount', 'currency', 'dueDate'],
|
||||
defaultColumns: ['number', 'customerInfo.name', 'status', 'amount', 'currency', 'dueDate'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'number',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'number',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Invoice number (e.g., INV-001)',
|
||||
},
|
||||
index: true,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'customers',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Open', value: 'open' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
{ label: 'Void', value: 'void' },
|
||||
{ label: 'Uncollectible', value: 'uncollectible' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
||||
},
|
||||
defaultValue: 'USD',
|
||||
maxLength: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
admin: {
|
||||
// Custom row labeling can be added here when needed
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
admin: {
|
||||
width: '40%',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'number',
|
||||
admin: {
|
||||
width: '15%',
|
||||
},
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'unitAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Amount in cents',
|
||||
width: '20%',
|
||||
},
|
||||
min: 0,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'totalAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Calculated: quantity × unitAmount',
|
||||
readOnly: true,
|
||||
width: '20%',
|
||||
},
|
||||
},
|
||||
],
|
||||
minRows: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'subtotal',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Sum of all line items',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'taxAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Tax amount in cents',
|
||||
},
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Total amount (subtotal + tax)',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dueDate',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paidAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
condition: (data: InvoiceData) => data.status === 'paid',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'payment',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (data: InvoiceData) => data.status === 'paid',
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'payments',
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Internal notes',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional invoice metadata',
|
||||
},
|
||||
},
|
||||
],
|
||||
fields,
|
||||
hooks: {
|
||||
afterChange: [
|
||||
({ doc, operation, req }: CollectionAfterChangeHook<InvoiceDocument>) => {
|
||||
async ({ doc, operation, req, previousDoc }) => {
|
||||
const logger = createContextLogger(req.payload, 'Invoices Collection')
|
||||
|
||||
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>[],
|
||||
beforeChange: [
|
||||
({ data, operation }: CollectionBeforeChangeHook<InvoiceData>) => {
|
||||
async ({ data, operation, req, originalDoc }) => {
|
||||
// Sync customer info from relationship if extractor is provided
|
||||
if (customerRelationSlug && customerInfoExtractor && data.customer) {
|
||||
// Check if customer changed or this is a new invoice
|
||||
const customerChanged = operation === 'create' ||
|
||||
(originalDoc && originalDoc.customer !== data.customer)
|
||||
|
||||
if (customerChanged) {
|
||||
try {
|
||||
// Fetch the customer data
|
||||
const customer = await req.payload.findByID({
|
||||
collection: customerRelationSlug as never,
|
||||
id: data.customer as never,
|
||||
})
|
||||
|
||||
// Extract customer info using the provided callback
|
||||
const extractedInfo = customerInfoExtractor(customer)
|
||||
|
||||
// Update the invoice data with extracted info
|
||||
data.customerInfo = {
|
||||
name: extractedInfo.name,
|
||||
email: extractedInfo.email,
|
||||
phone: extractedInfo.phone,
|
||||
company: extractedInfo.company,
|
||||
taxId: extractedInfo.taxId,
|
||||
}
|
||||
|
||||
if (extractedInfo.billingAddress) {
|
||||
data.billingAddress = extractedInfo.billingAddress
|
||||
}
|
||||
} catch (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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'create') {
|
||||
// Generate invoice number if not provided
|
||||
if (!data.number) {
|
||||
@@ -221,19 +449,37 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
||||
data.paidAt = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
],
|
||||
] satisfies CollectionBeforeChangeHook<Invoice>[],
|
||||
beforeValidate: [
|
||||
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
|
||||
({ data }) => {
|
||||
if (!data) {return}
|
||||
|
||||
// If using extractor, customer relationship is required
|
||||
if (customerRelationSlug && customerInfoExtractor && !data.customer) {
|
||||
throw new Error('Please select a customer')
|
||||
}
|
||||
|
||||
// If not using extractor but have customer collection, either relationship or info is required
|
||||
if (customerRelationSlug && !customerInfoExtractor &&
|
||||
!data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) {
|
||||
throw new Error('Either select a customer or provide customer information')
|
||||
}
|
||||
|
||||
// If no customer collection, ensure customer info is provided
|
||||
if (!customerRelationSlug && (!data.customerInfo?.name || !data.customerInfo?.email)) {
|
||||
throw new Error('Customer name and email are required')
|
||||
}
|
||||
|
||||
if (data && data.items && Array.isArray(data.items)) {
|
||||
// Calculate totals for each line item
|
||||
data.items = data.items.map((item: InvoiceItemData) => ({
|
||||
data.items = data.items.map((item) => ({
|
||||
...item,
|
||||
totalAmount: (item.quantity || 0) * (item.unitAmount || 0),
|
||||
}))
|
||||
|
||||
// Calculate subtotal
|
||||
data.subtotal = data.items.reduce(
|
||||
(sum: number, item: InvoiceItemData) => sum + (item.totalAmount || 0),
|
||||
(sum: number, item) => sum + (item.totalAmount || 0),
|
||||
0
|
||||
)
|
||||
|
||||
@@ -241,8 +487,16 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
||||
data.amount = (data.subtotal || 0) + (data.taxAmount || 0)
|
||||
}
|
||||
},
|
||||
],
|
||||
] satisfies CollectionBeforeValidateHook<Invoice>[],
|
||||
},
|
||||
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,16 +1,137 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import type { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
|
||||
import type { BillingPluginConfig} from '../plugin/config';
|
||||
import { defaults } from '../plugin/config'
|
||||
import { extractSlug } from '../plugin/utils'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import { initProviderPayment } from './hooks'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
PaymentData,
|
||||
PaymentDocument
|
||||
} from '../types/payload'
|
||||
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
// Get slugs for relationships - these need to be determined before building fields
|
||||
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
|
||||
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
|
||||
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
|
||||
export function createPaymentsCollection(slug: string = 'payments'): CollectionConfig {
|
||||
return {
|
||||
slug,
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Stripe', value: 'stripe' },
|
||||
{ label: 'Mollie', value: 'mollie' },
|
||||
{ label: 'Test', value: 'test' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'providerId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The payment ID from the payment provider',
|
||||
},
|
||||
label: 'Provider Payment ID',
|
||||
unique: true,
|
||||
index: true, // Ensure this field is indexed for webhook lookups
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Processing', value: 'processing' },
|
||||
{ label: 'Succeeded', value: 'succeeded' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
{ label: 'Canceled', value: 'canceled' },
|
||||
{ label: 'Refunded', value: 'refunded' },
|
||||
{ label: 'Partially Refunded', value: 'partially_refunded' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Amount in cents (e.g., 2000 = $20.00)',
|
||||
},
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
||||
},
|
||||
maxLength: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Payment description',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'checkoutUrl',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Checkout URL where user can complete payment (if applicable)',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: invoicesSlug,
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional metadata for the payment',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'providerData',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Raw data from the payment provider',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refunds',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: refundsSlug,
|
||||
},
|
||||
{
|
||||
name: 'version',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
admin: {
|
||||
hidden: true, // Hide from admin UI to prevent manual tampering
|
||||
},
|
||||
index: true, // Index for optimistic locking performance
|
||||
},
|
||||
]
|
||||
|
||||
const baseConfig: CollectionConfig = {
|
||||
slug: paymentsSlug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
@@ -22,130 +143,50 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC
|
||||
group: 'Billing',
|
||||
useAsTitle: 'id',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Stripe', value: 'stripe' },
|
||||
{ label: 'Mollie', value: 'mollie' },
|
||||
{ label: 'Test', value: 'test' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'providerId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The payment ID from the payment provider',
|
||||
},
|
||||
label: 'Provider Payment ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Processing', value: 'processing' },
|
||||
{ label: 'Succeeded', value: 'succeeded' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
{ label: 'Canceled', value: 'canceled' },
|
||||
{ label: 'Refunded', value: 'refunded' },
|
||||
{ label: 'Partially Refunded', value: 'partially_refunded' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Amount in cents (e.g., 2000 = $20.00)',
|
||||
},
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
||||
},
|
||||
maxLength: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Payment description',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'customers',
|
||||
},
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'invoices',
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional metadata for the payment',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'providerData',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Raw data from the payment provider',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refunds',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'refunds',
|
||||
},
|
||||
],
|
||||
fields,
|
||||
hooks: {
|
||||
afterChange: [
|
||||
({ doc, operation, req }: CollectionAfterChangeHook<PaymentDocument>) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Payment created: ${doc.id} (${doc.provider})`)
|
||||
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: [
|
||||
({ data, operation }: CollectionBeforeChangeHook<PaymentData>) => {
|
||||
async ({ data, operation, req, originalDoc }) => {
|
||||
if (operation === 'create') {
|
||||
// Validate amount format
|
||||
if (data.amount && !Number.isInteger(data.amount)) {
|
||||
throw new Error('Amount must be an integer (in cents)')
|
||||
}
|
||||
|
||||
|
||||
// Validate currency format
|
||||
if (data.currency) {
|
||||
data.currency = data.currency.toUpperCase()
|
||||
@@ -153,10 +194,29 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC
|
||||
throw new Error('Currency must be a 3-letter ISO code')
|
||||
}
|
||||
}
|
||||
|
||||
await initProviderPayment(req.payload, data)
|
||||
}
|
||||
|
||||
// Auto-increment version for manual updates (not webhook updates)
|
||||
// Webhook updates handle their own versioning in updatePaymentStatus
|
||||
if (operation === 'update' && !data.version) {
|
||||
// If version is not being explicitly set (i.e., manual admin update),
|
||||
// increment it automatically
|
||||
const currentVersion = (originalDoc as Payment)?.version || 1
|
||||
data.version = currentVersion + 1
|
||||
}
|
||||
},
|
||||
],
|
||||
] satisfies CollectionBeforeChangeHook<Payment>[],
|
||||
},
|
||||
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,16 +1,17 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
import type { BillingPluginConfig} from '../plugin/config';
|
||||
import { defaults } from '../plugin/config'
|
||||
import { extractSlug } from '../plugin/utils'
|
||||
import type { Payment } from '../plugin/types/index'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
RefundData,
|
||||
RefundDocument
|
||||
} from '../types/payload'
|
||||
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||
// Get slugs for relationships - these need to be determined before building fields
|
||||
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
|
||||
|
||||
export function createRefundsCollection(slug: string = 'refunds'): CollectionConfig {
|
||||
return {
|
||||
slug,
|
||||
const baseConfig: CollectionConfig = {
|
||||
slug: refundsSlug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
@@ -39,7 +40,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'payments',
|
||||
relationTo: paymentsSlug,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -113,40 +114,41 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, operation, req }: CollectionAfterChangeHook<RefundDocument>) => {
|
||||
async ({ doc, operation, req }) => {
|
||||
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
|
||||
try {
|
||||
const payment = await req.payload.findByID({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: 'payments',
|
||||
})
|
||||
|
||||
collection: paymentsSlug,
|
||||
}) as Payment
|
||||
|
||||
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
||||
|
||||
await req.payload.update({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: 'payments',
|
||||
collection: paymentsSlug,
|
||||
data: {
|
||||
refunds: [...refundIds, doc.id],
|
||||
},
|
||||
})
|
||||
} 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}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeChange: [
|
||||
({ data, operation }: CollectionBeforeChangeHook<RefundData>) => {
|
||||
({ data, operation }) => {
|
||||
if (operation === 'create') {
|
||||
// Validate amount format
|
||||
if (data.amount && !Number.isInteger(data.amount)) {
|
||||
throw new Error('Amount must be an integer (in cents)')
|
||||
}
|
||||
|
||||
|
||||
// Validate currency format
|
||||
if (data.currency) {
|
||||
data.currency = data.currency.toUpperCase()
|
||||
@@ -160,4 +162,12 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
|
||||
},
|
||||
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 {
|
||||
BillingDashboardWidget,
|
||||
formatCurrency,
|
||||
getPaymentStatusColor,
|
||||
PaymentStatusBadge,
|
||||
TestModeWarningBanner,
|
||||
TestModeBadge,
|
||||
TestPaymentControls,
|
||||
}
|
||||
@@ -10,8 +10,8 @@ interface BillingServerStatsProps {
|
||||
payloadInstance?: unknown
|
||||
}
|
||||
|
||||
export const BillingServerStats: React.FC<BillingServerStatsProps> = async ({
|
||||
payloadInstance
|
||||
export const BillingServerStats: React.FC<BillingServerStatsProps> = ({
|
||||
payloadInstance: _payloadInstance
|
||||
}) => {
|
||||
// In a real implementation, this would fetch data from the database
|
||||
// const stats = await payloadInstance?.find({
|
||||
|
||||
147
src/index.ts
147
src/index.ts
@@ -1,132 +1,21 @@
|
||||
import type { Config } from 'payload'
|
||||
|
||||
import type { BillingPluginConfig } from './types'
|
||||
export { billingPlugin } from './plugin/index.js'
|
||||
export { mollieProvider, stripeProvider } from './providers/index.js'
|
||||
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'
|
||||
|
||||
import { createCustomersCollection } from './collections/customers'
|
||||
import { createInvoicesCollection } from './collections/invoices'
|
||||
import { createPaymentsCollection } from './collections/payments'
|
||||
import { createRefundsCollection } from './collections/refunds'
|
||||
import { providerRegistry } from './providers/base/provider'
|
||||
import { TestPaymentProvider } from './providers/test/provider'
|
||||
// Export logging utilities
|
||||
export { getPluginLogger, createContextLogger } from './utils/logger.js'
|
||||
|
||||
export * from './providers/base/provider'
|
||||
export * from './providers/test/provider'
|
||||
export * from './types'
|
||||
|
||||
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
||||
if (pluginConfig.disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
// Initialize collections
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
config.collections.push(
|
||||
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
|
||||
createCustomersCollection(pluginConfig.collections?.customers || 'customers'),
|
||||
createInvoicesCollection(pluginConfig.collections?.invoices || 'invoices'),
|
||||
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
|
||||
)
|
||||
|
||||
// Initialize endpoints
|
||||
if (!config.endpoints) {
|
||||
config.endpoints = []
|
||||
}
|
||||
|
||||
config.endpoints?.push(
|
||||
// Webhook endpoints
|
||||
{
|
||||
handler: async (req) => {
|
||||
try {
|
||||
const provider = providerRegistry.get(req.routeParams?.provider as string)
|
||||
if (!provider) {
|
||||
return Response.json({ error: 'Provider not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const signature = req.headers.get('stripe-signature') ||
|
||||
req.headers.get('x-mollie-signature')
|
||||
|
||||
const event = await provider.handleWebhook(req as unknown as Request, signature || '')
|
||||
|
||||
// TODO: Process webhook event and update database
|
||||
|
||||
return Response.json({ eventId: event.id, received: true })
|
||||
} catch (error) {
|
||||
console.error('[BILLING] Webhook error:', error)
|
||||
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
|
||||
}
|
||||
},
|
||||
method: 'post',
|
||||
path: '/billing/webhooks/:provider'
|
||||
},
|
||||
// Health check endpoint
|
||||
{
|
||||
handler: async () => {
|
||||
const providers = providerRegistry.getAll().map(p => ({
|
||||
name: p.name,
|
||||
status: 'active'
|
||||
}))
|
||||
|
||||
return Response.json({
|
||||
providers,
|
||||
status: 'ok',
|
||||
version: '0.1.0'
|
||||
})
|
||||
},
|
||||
method: 'get',
|
||||
path: '/billing/health'
|
||||
}
|
||||
)
|
||||
|
||||
// Initialize providers and onInit hook
|
||||
const incomingOnInit = config.onInit
|
||||
|
||||
config.onInit = async (payload) => {
|
||||
// Execute any existing onInit functions first
|
||||
if (incomingOnInit) {
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
|
||||
// Initialize payment providers
|
||||
initializeProviders(pluginConfig)
|
||||
|
||||
// Log initialization
|
||||
console.log('[BILLING] Plugin initialized with providers:',
|
||||
providerRegistry.getAll().map(p => p.name).join(', ')
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function initializeProviders(config: BillingPluginConfig) {
|
||||
// Initialize test provider if enabled
|
||||
if (config.providers?.test?.enabled) {
|
||||
const testProvider = new TestPaymentProvider(config.providers.test)
|
||||
providerRegistry.register(testProvider)
|
||||
}
|
||||
|
||||
// TODO: Initialize Stripe provider
|
||||
// TODO: Initialize Mollie provider
|
||||
}
|
||||
|
||||
// Utility function to get payment provider
|
||||
export function getPaymentProvider(name: string) {
|
||||
const provider = providerRegistry.get(name)
|
||||
if (!provider) {
|
||||
throw new Error(`Payment provider '${name}' not found`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// Utility function to list available providers
|
||||
export function getAvailableProviders() {
|
||||
return providerRegistry.getAll().map(p => ({
|
||||
name: p.name,
|
||||
// Add provider-specific info here
|
||||
}))
|
||||
}
|
||||
|
||||
export default billingPlugin
|
||||
// Export all providers
|
||||
export { testProvider } from './providers/test.js'
|
||||
export type {
|
||||
StripeProviderConfig,
|
||||
MollieProviderConfig,
|
||||
TestProviderConfig,
|
||||
TestProviderConfigResponse,
|
||||
PaymentOutcome,
|
||||
PaymentMethod,
|
||||
PaymentScenario
|
||||
} from './providers/index.js'
|
||||
|
||||
68
src/plugin/config.ts
Normal file
68
src/plugin/config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import type { FieldsOverride } from './utils'
|
||||
import type { PaymentProvider } from './types/index'
|
||||
|
||||
export const defaults = {
|
||||
paymentsCollection: 'payments',
|
||||
invoicesCollection: 'invoices',
|
||||
refundsCollection: 'refunds',
|
||||
customerRelationSlug: 'customer'
|
||||
}
|
||||
|
||||
// Provider configurations
|
||||
|
||||
export interface TestProviderConfig {
|
||||
autoComplete?: boolean
|
||||
defaultDelay?: number
|
||||
enabled: boolean
|
||||
failureRate?: number
|
||||
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
|
||||
export interface CustomerInfoExtractor {
|
||||
(customer: any): {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
company?: string
|
||||
taxId?: string
|
||||
billingAddress?: {
|
||||
line1: string
|
||||
line2?: string
|
||||
city: string
|
||||
state?: string
|
||||
postalCode: string
|
||||
country: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collection configuration type
|
||||
export type CollectionExtension =
|
||||
| string
|
||||
| {
|
||||
slug: string
|
||||
extend?: (config: CollectionConfig) => CollectionConfig
|
||||
}
|
||||
|
||||
// Plugin configuration
|
||||
export interface BillingPluginConfig {
|
||||
admin?: {
|
||||
customComponents?: boolean
|
||||
dashboard?: boolean
|
||||
}
|
||||
collections?: {
|
||||
invoices?: CollectionExtension
|
||||
payments?: CollectionExtension
|
||||
refunds?: CollectionExtension
|
||||
}
|
||||
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
||||
customerRelationSlug?: string // Customer collection slug for relationship
|
||||
disabled?: boolean
|
||||
providers?: (PaymentProvider | undefined | null)[]
|
||||
}
|
||||
|
||||
56
src/plugin/index.ts
Normal file
56
src/plugin/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index'
|
||||
import type { BillingPluginConfig } from './config'
|
||||
import type { Config, Payload } from 'payload'
|
||||
import { createSingleton } from './singleton'
|
||||
import type { PaymentProvider } from '../providers/index'
|
||||
|
||||
const singleton = createSingleton(Symbol('billingPlugin'))
|
||||
|
||||
type BillingPlugin = {
|
||||
config: BillingPluginConfig
|
||||
providerConfig: {
|
||||
[key: string]: PaymentProvider
|
||||
}
|
||||
}
|
||||
|
||||
export const useBillingPlugin = (payload: Payload) => singleton.get(payload) as BillingPlugin | undefined
|
||||
|
||||
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
||||
if (pluginConfig.disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
config.collections = [
|
||||
...(config.collections || []),
|
||||
createPaymentsCollection(pluginConfig),
|
||||
createInvoicesCollection(pluginConfig),
|
||||
createRefundsCollection(pluginConfig),
|
||||
];
|
||||
|
||||
(pluginConfig.providers || [])
|
||||
.filter(provider => provider?.onConfig)
|
||||
.forEach(provider => provider?.onConfig!(config, pluginConfig))
|
||||
|
||||
const incomingOnInit = config.onInit
|
||||
config.onInit = async (payload) => {
|
||||
if (incomingOnInit) {
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
singleton.set(payload, {
|
||||
config: pluginConfig,
|
||||
providerConfig: (pluginConfig.providers || []).filter(Boolean).reduce(
|
||||
(record, provider) => {
|
||||
record[provider!.key] = provider as PaymentProvider
|
||||
return record
|
||||
},
|
||||
{} as Record<string, PaymentProvider>
|
||||
)
|
||||
} satisfies BillingPlugin)
|
||||
await Promise.all((pluginConfig.providers || [])
|
||||
.filter(provider => provider?.onInit)
|
||||
.map(provider => provider?.onInit!(payload)))
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
export default billingPlugin
|
||||
11
src/plugin/singleton.ts
Normal file
11
src/plugin/singleton.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const createSingleton = <T>(s?: symbol | string) => {
|
||||
const symbol = !s ? Symbol() : s
|
||||
return {
|
||||
get(container: any) {
|
||||
return container[symbol] as T
|
||||
},
|
||||
set(container: any, value: T) {
|
||||
container[symbol] = value
|
||||
},
|
||||
}
|
||||
}
|
||||
1
src/plugin/types/id.ts
Normal file
1
src/plugin/types/id.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Id = string | number
|
||||
5
src/plugin/types/index.ts
Normal file
5
src/plugin/types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './id.js'
|
||||
export * from './invoices.js'
|
||||
export * from './payments.js'
|
||||
export * from './refunds.js'
|
||||
export * from '../../providers/types.js'
|
||||
116
src/plugin/types/invoices.ts
Normal file
116
src/plugin/types/invoices.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { Payment } from './payments'
|
||||
import type { Id } from './id'
|
||||
|
||||
export interface Invoice<TCustomer = unknown> {
|
||||
id: Id;
|
||||
/**
|
||||
* Invoice number (e.g., INV-001)
|
||||
*/
|
||||
number: string;
|
||||
/**
|
||||
* Link to customer record (optional)
|
||||
*/
|
||||
customer?: (Id | null) | TCustomer;
|
||||
/**
|
||||
* Customer billing information (auto-populated from customer relationship)
|
||||
*/
|
||||
customerInfo?: {
|
||||
/**
|
||||
* Customer name
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Customer email address
|
||||
*/
|
||||
email?: string | null;
|
||||
/**
|
||||
* Customer phone number
|
||||
*/
|
||||
phone?: string | null;
|
||||
/**
|
||||
* Company name (optional)
|
||||
*/
|
||||
company?: string | null;
|
||||
/**
|
||||
* Tax ID or VAT number
|
||||
*/
|
||||
taxId?: string | null;
|
||||
};
|
||||
/**
|
||||
* Billing address (auto-populated from customer relationship)
|
||||
*/
|
||||
billingAddress?: {
|
||||
/**
|
||||
* Address line 1
|
||||
*/
|
||||
line1?: string | null;
|
||||
/**
|
||||
* Address line 2
|
||||
*/
|
||||
line2?: string | null;
|
||||
city?: string | null;
|
||||
/**
|
||||
* State or province
|
||||
*/
|
||||
state?: string | null;
|
||||
/**
|
||||
* Postal or ZIP code
|
||||
*/
|
||||
postalCode?: string | null;
|
||||
/**
|
||||
* Country code (e.g., US, GB)
|
||||
*/
|
||||
country?: string | null;
|
||||
};
|
||||
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
|
||||
/**
|
||||
* ISO 4217 currency code (e.g., USD, EUR)
|
||||
*/
|
||||
currency: string;
|
||||
items: {
|
||||
description: string;
|
||||
quantity: number;
|
||||
/**
|
||||
* Amount in cents
|
||||
*/
|
||||
unitAmount: number;
|
||||
/**
|
||||
* Calculated: quantity × unitAmount
|
||||
*/
|
||||
totalAmount?: number | null;
|
||||
id?: Id | null;
|
||||
}[];
|
||||
/**
|
||||
* Sum of all line items
|
||||
*/
|
||||
subtotal?: number | null;
|
||||
/**
|
||||
* Tax amount in cents
|
||||
*/
|
||||
taxAmount?: number | null;
|
||||
/**
|
||||
* Total amount (subtotal + tax)
|
||||
*/
|
||||
amount?: number | null;
|
||||
dueDate?: string | null;
|
||||
paidAt?: string | null;
|
||||
payment?: (number | null) | Payment;
|
||||
/**
|
||||
* Internal notes
|
||||
*/
|
||||
notes?: string | null;
|
||||
/**
|
||||
* Additional invoice metadata
|
||||
*/
|
||||
metadata?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
61
src/plugin/types/payments.ts
Normal file
61
src/plugin/types/payments.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Refund } from './refunds'
|
||||
import type { Invoice } from './invoices'
|
||||
import type { Id } from './id'
|
||||
|
||||
export interface Payment {
|
||||
id: Id;
|
||||
provider: 'stripe' | 'mollie' | 'test';
|
||||
/**
|
||||
* The payment ID from the payment provider
|
||||
*/
|
||||
providerId: Id;
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
|
||||
/**
|
||||
* Amount in cents (e.g., 2000 = $20.00)
|
||||
*/
|
||||
amount: number;
|
||||
/**
|
||||
* ISO 4217 currency code (e.g., USD, EUR)
|
||||
*/
|
||||
currency: string;
|
||||
/**
|
||||
* Payment description
|
||||
*/
|
||||
description?: string | null;
|
||||
/**
|
||||
* Checkout URL where user can complete payment (if applicable)
|
||||
*/
|
||||
checkoutUrl?: string | null;
|
||||
invoice?: (Id | null) | Invoice;
|
||||
/**
|
||||
* Additional metadata for the payment
|
||||
*/
|
||||
metadata?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Raw data from the payment provider
|
||||
*/
|
||||
providerData?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
refunds?: (number | Refund)[] | null;
|
||||
/**
|
||||
* Version number for optimistic locking (auto-incremented on updates)
|
||||
*/
|
||||
version?: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
53
src/plugin/types/refunds.ts
Normal file
53
src/plugin/types/refunds.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Payment } from './payments'
|
||||
|
||||
export interface Refund {
|
||||
id: number;
|
||||
/**
|
||||
* The refund ID from the payment provider
|
||||
*/
|
||||
providerId: string;
|
||||
payment: number | Payment;
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled';
|
||||
/**
|
||||
* Refund amount in cents
|
||||
*/
|
||||
amount: number;
|
||||
/**
|
||||
* ISO 4217 currency code (e.g., USD, EUR)
|
||||
*/
|
||||
currency: string;
|
||||
/**
|
||||
* Reason for the refund
|
||||
*/
|
||||
reason?: ('duplicate' | 'fraudulent' | 'requested_by_customer' | 'other') | null;
|
||||
/**
|
||||
* Additional details about the refund
|
||||
*/
|
||||
description?: string | null;
|
||||
/**
|
||||
* Additional refund metadata
|
||||
*/
|
||||
metadata?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Raw data from the payment provider
|
||||
*/
|
||||
providerData?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
28
src/plugin/utils.ts
Normal file
28
src/plugin/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
||||
import type { Id } from './types/index'
|
||||
import type { CollectionExtension } from './config'
|
||||
|
||||
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
||||
|
||||
/**
|
||||
* 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
|
||||
* This utility provides a typed way to handle the mismatch between our Id type and PayloadCMS expectations
|
||||
*/
|
||||
export function toPayloadId(id: Id): any {
|
||||
return id as any
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { CreatePaymentOptions, Payment, PaymentProvider, Refund, WebhookEvent } from '../../types'
|
||||
|
||||
export abstract class BasePaymentProvider implements PaymentProvider {
|
||||
abstract name: string
|
||||
|
||||
protected formatAmount(amount: number, currency: string): number {
|
||||
this.validateAmount(amount)
|
||||
this.validateCurrency(currency)
|
||||
return amount
|
||||
}
|
||||
protected log(level: 'error' | 'info' | 'warn', message: string, data?: Record<string, unknown>): void {
|
||||
const logData = {
|
||||
message,
|
||||
provider: this.name,
|
||||
...data,
|
||||
}
|
||||
|
||||
console[level](`[${this.name.toUpperCase()}]`, logData)
|
||||
}
|
||||
protected validateAmount(amount: number): void {
|
||||
if (amount <= 0 || !Number.isInteger(amount)) {
|
||||
throw new Error('Amount must be a positive integer in cents')
|
||||
}
|
||||
}
|
||||
protected validateCurrency(currency: string): void {
|
||||
if (!currency || currency.length !== 3) {
|
||||
throw new Error('Currency must be a valid 3-letter ISO currency code')
|
||||
}
|
||||
}
|
||||
abstract cancelPayment(id: string): Promise<Payment>
|
||||
|
||||
abstract createPayment(options: CreatePaymentOptions): Promise<Payment>
|
||||
|
||||
abstract handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
|
||||
|
||||
abstract refundPayment(id: string, amount?: number): Promise<Refund>
|
||||
|
||||
abstract retrievePayment(id: string): Promise<Payment>
|
||||
}
|
||||
|
||||
export function createProviderRegistry() {
|
||||
const providers = new Map<string, PaymentProvider>()
|
||||
|
||||
return {
|
||||
register(provider: PaymentProvider): void {
|
||||
providers.set(provider.name, provider)
|
||||
},
|
||||
|
||||
get(name: string): PaymentProvider | undefined {
|
||||
return providers.get(name)
|
||||
},
|
||||
|
||||
getAll(): PaymentProvider[] {
|
||||
return Array.from(providers.values())
|
||||
},
|
||||
|
||||
has(name: string): boolean {
|
||||
return providers.has(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const providerRegistry = createProviderRegistry()
|
||||
94
src/providers/currency.ts
Normal file
94
src/providers/currency.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Currency utilities for payment processing
|
||||
*/
|
||||
|
||||
// Currencies that don't use centesimal units (no decimal places)
|
||||
const NON_CENTESIMAL_CURRENCIES = new Set([
|
||||
'BIF', // Burundian Franc
|
||||
'CLP', // Chilean Peso
|
||||
'DJF', // Djiboutian Franc
|
||||
'GNF', // Guinean Franc
|
||||
'JPY', // Japanese Yen
|
||||
'KMF', // Comorian Franc
|
||||
'KRW', // South Korean Won
|
||||
'MGA', // Malagasy Ariary
|
||||
'PYG', // Paraguayan Guaraní
|
||||
'RWF', // Rwandan Franc
|
||||
'UGX', // Ugandan Shilling
|
||||
'VND', // Vietnamese Đồng
|
||||
'VUV', // Vanuatu Vatu
|
||||
'XAF', // Central African CFA Franc
|
||||
'XOF', // West African CFA Franc
|
||||
'XPF', // CFP Franc
|
||||
])
|
||||
|
||||
// Currencies that use 3 decimal places
|
||||
const THREE_DECIMAL_CURRENCIES = new Set([
|
||||
'BHD', // Bahraini Dinar
|
||||
'IQD', // Iraqi Dinar
|
||||
'JOD', // Jordanian Dinar
|
||||
'KWD', // Kuwaiti Dinar
|
||||
'LYD', // Libyan Dinar
|
||||
'OMR', // Omani Rial
|
||||
'TND', // Tunisian Dinar
|
||||
])
|
||||
|
||||
/**
|
||||
* Convert amount from smallest unit to decimal for display
|
||||
* @param amount - Amount in smallest unit (e.g., cents for USD)
|
||||
* @param currency - ISO 4217 currency code
|
||||
* @returns Formatted amount string for the payment provider
|
||||
*/
|
||||
export function formatAmountForProvider(amount: number, currency: string): string {
|
||||
const upperCurrency = currency.toUpperCase()
|
||||
|
||||
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
|
||||
// No decimal places
|
||||
return amount.toString()
|
||||
}
|
||||
|
||||
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
|
||||
// 3 decimal places
|
||||
return (amount / 1000).toFixed(3)
|
||||
}
|
||||
|
||||
// Default: 2 decimal places (most currencies)
|
||||
return (amount / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of decimal places for a currency
|
||||
* @param currency - ISO 4217 currency code
|
||||
* @returns Number of decimal places
|
||||
*/
|
||||
export function getCurrencyDecimals(currency: string): number {
|
||||
const upperCurrency = currency.toUpperCase()
|
||||
|
||||
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
|
||||
return 3
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate currency code format
|
||||
* @param currency - Currency code to validate
|
||||
* @returns True if valid ISO 4217 format
|
||||
*/
|
||||
export function isValidCurrencyCode(currency: string): boolean {
|
||||
return /^[A-Z]{3}$/.test(currency.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate amount is positive and within reasonable limits
|
||||
* @param amount - Amount to validate
|
||||
* @returns True if valid
|
||||
*/
|
||||
export function isValidAmount(amount: number): boolean {
|
||||
return Number.isInteger(amount) && amount > 0 && amount <= 99999999999 // Max ~999 million in major units
|
||||
}
|
||||
10
src/providers/index.ts
Normal file
10
src/providers/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './mollie'
|
||||
export * from './stripe'
|
||||
export * from './test'
|
||||
export * from './types'
|
||||
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'
|
||||
162
src/providers/mollie.ts
Normal file
162
src/providers/mollie.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { PaymentProvider } from '../plugin/types/index'
|
||||
import type { Payload } from 'payload'
|
||||
import { createSingleton } from '../plugin/singleton'
|
||||
import type { createMollieClient, MollieClient } from '@mollie/api-client'
|
||||
import {
|
||||
webhookResponses,
|
||||
findPaymentByProviderId,
|
||||
updatePaymentStatus,
|
||||
updateInvoiceOnPaymentSuccess,
|
||||
handleWebhookError,
|
||||
validateProductionUrl
|
||||
} from './utils'
|
||||
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
const symbol = Symbol('mollie')
|
||||
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
||||
|
||||
/**
|
||||
* Type-safe mapping of Mollie payment status to internal status
|
||||
*/
|
||||
function mapMollieStatusToPaymentStatus(mollieStatus: string): Payment['status'] {
|
||||
// Define known Mollie statuses for type safety
|
||||
const mollieStatusMap: Record<string, Payment['status']> = {
|
||||
'paid': 'succeeded',
|
||||
'failed': 'failed',
|
||||
'canceled': 'canceled',
|
||||
'expired': 'canceled',
|
||||
'pending': 'pending',
|
||||
'open': 'pending',
|
||||
'authorized': 'pending',
|
||||
}
|
||||
|
||||
return mollieStatusMap[mollieStatus] || 'processing'
|
||||
}
|
||||
|
||||
export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
||||
webhookUrl?: string
|
||||
redirectUrl?: string
|
||||
}) => {
|
||||
// Validate required configuration at initialization
|
||||
if (!mollieConfig.apiKey) {
|
||||
throw new Error('Mollie API key is required')
|
||||
}
|
||||
|
||||
const singleton = createSingleton<MollieClient>(symbol)
|
||||
return {
|
||||
key: 'mollie',
|
||||
onConfig: (config, pluginConfig) => {
|
||||
// Always register Mollie webhook since it doesn't require a separate webhook secret
|
||||
// Mollie validates webhooks through payment ID verification
|
||||
config.endpoints = [
|
||||
...(config.endpoints || []),
|
||||
{
|
||||
path: '/payload-billing/mollie/webhook',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
try {
|
||||
const payload = req.payload
|
||||
const mollieClient = singleton.get(payload)
|
||||
|
||||
// Parse the webhook body to get the Mollie payment ID
|
||||
if (!req.text) {
|
||||
return webhookResponses.missingBody()
|
||||
}
|
||||
const body = await req.text()
|
||||
if (!body || !body.startsWith('id=')) {
|
||||
return webhookResponses.invalidPayload()
|
||||
}
|
||||
|
||||
const molliePaymentId = body.slice(3) // Remove 'id=' prefix
|
||||
|
||||
// Fetch the payment details from Mollie
|
||||
const molliePayment = await mollieClient.payments.get(molliePaymentId)
|
||||
|
||||
// Find the corresponding payment in our database
|
||||
const payment = await findPaymentByProviderId(payload, molliePaymentId, pluginConfig)
|
||||
|
||||
if (!payment) {
|
||||
return webhookResponses.paymentNotFound()
|
||||
}
|
||||
|
||||
// Map Mollie status to our status using proper type-safe mapping
|
||||
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
|
||||
|
||||
// Update the payment status and provider data
|
||||
const updateSuccess = await updatePaymentStatus(
|
||||
payload,
|
||||
payment.id,
|
||||
status,
|
||||
molliePayment.toPlainObject(),
|
||||
pluginConfig
|
||||
)
|
||||
|
||||
// If payment is successful and update succeeded, update the invoice
|
||||
if (status === 'succeeded' && updateSuccess) {
|
||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||
} else if (!updateSuccess) {
|
||||
const logger = createContextLogger(payload, 'Mollie Webhook')
|
||||
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
|
||||
}
|
||||
|
||||
return webhookResponses.success()
|
||||
} catch (error) {
|
||||
return handleWebhookError('Mollie', error, undefined, req.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
onInit: async (payload: Payload) => {
|
||||
const createMollieClient = (await import('@mollie/api-client')).default
|
||||
const mollieClient = createMollieClient(mollieConfig)
|
||||
singleton.set(payload, mollieClient)
|
||||
},
|
||||
initPayment: async (payload, payment) => {
|
||||
// Validate required fields
|
||||
if (!payment.amount) {
|
||||
throw new Error('Amount is required')
|
||||
}
|
||||
if (!payment.currency) {
|
||||
throw new Error('Currency is required')
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
if (!isValidAmount(payment.amount)) {
|
||||
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
|
||||
}
|
||||
|
||||
// Validate currency code
|
||||
if (!isValidCurrencyCode(payment.currency)) {
|
||||
throw new Error('Invalid currency: must be a 3-letter ISO code')
|
||||
}
|
||||
|
||||
// Setup URLs with development defaults
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
const redirectUrl = mollieConfig.redirectUrl ||
|
||||
(!isProduction ? 'https://localhost:3000/payment/success' : undefined)
|
||||
const webhookUrl = mollieConfig.webhookUrl ||
|
||||
`${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook`
|
||||
|
||||
// Validate URLs for production
|
||||
validateProductionUrl(redirectUrl, 'Redirect')
|
||||
validateProductionUrl(webhookUrl, 'Webhook')
|
||||
|
||||
const molliePayment = await singleton.get(payload).payments.create({
|
||||
amount: {
|
||||
value: formatAmountForProvider(payment.amount, payment.currency),
|
||||
currency: payment.currency.toUpperCase()
|
||||
},
|
||||
description: payment.description || '',
|
||||
redirectUrl,
|
||||
webhookUrl,
|
||||
});
|
||||
payment.providerId = molliePayment.id
|
||||
payment.providerData = molliePayment.toPlainObject()
|
||||
payment.checkoutUrl = molliePayment._links?.checkout?.href || null
|
||||
return payment
|
||||
},
|
||||
} satisfies PaymentProvider
|
||||
}
|
||||
266
src/providers/stripe.ts
Normal file
266
src/providers/stripe.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||
import type { Payload } from 'payload'
|
||||
import { createSingleton } from '../plugin/singleton'
|
||||
import type Stripe from 'stripe'
|
||||
import {
|
||||
webhookResponses,
|
||||
findPaymentByProviderId,
|
||||
updatePaymentStatus,
|
||||
updateInvoiceOnPaymentSuccess,
|
||||
handleWebhookError,
|
||||
logWebhookEvent
|
||||
} from './utils'
|
||||
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
const symbol = Symbol('stripe')
|
||||
|
||||
export interface StripeProviderConfig {
|
||||
secretKey: string
|
||||
webhookSecret?: string
|
||||
apiVersion?: Stripe.StripeConfig['apiVersion']
|
||||
returnUrl?: string
|
||||
webhookUrl?: string
|
||||
}
|
||||
|
||||
// Default API version for consistency
|
||||
const DEFAULT_API_VERSION: Stripe.StripeConfig['apiVersion'] = '2025-08-27.basil'
|
||||
|
||||
export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||
// Validate required configuration at initialization
|
||||
if (!stripeConfig.secretKey) {
|
||||
throw new Error('Stripe secret key is required')
|
||||
}
|
||||
|
||||
const singleton = createSingleton<Stripe>(symbol)
|
||||
|
||||
return {
|
||||
key: 'stripe',
|
||||
onConfig: (config, pluginConfig) => {
|
||||
// Only register webhook endpoint if webhook secret is configured
|
||||
if (stripeConfig.webhookSecret) {
|
||||
config.endpoints = [
|
||||
...(config.endpoints || []),
|
||||
{
|
||||
path: '/payload-billing/stripe/webhook',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
try {
|
||||
const payload = req.payload
|
||||
const stripe = singleton.get(payload)
|
||||
|
||||
// Get the raw body for signature verification
|
||||
let body: string
|
||||
try {
|
||||
if (!req.text) {
|
||||
return webhookResponses.missingBody()
|
||||
}
|
||||
body = await req.text()
|
||||
if (!body) {
|
||||
return webhookResponses.missingBody()
|
||||
}
|
||||
} catch (error) {
|
||||
return handleWebhookError('Stripe', error, 'Failed to read request body', req.payload)
|
||||
}
|
||||
|
||||
const signature = req.headers.get('stripe-signature')
|
||||
|
||||
if (!signature) {
|
||||
return webhookResponses.error('Missing webhook signature', 400, req.payload)
|
||||
}
|
||||
|
||||
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
|
||||
|
||||
// Verify webhook signature and construct event
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
|
||||
} catch (err) {
|
||||
return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload)
|
||||
}
|
||||
|
||||
// Handle different event types
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
case 'payment_intent.payment_failed':
|
||||
case 'payment_intent.canceled': {
|
||||
const paymentIntent = event.data.object
|
||||
|
||||
// Find the corresponding payment in our database
|
||||
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
||||
|
||||
if (!payment) {
|
||||
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`, undefined, req.payload)
|
||||
return webhookResponses.success() // Still return 200 to acknowledge receipt
|
||||
}
|
||||
|
||||
// Map Stripe status to our status
|
||||
let status: Payment['status'] = 'pending'
|
||||
|
||||
if (paymentIntent.status === 'succeeded') {
|
||||
status = 'succeeded'
|
||||
} else if (paymentIntent.status === 'canceled') {
|
||||
status = 'canceled'
|
||||
} else if (paymentIntent.status === 'requires_payment_method' ||
|
||||
paymentIntent.status === 'requires_confirmation' ||
|
||||
paymentIntent.status === 'requires_action') {
|
||||
status = 'pending'
|
||||
} else if (paymentIntent.status === 'processing') {
|
||||
status = 'processing'
|
||||
} else {
|
||||
status = 'failed'
|
||||
}
|
||||
|
||||
// Update the payment status and provider data
|
||||
const providerData: ProviderData<Stripe.PaymentIntent> = {
|
||||
raw: paymentIntent,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'stripe'
|
||||
}
|
||||
const updateSuccess = await updatePaymentStatus(
|
||||
payload,
|
||||
payment.id,
|
||||
status,
|
||||
providerData,
|
||||
pluginConfig
|
||||
)
|
||||
|
||||
// If payment is successful and update succeeded, update the invoice
|
||||
if (status === 'succeeded' && updateSuccess) {
|
||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||
} else if (!updateSuccess) {
|
||||
const logger = createContextLogger(payload, 'Stripe Webhook')
|
||||
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'charge.refunded': {
|
||||
const charge = event.data.object
|
||||
|
||||
// Find the payment by charge ID or payment intent
|
||||
let payment: Payment | null = null
|
||||
|
||||
// First try to find by payment intent ID
|
||||
if (charge.payment_intent) {
|
||||
payment = await findPaymentByProviderId(
|
||||
payload,
|
||||
charge.payment_intent as string,
|
||||
pluginConfig
|
||||
)
|
||||
}
|
||||
|
||||
// If not found, try charge ID
|
||||
if (!payment) {
|
||||
payment = await findPaymentByProviderId(payload, charge.id, pluginConfig)
|
||||
}
|
||||
|
||||
if (payment) {
|
||||
// Determine if fully or partially refunded
|
||||
const isFullyRefunded = charge.amount_refunded === charge.amount
|
||||
|
||||
const providerData: ProviderData<Stripe.Charge> = {
|
||||
raw: charge,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'stripe'
|
||||
}
|
||||
const updateSuccess = await updatePaymentStatus(
|
||||
payload,
|
||||
payment.id,
|
||||
isFullyRefunded ? 'refunded' : 'partially_refunded',
|
||||
providerData,
|
||||
pluginConfig
|
||||
)
|
||||
|
||||
if (!updateSuccess) {
|
||||
const logger = createContextLogger(payload, 'Stripe Webhook')
|
||||
logger.warn(`Failed to update refund status for payment ${payment.id}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
// Unhandled event type
|
||||
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload)
|
||||
}
|
||||
|
||||
return webhookResponses.success()
|
||||
} catch (error) {
|
||||
return handleWebhookError('Stripe', error, undefined, req.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
onInit: async (payload: Payload) => {
|
||||
const { default: Stripe } = await import('stripe')
|
||||
const stripe = new Stripe(stripeConfig.secretKey, {
|
||||
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
|
||||
})
|
||||
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) => {
|
||||
// Validate required fields
|
||||
if (!payment.amount) {
|
||||
throw new Error('Amount is required')
|
||||
}
|
||||
if (!payment.currency) {
|
||||
throw new Error('Currency is required')
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
if (!isValidAmount(payment.amount)) {
|
||||
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
|
||||
}
|
||||
|
||||
// Validate currency code
|
||||
if (!isValidCurrencyCode(payment.currency)) {
|
||||
throw new Error('Invalid currency: must be a 3-letter ISO code')
|
||||
}
|
||||
|
||||
// Validate description length if provided
|
||||
if (payment.description && payment.description.length > 1000) {
|
||||
throw new Error('Description must be 1000 characters or less')
|
||||
}
|
||||
|
||||
const stripe = singleton.get(payload)
|
||||
|
||||
// Create a payment intent
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: payment.amount, // Stripe handles currency conversion internally
|
||||
currency: payment.currency.toLowerCase(),
|
||||
description: payment.description || undefined,
|
||||
metadata: {
|
||||
payloadPaymentId: payment.id?.toString() || '',
|
||||
...(typeof payment.metadata === 'object' &&
|
||||
payment.metadata !== null &&
|
||||
!Array.isArray(payment.metadata)
|
||||
? payment.metadata
|
||||
: {})
|
||||
} as Stripe.MetadataParam,
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
payment.providerId = paymentIntent.id
|
||||
const providerData: ProviderData<Stripe.PaymentIntent> = {
|
||||
raw: { ...paymentIntent, client_secret: paymentIntent.client_secret },
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'stripe'
|
||||
}
|
||||
payment.providerData = providerData
|
||||
|
||||
return payment
|
||||
},
|
||||
} satisfies PaymentProvider
|
||||
}
|
||||
943
src/providers/test.ts
Normal file
943
src/providers/test.ts
Normal file
@@ -0,0 +1,943 @@
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||
import type { BillingPluginConfig } from '../plugin/config'
|
||||
import type { Payload } from 'payload'
|
||||
import { handleWebhookError, logWebhookEvent } from './utils'
|
||||
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
const TestModeWarningSymbol = Symbol('TestModeWarning')
|
||||
const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis
|
||||
const setTestModeWarning = () => ((<any>globalThis)[TestModeWarningSymbol] = true)
|
||||
|
||||
|
||||
// Request validation schemas
|
||||
interface ProcessPaymentRequest {
|
||||
paymentId: string
|
||||
scenarioId: string
|
||||
method: PaymentMethod
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
function validateProcessPaymentRequest(body: any): { isValid: boolean; data?: ProcessPaymentRequest; error?: string } {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { isValid: false, error: 'Request body must be a valid JSON object' }
|
||||
}
|
||||
|
||||
const { paymentId, scenarioId, method } = body
|
||||
|
||||
if (!paymentId || typeof paymentId !== 'string') {
|
||||
return { isValid: false, error: 'paymentId is required and must be a string' }
|
||||
}
|
||||
|
||||
if (!scenarioId || typeof scenarioId !== 'string') {
|
||||
return { isValid: false, error: 'scenarioId is required and must be a string' }
|
||||
}
|
||||
|
||||
if (!method || typeof method !== 'string') {
|
||||
return { isValid: false, error: 'method is required and must be a string' }
|
||||
}
|
||||
|
||||
// Validate method is a valid payment method
|
||||
const validMethods: PaymentMethod[] = ['ideal', 'creditcard', 'paypal', 'applepay', 'banktransfer']
|
||||
if (!validMethods.includes(method as PaymentMethod)) {
|
||||
return { isValid: false, error: `method must be one of: ${validMethods.join(', ')}` }
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
data: { paymentId, scenarioId, method: method as PaymentMethod }
|
||||
}
|
||||
}
|
||||
|
||||
function validatePaymentId(paymentId: string): { isValid: boolean; error?: string } {
|
||||
if (!paymentId || typeof paymentId !== 'string') {
|
||||
return { isValid: false, error: 'Payment ID is required and must be a string' }
|
||||
}
|
||||
|
||||
// Validate payment ID format (should match test payment ID pattern)
|
||||
if (!paymentId.startsWith('test_pay_')) {
|
||||
return { isValid: false, error: 'Invalid payment ID format' }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
// Utility function to safely extract collection name
|
||||
function getPaymentsCollectionName(pluginConfig: BillingPluginConfig): string {
|
||||
if (typeof pluginConfig.collections?.payments === 'string') {
|
||||
return pluginConfig.collections.payments
|
||||
}
|
||||
return 'payments'
|
||||
}
|
||||
|
||||
// Enhanced error handling utility for database operations
|
||||
async function updatePaymentInDatabase(
|
||||
payload: Payload,
|
||||
sessionId: string,
|
||||
status: Payment['status'],
|
||||
providerData: ProviderData,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const paymentsCollection = getPaymentsCollectionName(pluginConfig)
|
||||
const payments = await payload.find({
|
||||
collection: paymentsCollection as any, // PayloadCMS collection type constraint
|
||||
where: { providerId: { equals: sessionId } },
|
||||
limit: 1
|
||||
})
|
||||
|
||||
if (payments.docs.length === 0) {
|
||||
return { success: false, error: 'Payment not found in database' }
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: paymentsCollection as any, // PayloadCMS collection type constraint
|
||||
id: payments.docs[0].id,
|
||||
data: {
|
||||
status,
|
||||
providerData
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown database error'
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Database update failed:', errorMessage)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending'
|
||||
|
||||
export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer'
|
||||
|
||||
export interface PaymentScenario {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
outcome: PaymentOutcome
|
||||
delay?: number // Delay in milliseconds before processing
|
||||
method?: PaymentMethod
|
||||
}
|
||||
|
||||
export interface TestProviderConfig {
|
||||
enabled: boolean
|
||||
scenarios?: PaymentScenario[]
|
||||
customUiRoute?: string
|
||||
testModeIndicators?: {
|
||||
showWarningBanners?: boolean
|
||||
showTestBadges?: boolean
|
||||
consoleWarnings?: boolean
|
||||
}
|
||||
defaultDelay?: number
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
export interface TestProviderConfigResponse {
|
||||
enabled: boolean
|
||||
scenarios: PaymentScenario[]
|
||||
methods: Array<{
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
}>
|
||||
testModeIndicators: {
|
||||
showWarningBanners: boolean
|
||||
showTestBadges: boolean
|
||||
consoleWarnings: boolean
|
||||
}
|
||||
defaultDelay: number
|
||||
customUiRoute: string
|
||||
}
|
||||
|
||||
// Properly typed session interface
|
||||
export interface TestPaymentSession {
|
||||
id: string
|
||||
payment: Partial<Payment>
|
||||
scenario?: PaymentScenario
|
||||
method?: PaymentMethod
|
||||
createdAt: Date
|
||||
status: PaymentOutcome
|
||||
}
|
||||
|
||||
// Use the proper BillingPluginConfig type
|
||||
|
||||
// Default payment scenarios
|
||||
const DEFAULT_SCENARIOS: PaymentScenario[] = [
|
||||
{
|
||||
id: 'instant-success',
|
||||
name: 'Instant Success',
|
||||
description: 'Payment succeeds immediately',
|
||||
outcome: 'paid',
|
||||
delay: 0
|
||||
},
|
||||
{
|
||||
id: 'delayed-success',
|
||||
name: 'Delayed Success',
|
||||
description: 'Payment succeeds after a delay',
|
||||
outcome: 'paid',
|
||||
delay: 3000
|
||||
},
|
||||
{
|
||||
id: 'cancelled-payment',
|
||||
name: 'Cancelled Payment',
|
||||
description: 'User cancels the payment',
|
||||
outcome: 'cancelled',
|
||||
delay: 1000
|
||||
},
|
||||
{
|
||||
id: 'declined-payment',
|
||||
name: 'Declined Payment',
|
||||
description: 'Payment is declined by the provider',
|
||||
outcome: 'failed',
|
||||
delay: 2000
|
||||
},
|
||||
{
|
||||
id: 'expired-payment',
|
||||
name: 'Expired Payment',
|
||||
description: 'Payment expires before completion',
|
||||
outcome: 'expired',
|
||||
delay: 5000
|
||||
},
|
||||
{
|
||||
id: 'pending-payment',
|
||||
name: 'Pending Payment',
|
||||
description: 'Payment remains in pending state',
|
||||
outcome: 'pending',
|
||||
delay: 1500
|
||||
}
|
||||
]
|
||||
|
||||
// Payment method configurations
|
||||
const PAYMENT_METHODS: Record<PaymentMethod, { name: string; icon: string }> = {
|
||||
ideal: { name: 'iDEAL', icon: '🏦' },
|
||||
creditcard: { name: 'Credit Card', icon: '💳' },
|
||||
paypal: { name: 'PayPal', icon: '🅿️' },
|
||||
applepay: { name: 'Apple Pay', icon: '🍎' },
|
||||
banktransfer: { name: 'Bank Transfer', icon: '🏛️' }
|
||||
}
|
||||
|
||||
// In-memory storage for test payment sessions
|
||||
const testPaymentSessions = new Map<string, TestPaymentSession>()
|
||||
|
||||
export const testProvider = (testConfig: TestProviderConfig) => {
|
||||
if (!testConfig.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
|
||||
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
|
||||
const uiRoute = testConfig.customUiRoute || '/test-payment'
|
||||
|
||||
// Test mode warnings will be logged in onInit when payload is available
|
||||
|
||||
return {
|
||||
key: 'test',
|
||||
onConfig: (config, pluginConfig) => {
|
||||
// Register test payment UI endpoint
|
||||
config.endpoints = [
|
||||
...(config.endpoints || []),
|
||||
{
|
||||
path: '/payload-billing/test/payment/:id',
|
||||
method: 'get',
|
||||
handler: (req) => {
|
||||
// Extract payment ID from URL path
|
||||
const urlParts = req.url?.split('/') || []
|
||||
const paymentId = urlParts[urlParts.length - 1]
|
||||
|
||||
if (!paymentId) {
|
||||
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Validate payment ID format
|
||||
const validation = validatePaymentId(paymentId)
|
||||
if (!validation.isValid) {
|
||||
return new Response(JSON.stringify({ error: validation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const session = testPaymentSessions.get(paymentId)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Generate test payment UI
|
||||
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig)
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payload-billing/test/config',
|
||||
method: 'get',
|
||||
handler: () => {
|
||||
const response: TestProviderConfigResponse = {
|
||||
enabled: testConfig.enabled,
|
||||
scenarios,
|
||||
methods: Object.entries(PAYMENT_METHODS).map(([id, method]) => ({
|
||||
id,
|
||||
name: method.name,
|
||||
icon: method.icon
|
||||
})),
|
||||
testModeIndicators: {
|
||||
showWarningBanners: testConfig.testModeIndicators?.showWarningBanners ?? true,
|
||||
showTestBadges: testConfig.testModeIndicators?.showTestBadges ?? true,
|
||||
consoleWarnings: testConfig.testModeIndicators?.consoleWarnings ?? true
|
||||
},
|
||||
defaultDelay: testConfig.defaultDelay || 1000,
|
||||
customUiRoute: uiRoute
|
||||
}
|
||||
return new Response(JSON.stringify(response), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payload-billing/test/process',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
try {
|
||||
const payload = req.payload
|
||||
const body = await req.json?.() || {}
|
||||
|
||||
// Validate request body
|
||||
const validation = validateProcessPaymentRequest(body)
|
||||
if (!validation.isValid) {
|
||||
return new Response(JSON.stringify({ error: validation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const { paymentId, scenarioId, method } = validation.data!
|
||||
|
||||
const session = testPaymentSessions.get(paymentId)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const scenario = scenarios.find(s => s.id === scenarioId)
|
||||
if (!scenario) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid scenario ID' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Update session with selected scenario and method
|
||||
session.scenario = scenario
|
||||
session.method = method
|
||||
session.status = 'pending'
|
||||
|
||||
// Process payment after delay
|
||||
setTimeout(() => {
|
||||
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Failed to process payment:', error)
|
||||
|
||||
// Ensure session status is updated consistently
|
||||
session.status = 'failed'
|
||||
|
||||
// Create error provider data
|
||||
const errorProviderData: ProviderData = {
|
||||
raw: {
|
||||
error: error instanceof Error ? error.message : 'Unknown processing error',
|
||||
processedAt: new Date().toISOString(),
|
||||
testMode: true
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'test'
|
||||
}
|
||||
|
||||
// Update payment record in database with enhanced error handling
|
||||
const dbResult = await updatePaymentInDatabase(
|
||||
payload,
|
||||
session.id,
|
||||
'failed',
|
||||
errorProviderData,
|
||||
pluginConfig
|
||||
)
|
||||
|
||||
if (!dbResult.success) {
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Database error during failure handling:', dbResult.error)
|
||||
// Even if database update fails, we maintain session consistency
|
||||
} else {
|
||||
logWebhookEvent('Test Provider', `Payment ${session.id} marked as failed after processing error`, undefined, req.payload)
|
||||
}
|
||||
})
|
||||
}, scenario.delay || testConfig.defaultDelay || 1000)
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
status: 'processing',
|
||||
scenario: scenario.name,
|
||||
delay: scenario.delay || testConfig.defaultDelay || 1000
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (error) {
|
||||
return handleWebhookError('Test Provider', error, 'Failed to process test payment', req.payload)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payload-billing/test/status/:id',
|
||||
method: 'get',
|
||||
handler: (req) => {
|
||||
// Extract payment ID from URL path
|
||||
const urlParts = req.url?.split('/') || []
|
||||
const paymentId = urlParts[urlParts.length - 1]
|
||||
|
||||
if (!paymentId) {
|
||||
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Validate payment ID format
|
||||
const validation = validatePaymentId(paymentId)
|
||||
if (!validation.isValid) {
|
||||
return new Response(JSON.stringify({ error: validation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const session = testPaymentSessions.get(paymentId)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
status: session.status,
|
||||
scenario: session.scenario?.name,
|
||||
method: session.method ? PAYMENT_METHODS[session.method]?.name : undefined
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
onInit: (payload: Payload) => {
|
||||
logWebhookEvent('Test Provider', 'Test payment provider initialized', undefined, payload)
|
||||
|
||||
// Log test mode warnings if enabled
|
||||
if (testConfig.testModeIndicators?.consoleWarnings !== false && !hasGivenTestModeWarning()) {
|
||||
setTestModeWarning()
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.warn('🧪 Payment system is running in test mode')
|
||||
}
|
||||
|
||||
// Clean up old sessions periodically (older than 1 hour)
|
||||
setInterval(() => {
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
||||
testPaymentSessions.forEach((session, id) => {
|
||||
if (session.createdAt < oneHourAgo) {
|
||||
testPaymentSessions.delete(id)
|
||||
}
|
||||
})
|
||||
}, 10 * 60 * 1000) // Clean every 10 minutes
|
||||
},
|
||||
initPayment: (payload, payment) => {
|
||||
// Validate required fields
|
||||
if (!payment.amount) {
|
||||
throw new Error('Amount is required')
|
||||
}
|
||||
if (!payment.currency) {
|
||||
throw new Error('Currency is required')
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
if (!isValidAmount(payment.amount)) {
|
||||
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
|
||||
}
|
||||
|
||||
// Validate currency code
|
||||
if (!isValidCurrencyCode(payment.currency)) {
|
||||
throw new Error('Invalid currency: must be a 3-letter ISO code')
|
||||
}
|
||||
|
||||
// Generate unique test payment ID
|
||||
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Create test payment session
|
||||
const session = {
|
||||
id: testPaymentId,
|
||||
payment: { ...payment },
|
||||
createdAt: new Date(),
|
||||
status: 'pending' as PaymentOutcome
|
||||
}
|
||||
|
||||
testPaymentSessions.set(testPaymentId, session)
|
||||
|
||||
// Set provider ID and data
|
||||
payment.providerId = testPaymentId
|
||||
const paymentUrl = `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`
|
||||
const providerData: ProviderData = {
|
||||
raw: {
|
||||
id: testPaymentId,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
description: payment.description,
|
||||
status: 'pending',
|
||||
testMode: true,
|
||||
paymentUrl,
|
||||
scenarios: scenarios.map(s => ({ id: s.id, name: s.name, description: s.description })),
|
||||
methods: Object.entries(PAYMENT_METHODS).map(([key, value]) => ({
|
||||
id: key,
|
||||
name: value.name,
|
||||
icon: value.icon
|
||||
}))
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'test'
|
||||
}
|
||||
payment.providerData = providerData
|
||||
payment.checkoutUrl = paymentUrl
|
||||
|
||||
return payment
|
||||
},
|
||||
} satisfies PaymentProvider
|
||||
}
|
||||
|
||||
// Helper function to process test payment based on scenario
|
||||
async function processTestPayment(
|
||||
payload: Payload,
|
||||
session: TestPaymentSession,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!session.scenario) {return}
|
||||
|
||||
// Map scenario outcome to payment status
|
||||
let finalStatus: Payment['status'] = 'pending'
|
||||
switch (session.scenario.outcome) {
|
||||
case 'paid':
|
||||
finalStatus = 'succeeded'
|
||||
break
|
||||
case 'failed':
|
||||
finalStatus = 'failed'
|
||||
break
|
||||
case 'cancelled':
|
||||
finalStatus = 'canceled'
|
||||
break
|
||||
case 'expired':
|
||||
finalStatus = 'canceled' // Treat expired as canceled
|
||||
break
|
||||
case 'pending':
|
||||
finalStatus = 'pending'
|
||||
break
|
||||
}
|
||||
|
||||
// Update session status
|
||||
session.status = session.scenario.outcome
|
||||
|
||||
// Update payment with final status and provider data
|
||||
const updatedProviderData: ProviderData = {
|
||||
raw: {
|
||||
...session.payment,
|
||||
id: session.id,
|
||||
status: session.scenario.outcome,
|
||||
scenario: session.scenario.name,
|
||||
method: session.method,
|
||||
processedAt: new Date().toISOString(),
|
||||
testMode: true
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'test'
|
||||
}
|
||||
|
||||
// Use the utility function for database operations
|
||||
const dbResult = await updatePaymentInDatabase(
|
||||
payload,
|
||||
session.id,
|
||||
finalStatus,
|
||||
updatedProviderData,
|
||||
pluginConfig
|
||||
)
|
||||
|
||||
if (dbResult.success) {
|
||||
logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`, undefined, payload)
|
||||
} else {
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Failed to update payment in database:', dbResult.error)
|
||||
// Update session status to indicate database error, but don't throw
|
||||
// This allows the UI to still show the intended test result
|
||||
session.status = 'failed'
|
||||
throw new Error(`Database update failed: ${dbResult.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown processing error'
|
||||
const logger = createContextLogger(payload, 'Test Provider')
|
||||
logger.error('Failed to process payment:', errorMessage)
|
||||
session.status = 'failed'
|
||||
throw error // Re-throw to be handled by the caller
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to generate test payment UI
|
||||
function generateTestPaymentUI(
|
||||
session: TestPaymentSession,
|
||||
scenarios: PaymentScenario[],
|
||||
uiRoute: string,
|
||||
baseUrl: string,
|
||||
testConfig: TestProviderConfig
|
||||
): string {
|
||||
const payment = session.payment
|
||||
const testModeIndicators = testConfig.testModeIndicators || {}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Payment - ${payment.description || 'Payment'}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
${testModeIndicators.showWarningBanners !== false ? `
|
||||
.test-banner {
|
||||
background: linear-gradient(90deg, #ff6b6b, #ffa726);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
` : ''}
|
||||
.header {
|
||||
background: #f8f9fa;
|
||||
padding: 30px 40px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.amount {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #27ae60;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.description {
|
||||
color: #6c757d;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.content { padding: 40px; }
|
||||
.section { margin-bottom: 30px; }
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.payment-methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.method {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
.method:hover {
|
||||
border-color: #007bff;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
.method.selected {
|
||||
border-color: #007bff;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.method-icon { font-size: 24px; margin-bottom: 8px; }
|
||||
.method-name { font-size: 12px; font-weight: 500; }
|
||||
.scenarios {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.scenario {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
.scenario:hover {
|
||||
border-color: #28a745;
|
||||
background: #f8fff9;
|
||||
}
|
||||
.scenario.selected {
|
||||
border-color: #28a745;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.scenario-name { font-weight: 600; margin-bottom: 4px; }
|
||||
.scenario-desc { font-size: 14px; opacity: 0.8; }
|
||||
.process-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #007bff, #0056b3);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.process-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,123,255,0.3);
|
||||
}
|
||||
.process-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status.processing { background: #fff3cd; color: #856404; }
|
||||
.status.success { background: #d4edda; color: #155724; }
|
||||
.status.error { background: #f8d7da; color: #721c24; }
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 10px;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
${testModeIndicators.showTestBadges !== false ? `
|
||||
.test-badge {
|
||||
display: inline-block;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-left: 8px;
|
||||
}
|
||||
` : ''}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
${testModeIndicators.showWarningBanners !== false ? `
|
||||
<div class="test-banner">
|
||||
🧪 TEST MODE - This is a simulated payment for development purposes
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
Test Payment Checkout
|
||||
${testModeIndicators.showTestBadges !== false ? '<span class="test-badge">Test</span>' : ''}
|
||||
</div>
|
||||
<div class="amount">${payment.currency?.toUpperCase()} ${payment.amount ? (payment.amount / 100).toFixed(2) : '0.00'}</div>
|
||||
${payment.description ? `<div class="description">${payment.description}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
💳 Select Payment Method
|
||||
</div>
|
||||
<div class="payment-methods">
|
||||
${Object.entries(PAYMENT_METHODS).map(([key, method]) => `
|
||||
<div class="method" data-method="${key}">
|
||||
<div class="method-icon">${method.icon}</div>
|
||||
<div class="method-name">${method.name}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
🎭 Select Test Scenario
|
||||
</div>
|
||||
<div class="scenarios">
|
||||
${scenarios.map(scenario => `
|
||||
<div class="scenario" data-scenario="${scenario.id}">
|
||||
<div class="scenario-name">${scenario.name}</div>
|
||||
<div class="scenario-desc">${scenario.description}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="process-btn" id="processBtn" disabled>
|
||||
Process Test Payment
|
||||
</button>
|
||||
|
||||
<div id="status" class="status" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedMethod = null;
|
||||
let selectedScenario = null;
|
||||
|
||||
// Payment method selection
|
||||
document.querySelectorAll('.method').forEach(method => {
|
||||
method.addEventListener('click', () => {
|
||||
document.querySelectorAll('.method').forEach(m => m.classList.remove('selected'));
|
||||
method.classList.add('selected');
|
||||
selectedMethod = method.dataset.method;
|
||||
updateProcessButton();
|
||||
});
|
||||
});
|
||||
|
||||
// Scenario selection
|
||||
document.querySelectorAll('.scenario').forEach(scenario => {
|
||||
scenario.addEventListener('click', () => {
|
||||
document.querySelectorAll('.scenario').forEach(s => s.classList.remove('selected'));
|
||||
scenario.classList.add('selected');
|
||||
selectedScenario = scenario.dataset.scenario;
|
||||
updateProcessButton();
|
||||
});
|
||||
});
|
||||
|
||||
function updateProcessButton() {
|
||||
const btn = document.getElementById('processBtn');
|
||||
btn.disabled = !selectedMethod || !selectedScenario;
|
||||
}
|
||||
|
||||
// Process payment
|
||||
document.getElementById('processBtn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('processBtn');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading"></span>Processing...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/payload-billing/test/process', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
paymentId: '${session.id}',
|
||||
scenarioId: selectedScenario,
|
||||
method: selectedMethod
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
status.className = 'status processing';
|
||||
status.style.display = 'block';
|
||||
status.innerHTML = \`<span class="loading"></span>Processing payment with \${result.scenario}...\`;
|
||||
|
||||
// Poll for status updates
|
||||
setTimeout(() => pollStatus(), result.delay || 1000);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to process payment');
|
||||
}
|
||||
} catch (error) {
|
||||
status.className = 'status error';
|
||||
status.style.display = 'block';
|
||||
status.textContent = 'Error: ' + error.message;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Process Test Payment';
|
||||
}
|
||||
});
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/payload-billing/test/status/${session.id}');
|
||||
const result = await response.json();
|
||||
|
||||
const status = document.getElementById('status');
|
||||
const btn = document.getElementById('processBtn');
|
||||
|
||||
if (result.status === 'paid') {
|
||||
status.className = 'status success';
|
||||
status.textContent = '✅ Payment successful!';
|
||||
setTimeout(() => {
|
||||
window.location.href = '${baseUrl}/success';
|
||||
}, 2000);
|
||||
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
|
||||
status.className = 'status error';
|
||||
status.textContent = \`❌ Payment \${result.status}\`;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Try Again';
|
||||
} else if (result.status === 'pending') {
|
||||
status.className = 'status processing';
|
||||
status.innerHTML = '<span class="loading"></span>Payment is still pending...';
|
||||
setTimeout(() => pollStatus(), 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Test Provider] Failed to poll status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
${testModeIndicators.consoleWarnings !== false ? `
|
||||
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface for development purposes');
|
||||
` : ''}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import type {
|
||||
CreatePaymentOptions,
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
Refund,
|
||||
TestProviderConfig,
|
||||
WebhookEvent
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
RefundStatus
|
||||
} from '../../types'
|
||||
import { BasePaymentProvider } from '../base/provider'
|
||||
|
||||
interface TestPaymentData {
|
||||
delayMs?: number
|
||||
failAfterMs?: number
|
||||
simulateFailure?: boolean
|
||||
}
|
||||
|
||||
export class TestPaymentProvider extends BasePaymentProvider {
|
||||
private config: TestProviderConfig
|
||||
private payments = new Map<string, Payment>()
|
||||
private refunds = new Map<string, Refund>()
|
||||
name = 'test'
|
||||
|
||||
constructor(config: TestProviderConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async cancelPayment(id: string): Promise<Payment> {
|
||||
const payment = this.payments.get(id)
|
||||
if (!payment) {
|
||||
throw new Error(`Payment ${id} not found`)
|
||||
}
|
||||
|
||||
if (payment.status === 'succeeded') {
|
||||
throw new Error('Cannot cancel a succeeded payment')
|
||||
}
|
||||
|
||||
const canceledPayment = {
|
||||
...payment,
|
||||
status: 'canceled' as PaymentStatus,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
this.payments.set(id, canceledPayment)
|
||||
|
||||
this.log('info', 'Payment canceled', { paymentId: id })
|
||||
|
||||
return canceledPayment
|
||||
}
|
||||
|
||||
clearStoredData(): void {
|
||||
this.payments.clear()
|
||||
this.refunds.clear()
|
||||
this.log('info', 'Test data cleared')
|
||||
}
|
||||
|
||||
async createPayment(options: CreatePaymentOptions): Promise<Payment> {
|
||||
const testData = options.metadata?.test as TestPaymentData || {}
|
||||
const delay = testData.delayMs ?? this.config.defaultDelay ?? 0
|
||||
|
||||
if (delay > 0) {
|
||||
await this.sleep(delay)
|
||||
}
|
||||
|
||||
const shouldFail = testData.simulateFailure ??
|
||||
(this.config.simulateFailures && Math.random() < (this.config.failureRate ?? 0.1))
|
||||
|
||||
const paymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const payment: Payment = {
|
||||
id: paymentId,
|
||||
amount: options.amount,
|
||||
createdAt: new Date().toISOString(),
|
||||
currency: options.currency,
|
||||
customer: options.customer,
|
||||
description: options.description,
|
||||
metadata: options.metadata,
|
||||
provider: this.name,
|
||||
providerData: {
|
||||
autoCompleted: this.config.autoComplete,
|
||||
delayApplied: delay,
|
||||
simulatedFailure: shouldFail,
|
||||
testMode: true
|
||||
},
|
||||
status: shouldFail ? 'failed' : (this.config.autoComplete ? 'succeeded' : 'pending'),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
this.payments.set(paymentId, payment)
|
||||
|
||||
this.log('info', 'Payment created', {
|
||||
amount: options.amount,
|
||||
currency: options.currency,
|
||||
paymentId,
|
||||
status: payment.status
|
||||
})
|
||||
|
||||
// Simulate async status updates if configured
|
||||
if (testData.failAfterMs && !shouldFail) {
|
||||
setTimeout(() => {
|
||||
const updatedPayment = { ...payment, status: 'failed' as PaymentStatus, updatedAt: new Date().toISOString() }
|
||||
this.payments.set(paymentId, updatedPayment)
|
||||
this.log('info', 'Payment failed after delay', { paymentId })
|
||||
}, testData.failAfterMs)
|
||||
}
|
||||
|
||||
return payment
|
||||
}
|
||||
|
||||
getAllPayments(): Payment[] {
|
||||
return Array.from(this.payments.values())
|
||||
}
|
||||
|
||||
getAllRefunds(): Refund[] {
|
||||
return Array.from(this.refunds.values())
|
||||
}
|
||||
|
||||
// Test-specific methods
|
||||
getStoredPayment(id: string): Payment | undefined {
|
||||
return this.payments.get(id)
|
||||
}
|
||||
|
||||
getStoredRefund(id: string): Refund | undefined {
|
||||
return this.refunds.get(id)
|
||||
}
|
||||
|
||||
async handleWebhook(request: Request, signature?: string): Promise<WebhookEvent> {
|
||||
if (!this.config.enabled) {
|
||||
throw new Error('Test provider is not enabled')
|
||||
}
|
||||
|
||||
// For test provider, we'll simulate webhook events
|
||||
const body = await request.text()
|
||||
let eventData: Record<string, unknown>
|
||||
|
||||
try {
|
||||
eventData = JSON.parse(body)
|
||||
} catch (error) {
|
||||
throw new Error('Invalid JSON in webhook body')
|
||||
}
|
||||
|
||||
const event: WebhookEvent = {
|
||||
id: `test_evt_${Date.now()}_${Math.random().toString(36).substring(7)}`,
|
||||
type: (eventData.type as string) || 'payment.status_changed',
|
||||
data: eventData,
|
||||
provider: this.name,
|
||||
verified: true // Test provider always considers webhooks verified
|
||||
}
|
||||
|
||||
this.log('info', 'Webhook received', {
|
||||
type: event.type,
|
||||
dataKeys: Object.keys(event.data),
|
||||
eventId: event.id
|
||||
})
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
async refundPayment(id: string, amount?: number): Promise<Refund> {
|
||||
const payment = this.payments.get(id)
|
||||
if (!payment) {
|
||||
throw new Error(`Payment ${id} not found`)
|
||||
}
|
||||
|
||||
if (payment.status !== 'succeeded') {
|
||||
throw new Error('Can only refund succeeded payments')
|
||||
}
|
||||
|
||||
const refundAmount = amount ?? payment.amount
|
||||
if (refundAmount > payment.amount) {
|
||||
throw new Error('Refund amount cannot exceed payment amount')
|
||||
}
|
||||
|
||||
const refundId = `test_ref_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const refund: Refund = {
|
||||
id: refundId,
|
||||
amount: refundAmount,
|
||||
createdAt: new Date().toISOString(),
|
||||
currency: payment.currency,
|
||||
paymentId: id,
|
||||
providerData: {
|
||||
autoCompleted: this.config.autoComplete,
|
||||
testMode: true
|
||||
},
|
||||
status: this.config.autoComplete ? 'succeeded' : 'pending'
|
||||
}
|
||||
|
||||
this.refunds.set(refundId, refund)
|
||||
|
||||
// Update payment status
|
||||
const newPaymentStatus: PaymentStatus = refundAmount === payment.amount ? 'refunded' : 'partially_refunded'
|
||||
const updatedPayment = {
|
||||
...payment,
|
||||
status: newPaymentStatus,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
this.payments.set(id, updatedPayment)
|
||||
|
||||
this.log('info', 'Refund created', {
|
||||
amount: refundAmount,
|
||||
paymentId: id,
|
||||
refundId,
|
||||
status: refund.status
|
||||
})
|
||||
|
||||
return refund
|
||||
}
|
||||
|
||||
async retrievePayment(id: string): Promise<Payment> {
|
||||
const payment = this.payments.get(id)
|
||||
if (!payment) {
|
||||
throw new Error(`Payment ${id} not found`)
|
||||
}
|
||||
return payment
|
||||
}
|
||||
}
|
||||
21
src/providers/types.ts
Normal file
21
src/providers/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { Config, Payload } from 'payload'
|
||||
import type { BillingPluginConfig } from '../plugin/config'
|
||||
|
||||
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
|
||||
|
||||
export type PaymentProvider = {
|
||||
key: string
|
||||
onConfig?: (config: Config, pluginConfig: BillingPluginConfig) => void
|
||||
onInit?: (payload: Payload) => Promise<void> | void
|
||||
initPayment: InitPayment
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe provider data wrapper
|
||||
*/
|
||||
export type ProviderData<T = unknown> = {
|
||||
raw: T
|
||||
timestamp: string
|
||||
provider: string
|
||||
}
|
||||
229
src/providers/utils.ts
Normal file
229
src/providers/utils.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { Payload } from 'payload'
|
||||
import type { Payment } from '../plugin/types/payments'
|
||||
import type { BillingPluginConfig } from '../plugin/config'
|
||||
import type { ProviderData } from './types'
|
||||
import { defaults } from '../plugin/config'
|
||||
import { extractSlug, toPayloadId } from '../plugin/utils'
|
||||
import { createContextLogger } from '../utils/logger'
|
||||
|
||||
/**
|
||||
* Common webhook response utilities
|
||||
* Note: Always return 200 for webhook acknowledgment to prevent information disclosure
|
||||
*/
|
||||
export const webhookResponses = {
|
||||
success: () => Response.json({ received: true }, { status: 200 }),
|
||||
error: (message: string, status = 400, payload?: Payload) => {
|
||||
// Log error internally but don't expose details
|
||||
if (payload) {
|
||||
const logger = createContextLogger(payload, 'Webhook')
|
||||
logger.error('Error:', message)
|
||||
} else {
|
||||
console.error('[Webhook] Error:', message)
|
||||
}
|
||||
return Response.json({ error: 'Invalid request' }, { status })
|
||||
},
|
||||
missingBody: () => Response.json({ received: true }, { status: 200 }),
|
||||
paymentNotFound: () => Response.json({ received: true }, { status: 200 }),
|
||||
invalidPayload: () => Response.json({ received: true }, { status: 200 }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a payment by provider ID
|
||||
*/
|
||||
export async function findPaymentByProviderId(
|
||||
payload: Payload,
|
||||
providerId: string,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<Payment | null> {
|
||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
|
||||
const payments = await payload.find({
|
||||
collection: paymentsCollection,
|
||||
where: {
|
||||
providerId: {
|
||||
equals: providerId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return payments.docs.length > 0 ? payments.docs[0] as Payment : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment status and provider data with optimistic locking
|
||||
*/
|
||||
export async function updatePaymentStatus(
|
||||
payload: Payload,
|
||||
paymentId: string | number,
|
||||
status: Payment['status'],
|
||||
providerData: ProviderData<any>,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<boolean> {
|
||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||
|
||||
try {
|
||||
// First, fetch the current payment to get the current version
|
||||
const currentPayment = await payload.findByID({
|
||||
collection: paymentsCollection,
|
||||
id: toPayloadId(paymentId),
|
||||
}) as Payment
|
||||
|
||||
if (!currentPayment) {
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.error(`Payment ${paymentId} not found`)
|
||||
return false
|
||||
}
|
||||
|
||||
const currentVersion = currentPayment.version || 1
|
||||
|
||||
// Attempt to update with optimistic locking
|
||||
// We'll use a transaction to ensure atomicity
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
|
||||
if (!transactionID) {
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.error('Failed to begin transaction')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-fetch within transaction to ensure consistency
|
||||
const paymentInTransaction = await payload.findByID({
|
||||
collection: paymentsCollection,
|
||||
id: toPayloadId(paymentId),
|
||||
req: { transactionID }
|
||||
}) as Payment
|
||||
|
||||
// Check if version still matches
|
||||
if ((paymentInTransaction.version || 1) !== currentVersion) {
|
||||
// Version conflict detected - payment was modified by another process
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
|
||||
await payload.db.rollbackTransaction(transactionID)
|
||||
return false
|
||||
}
|
||||
|
||||
// Update with new version
|
||||
await payload.update({
|
||||
collection: paymentsCollection,
|
||||
id: toPayloadId(paymentId),
|
||||
data: {
|
||||
status,
|
||||
providerData: {
|
||||
...providerData,
|
||||
webhookProcessedAt: new Date().toISOString()
|
||||
},
|
||||
version: currentVersion + 1
|
||||
},
|
||||
req: { transactionID }
|
||||
})
|
||||
|
||||
await payload.db.commitTransaction(transactionID)
|
||||
return true
|
||||
} catch (error) {
|
||||
await payload.db.rollbackTransaction(transactionID)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
const logger = createContextLogger(payload, 'Payment Update')
|
||||
logger.error(`Failed to update payment ${paymentId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice status when payment succeeds
|
||||
*/
|
||||
export async function updateInvoiceOnPaymentSuccess(
|
||||
payload: Payload,
|
||||
payment: Payment,
|
||||
pluginConfig: BillingPluginConfig
|
||||
): Promise<void> {
|
||||
if (!payment.invoice) {return}
|
||||
|
||||
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
|
||||
const invoiceId = typeof payment.invoice === 'object'
|
||||
? payment.invoice.id
|
||||
: payment.invoice
|
||||
|
||||
await payload.update({
|
||||
collection: invoicesCollection,
|
||||
id: toPayloadId(invoiceId),
|
||||
data: {
|
||||
status: 'paid',
|
||||
payment: toPayloadId(payment.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle webhook errors with consistent logging
|
||||
*/
|
||||
export function handleWebhookError(
|
||||
provider: string,
|
||||
error: unknown,
|
||||
context?: string,
|
||||
payload?: Payload
|
||||
): Response {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook`
|
||||
|
||||
// Log detailed error internally for debugging
|
||||
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 Response.json({
|
||||
received: false,
|
||||
error: 'Processing error'
|
||||
}, { status: 200 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Log webhook events
|
||||
*/
|
||||
export function logWebhookEvent(
|
||||
provider: string,
|
||||
event: string,
|
||||
details?: any,
|
||||
payload?: Payload
|
||||
): void {
|
||||
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) : '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL for production use
|
||||
*/
|
||||
export function validateProductionUrl(url: string | undefined, urlType: string): void {
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
if (!isProduction) {return}
|
||||
|
||||
if (!url) {
|
||||
throw new Error(`${urlType} URL is required for production`)
|
||||
}
|
||||
|
||||
if (url.includes('localhost') || url.includes('127.0.0.1')) {
|
||||
throw new Error(`${urlType} URL cannot use localhost in production`)
|
||||
}
|
||||
|
||||
if (!url.startsWith('https://')) {
|
||||
throw new Error(`${urlType} URL must use HTTPS in production`)
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
throw new Error(`${urlType} URL is not a valid URL`)
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import type { Config } from 'payload'
|
||||
|
||||
// Base payment provider interface
|
||||
export interface PaymentProvider {
|
||||
cancelPayment(id: string): Promise<Payment>
|
||||
createPayment(options: CreatePaymentOptions): Promise<Payment>
|
||||
handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
|
||||
name: string
|
||||
refundPayment(id: string, amount?: number): Promise<Refund>
|
||||
retrievePayment(id: string): Promise<Payment>
|
||||
}
|
||||
|
||||
// Payment types
|
||||
export interface CreatePaymentOptions {
|
||||
amount: number
|
||||
cancelUrl?: string
|
||||
currency: string
|
||||
customer?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
returnUrl?: string
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer?: string
|
||||
description?: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
provider: string
|
||||
providerData?: Record<string, unknown>
|
||||
status: PaymentStatus
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Refund {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
paymentId: string
|
||||
providerData?: Record<string, unknown>
|
||||
reason?: string
|
||||
status: RefundStatus
|
||||
}
|
||||
|
||||
export interface WebhookEvent {
|
||||
data: Record<string, unknown>
|
||||
id: string
|
||||
provider: string
|
||||
type: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
// Status enums
|
||||
export type PaymentStatus =
|
||||
| 'canceled'
|
||||
| 'failed'
|
||||
| 'partially_refunded'
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'refunded'
|
||||
| 'succeeded'
|
||||
|
||||
export type RefundStatus =
|
||||
| 'canceled'
|
||||
| 'failed'
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'succeeded'
|
||||
|
||||
// Provider configurations
|
||||
export interface StripeConfig {
|
||||
apiVersion?: string
|
||||
publishableKey: string
|
||||
secretKey: string
|
||||
webhookEndpointSecret: string
|
||||
}
|
||||
|
||||
export interface MollieConfig {
|
||||
apiKey: string
|
||||
testMode?: boolean
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
export interface TestProviderConfig {
|
||||
autoComplete?: boolean
|
||||
defaultDelay?: number
|
||||
enabled: boolean
|
||||
failureRate?: number
|
||||
simulateFailures?: boolean
|
||||
}
|
||||
|
||||
// Plugin configuration
|
||||
export interface BillingPluginConfig {
|
||||
admin?: {
|
||||
customComponents?: boolean
|
||||
dashboard?: boolean
|
||||
}
|
||||
collections?: {
|
||||
customers?: string
|
||||
invoices?: string
|
||||
payments?: string
|
||||
refunds?: string
|
||||
}
|
||||
disabled?: boolean
|
||||
providers?: {
|
||||
mollie?: MollieConfig
|
||||
stripe?: StripeConfig
|
||||
test?: TestProviderConfig
|
||||
}
|
||||
webhooks?: {
|
||||
basePath?: string
|
||||
cors?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Collection types
|
||||
export interface PaymentRecord {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer?: string
|
||||
description?: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
provider: string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId: string
|
||||
status: PaymentStatus
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CustomerRecord {
|
||||
address?: {
|
||||
city?: string
|
||||
country?: string
|
||||
line1?: string
|
||||
line2?: string
|
||||
postal_code?: string
|
||||
state?: string
|
||||
}
|
||||
createdAt: string
|
||||
email?: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
name?: string
|
||||
phone?: string
|
||||
providerIds?: Record<string, string>
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InvoiceRecord {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer?: string
|
||||
dueDate?: string
|
||||
id: string
|
||||
items: InvoiceItem[]
|
||||
metadata?: Record<string, unknown>
|
||||
number: string
|
||||
paidAt?: string
|
||||
status: InvoiceStatus
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
description: string
|
||||
quantity: number
|
||||
totalAmount: number
|
||||
unitAmount: number
|
||||
}
|
||||
|
||||
export type InvoiceStatus =
|
||||
| 'draft'
|
||||
| 'open'
|
||||
| 'paid'
|
||||
| 'uncollectible'
|
||||
| 'void'
|
||||
|
||||
// Plugin type
|
||||
export interface BillingPluginOptions extends BillingPluginConfig {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Error types
|
||||
export class BillingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public provider?: string,
|
||||
public details?: Record<string, unknown>
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'BillingError'
|
||||
}
|
||||
}
|
||||
|
||||
export class PaymentProviderError extends BillingError {
|
||||
constructor(
|
||||
message: string,
|
||||
provider: string,
|
||||
code?: string,
|
||||
details?: Record<string, unknown>
|
||||
) {
|
||||
super(message, code || 'PROVIDER_ERROR', provider, details)
|
||||
this.name = 'PaymentProviderError'
|
||||
}
|
||||
}
|
||||
|
||||
export class WebhookError extends BillingError {
|
||||
constructor(
|
||||
message: string,
|
||||
provider: string,
|
||||
code?: string,
|
||||
details?: Record<string, unknown>
|
||||
) {
|
||||
super(message, code || 'WEBHOOK_ERROR', provider, details)
|
||||
this.name = 'WebhookError'
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* PayloadCMS type definitions for hooks and handlers
|
||||
*/
|
||||
|
||||
import type { PayloadRequest, User } from 'payload'
|
||||
|
||||
// Collection hook types
|
||||
export interface CollectionBeforeChangeHook<T = Record<string, unknown>> {
|
||||
data: T
|
||||
operation: 'create' | 'delete' | 'update'
|
||||
originalDoc?: T
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
export interface CollectionAfterChangeHook<T = Record<string, unknown>> {
|
||||
doc: T
|
||||
operation: 'create' | 'delete' | 'update'
|
||||
previousDoc?: T
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
export interface CollectionBeforeValidateHook<T = Record<string, unknown>> {
|
||||
data?: T
|
||||
operation: 'create' | 'update'
|
||||
originalDoc?: T
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
// Access control types
|
||||
export interface AccessArgs<T = unknown> {
|
||||
data?: T
|
||||
id?: number | string
|
||||
req: {
|
||||
payload: unknown
|
||||
user: null | User
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice item type for hooks
|
||||
export interface InvoiceItemData {
|
||||
description: string
|
||||
quantity: number
|
||||
totalAmount?: number
|
||||
unitAmount: number
|
||||
}
|
||||
|
||||
// Invoice data type for hooks
|
||||
export interface InvoiceData {
|
||||
amount?: number
|
||||
currency?: string
|
||||
customer?: string
|
||||
dueDate?: string
|
||||
items?: InvoiceItemData[]
|
||||
metadata?: Record<string, unknown>
|
||||
notes?: string
|
||||
number?: string
|
||||
paidAt?: string
|
||||
payment?: string
|
||||
status?: string
|
||||
subtotal?: number
|
||||
taxAmount?: number
|
||||
}
|
||||
|
||||
// Payment data type for hooks
|
||||
export interface PaymentData {
|
||||
amount?: number
|
||||
currency?: string
|
||||
customer?: string
|
||||
description?: string
|
||||
invoice?: string
|
||||
metadata?: Record<string, unknown>
|
||||
provider?: string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
// Customer data type for hooks
|
||||
export interface CustomerData {
|
||||
address?: {
|
||||
city?: string
|
||||
country?: string
|
||||
line1?: string
|
||||
line2?: string
|
||||
postal_code?: string
|
||||
state?: string
|
||||
}
|
||||
email?: string
|
||||
metadata?: Record<string, unknown>
|
||||
name?: string
|
||||
phone?: string
|
||||
providerIds?: Record<string, string>
|
||||
}
|
||||
|
||||
// Refund data type for hooks
|
||||
export interface RefundData {
|
||||
amount?: number
|
||||
currency?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
payment?: { id: string } | string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId?: string
|
||||
reason?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
// Document types with required fields after creation
|
||||
export interface PaymentDocument extends PaymentData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
provider: string
|
||||
providerId: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CustomerDocument extends CustomerData {
|
||||
createdAt: string
|
||||
id: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InvoiceDocument extends InvoiceData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer: string
|
||||
id: string
|
||||
items: InvoiceItemData[]
|
||||
number: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface RefundDocument extends RefundData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
payment: { id: string } | string
|
||||
providerId: string
|
||||
refunds?: string[]
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Currency utility functions for payment processing
|
||||
*/
|
||||
|
||||
// Common currency configurations
|
||||
export const CURRENCY_CONFIG = {
|
||||
AUD: { name: 'Australian Dollar', decimals: 2, symbol: 'A$' },
|
||||
CAD: { name: 'Canadian Dollar', decimals: 2, symbol: 'C$' },
|
||||
CHF: { name: 'Swiss Franc', decimals: 2, symbol: 'Fr' },
|
||||
DKK: { name: 'Danish Krone', decimals: 2, symbol: 'kr' },
|
||||
EUR: { name: 'Euro', decimals: 2, symbol: '€' },
|
||||
GBP: { name: 'British Pound', decimals: 2, symbol: '£' },
|
||||
JPY: { name: 'Japanese Yen', decimals: 0, symbol: '¥' },
|
||||
NOK: { name: 'Norwegian Krone', decimals: 2, symbol: 'kr' },
|
||||
SEK: { name: 'Swedish Krona', decimals: 2, symbol: 'kr' },
|
||||
USD: { name: 'US Dollar', decimals: 2, symbol: '$' },
|
||||
} as const
|
||||
|
||||
export type SupportedCurrency = keyof typeof CURRENCY_CONFIG
|
||||
|
||||
/**
|
||||
* Validates if a currency code is supported
|
||||
*/
|
||||
export function isSupportedCurrency(currency: string): currency is SupportedCurrency {
|
||||
return currency in CURRENCY_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates currency format (3-letter ISO code)
|
||||
*/
|
||||
export function isValidCurrencyCode(currency: string): boolean {
|
||||
return /^[A-Z]{3}$/.test(currency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts amount from cents to major currency unit
|
||||
*/
|
||||
export function fromCents(amount: number, currency: string): number {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
if (!config) {
|
||||
// Default to 2 decimals for unknown currencies
|
||||
return amount / 100
|
||||
}
|
||||
|
||||
return config.decimals === 0 ? amount : amount / Math.pow(10, config.decimals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts amount from major currency unit to cents
|
||||
*/
|
||||
export function toCents(amount: number, currency: string): number {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
if (!config) {
|
||||
// Default to 2 decimals for unknown currencies
|
||||
return Math.round(amount * 100)
|
||||
}
|
||||
|
||||
return config.decimals === 0
|
||||
? Math.round(amount)
|
||||
: Math.round(amount * Math.pow(10, config.decimals))
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats amount for display with currency symbol
|
||||
*/
|
||||
export function formatAmount(amount: number, currency: string, options?: {
|
||||
showCode?: boolean
|
||||
showSymbol?: boolean
|
||||
}): string {
|
||||
const { showCode = false, showSymbol = true } = options || {}
|
||||
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const majorAmount = fromCents(amount, currency)
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
|
||||
let formatted = majorAmount.toFixed(config?.decimals ?? 2)
|
||||
|
||||
if (showSymbol && config?.symbol) {
|
||||
formatted = `${config.symbol}${formatted}`
|
||||
}
|
||||
|
||||
if (showCode) {
|
||||
formatted += ` ${currency}`
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets currency information
|
||||
*/
|
||||
export function getCurrencyInfo(currency: string) {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
return CURRENCY_CONFIG[currency as SupportedCurrency] || {
|
||||
name: currency,
|
||||
decimals: 2,
|
||||
symbol: currency
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates amount is positive and properly formatted
|
||||
*/
|
||||
export function validateAmount(amount: number): void {
|
||||
if (!Number.isFinite(amount)) {
|
||||
throw new Error('Amount must be a finite number')
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new Error('Amount must be positive')
|
||||
}
|
||||
|
||||
if (!Number.isInteger(amount)) {
|
||||
throw new Error('Amount must be an integer (in cents)')
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './currency'
|
||||
export * from './logger'
|
||||
export * from './validation'
|
||||
@@ -1,113 +1,48 @@
|
||||
/**
|
||||
* Structured logging utilities for the billing plugin
|
||||
*/
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
export type LogLevel = 'debug' | 'error' | 'info' | 'warn'
|
||||
|
||||
export interface LogContext {
|
||||
[key: string]: unknown
|
||||
amount?: number
|
||||
currency?: string
|
||||
customerId?: string
|
||||
invoiceId?: string
|
||||
paymentId?: string
|
||||
provider?: string
|
||||
refundId?: string
|
||||
webhookId?: string
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, context?: LogContext): void
|
||||
error(message: string, context?: LogContext): void
|
||||
info(message: string, context?: LogContext): void
|
||||
warn(message: string, context?: LogContext): void
|
||||
}
|
||||
let pluginLogger: any = null
|
||||
|
||||
/**
|
||||
* Creates a structured logger with consistent formatting
|
||||
* 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 createLogger(namespace: string = 'BILLING'): Logger {
|
||||
const log = (level: LogLevel, message: string, context: LogContext = {}) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const logData = {
|
||||
level: level.toUpperCase(),
|
||||
message,
|
||||
namespace,
|
||||
timestamp,
|
||||
...context,
|
||||
}
|
||||
export function getPluginLogger(payload: Payload) {
|
||||
if (!pluginLogger && payload.logger) {
|
||||
const logLevel = process.env.PAYLOAD_BILLING_LOG_LEVEL || 'info'
|
||||
|
||||
// Use console methods based on log level
|
||||
const consoleMethod = console[level] || console.log
|
||||
consoleMethod(`[${namespace}] ${message}`, logData)
|
||||
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, context?: LogContext) => log('debug', message, context),
|
||||
error: (message: string, context?: LogContext) => log('error', message, context),
|
||||
info: (message: string, context?: LogContext) => log('info', message, context),
|
||||
warn: (message: string, context?: LogContext) => log('warn', message, context),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default logger instance for the plugin
|
||||
*/
|
||||
export const logger = createLogger('BILLING')
|
||||
|
||||
/**
|
||||
* Creates a provider-specific logger
|
||||
*/
|
||||
export function createProviderLogger(providerName: string): Logger {
|
||||
return createLogger(`BILLING:${providerName.toUpperCase()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log payment operations with consistent structure
|
||||
*/
|
||||
export function logPaymentOperation(
|
||||
operation: string,
|
||||
paymentId: string,
|
||||
provider: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.info(`Payment ${operation}`, {
|
||||
operation,
|
||||
paymentId,
|
||||
provider,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log webhook events with consistent structure
|
||||
*/
|
||||
export function logWebhookEvent(
|
||||
provider: string,
|
||||
eventType: string,
|
||||
webhookId: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.info(`Webhook received`, {
|
||||
eventType,
|
||||
provider,
|
||||
webhookId,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log errors with consistent structure
|
||||
*/
|
||||
export function logError(
|
||||
error: Error,
|
||||
operation: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.error(`Operation failed: ${operation}`, {
|
||||
error: error.message,
|
||||
operation,
|
||||
stack: error.stack,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Validation utilities for billing data
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { isValidCurrencyCode } from './currency'
|
||||
|
||||
/**
|
||||
* Zod schema for payment creation options
|
||||
*/
|
||||
export const createPaymentSchema = z.object({
|
||||
amount: z.number().int().positive('Amount must be positive').min(1, 'Amount must be at least 1 cent'),
|
||||
cancelUrl: z.string().url('Invalid cancel URL').optional(),
|
||||
currency: z.string().length(3, 'Currency must be 3 characters').regex(/^[A-Z]{3}$/, 'Currency must be uppercase'),
|
||||
customer: z.string().optional(),
|
||||
description: z.string().max(500, 'Description too long').optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
returnUrl: z.string().url('Invalid return URL').optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for customer data
|
||||
*/
|
||||
export const customerSchema = z.object({
|
||||
name: z.string().max(100, 'Name too long').optional(),
|
||||
address: z.object({
|
||||
city: z.string().max(50).optional(),
|
||||
country: z.string().length(2, 'Country must be 2 characters').regex(/^[A-Z]{2}$/, 'Country must be uppercase').optional(),
|
||||
line1: z.string().max(100).optional(),
|
||||
line2: z.string().max(100).optional(),
|
||||
postal_code: z.string().max(20).optional(),
|
||||
state: z.string().max(50).optional(),
|
||||
}).optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
phone: z.string().max(20, 'Phone number too long').optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for invoice items
|
||||
*/
|
||||
export const invoiceItemSchema = z.object({
|
||||
description: z.string().min(1, 'Description is required').max(200, 'Description too long'),
|
||||
quantity: z.number().int().positive('Quantity must be positive'),
|
||||
unitAmount: z.number().int().min(0, 'Unit amount must be non-negative'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for invoice creation
|
||||
*/
|
||||
export const invoiceSchema = z.object({
|
||||
currency: z.string().length(3).regex(/^[A-Z]{3}$/),
|
||||
customer: z.string().min(1, 'Customer is required'),
|
||||
dueDate: z.string().datetime().optional(),
|
||||
items: z.array(invoiceItemSchema).min(1, 'At least one item is required'),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
taxAmount: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validates payment creation data
|
||||
*/
|
||||
export function validateCreatePayment(data: unknown) {
|
||||
const result = createPaymentSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid payment data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
|
||||
// Additional currency validation
|
||||
if (!isValidCurrencyCode(result.data.currency)) {
|
||||
throw new Error(`Unsupported currency: ${result.data.currency}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates customer data
|
||||
*/
|
||||
export function validateCustomer(data: unknown) {
|
||||
const result = customerSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid customer data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates invoice data
|
||||
*/
|
||||
export function validateInvoice(data: unknown) {
|
||||
const result = invoiceSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid invoice data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
|
||||
// Additional currency validation
|
||||
if (!isValidCurrencyCode(result.data.currency)) {
|
||||
throw new Error(`Unsupported currency: ${result.data.currency}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates webhook signature format
|
||||
*/
|
||||
export function validateWebhookSignature(signature: string, provider: string): void {
|
||||
if (!signature) {
|
||||
throw new Error(`Missing webhook signature for ${provider}`)
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'mollie':
|
||||
if (signature.length < 32) {
|
||||
throw new Error('Invalid Mollie webhook signature length')
|
||||
}
|
||||
break
|
||||
case 'stripe':
|
||||
if (!signature.startsWith('t=')) {
|
||||
throw new Error('Invalid Stripe webhook signature format')
|
||||
}
|
||||
break
|
||||
case 'test':
|
||||
// Test provider accepts any signature
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${provider}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates payment provider name
|
||||
*/
|
||||
export function validateProviderName(provider: string): void {
|
||||
const validProviders = ['stripe', 'mollie', 'test']
|
||||
if (!validProviders.includes(provider)) {
|
||||
throw new Error(`Invalid provider: ${provider}. Must be one of: ${validProviders.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates payment amount and currency combination
|
||||
*/
|
||||
export function validateAmountAndCurrency(amount: number, currency: string): void {
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
throw new Error('Amount must be a positive integer')
|
||||
}
|
||||
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error('Invalid currency code')
|
||||
}
|
||||
|
||||
// Validate minimum amounts for different currencies
|
||||
const minimums: Record<string, number> = {
|
||||
EUR: 50, // €0.50
|
||||
GBP: 30, // £0.30
|
||||
JPY: 50, // ¥50
|
||||
USD: 50, // $0.50
|
||||
}
|
||||
|
||||
const minimum = minimums[currency] || 50
|
||||
if (amount < minimum) {
|
||||
throw new Error(`Amount too small for ${currency}. Minimum: ${minimum} cents`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates refund amount against original payment
|
||||
*/
|
||||
export function validateRefundAmount(refundAmount: number, paymentAmount: number): void {
|
||||
if (!Number.isInteger(refundAmount) || refundAmount <= 0) {
|
||||
throw new Error('Refund amount must be a positive integer')
|
||||
}
|
||||
|
||||
if (refundAmount > paymentAmount) {
|
||||
throw new Error('Refund amount cannot exceed original payment amount')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user