37 Commits

Author SHA1 Message Date
2904d30a5c fix: improve error logging with detailed messages and stack traces
Previously, error objects were passed directly to the logger without
proper serialization, resulting in empty error messages like "Error:"
with no details. This made debugging production issues impossible.

Changes:
- Extract error message and stack trace before logging
- Format errors consistently across all providers
- Add stack trace logging for better debugging
- Update test provider error handling

This fixes the issue where webhook and payment update errors showed
no useful information in production logs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 14:39:52 +01:00
bf6f546371 fix: handle missing toPlainObject in Mollie client responses
Add fallback for Mollie API responses that may not have toPlainObject method
depending on client version or response type.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:16:45 +01:00
7e4ec86e00 feat: add per-payment redirectUrl field
- Add redirectUrl field to payments collection for custom redirect destinations
- Update Mollie provider to use payment.redirectUrl with config fallback
- Update Stripe provider to pass redirectUrl as return_url
- Update test provider to redirect to payment-specific URL on success
- Fix production URL detection to check NEXT_PUBLIC_SERVER_URL first
- Update README with redirectUrl documentation and examples

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 20:57:21 +01:00
79de7910d4 fix: fetch payment sessions from database for persistence
The test provider was using an in-memory Map to store payment sessions,
which caused "Payment session not found" errors in several scenarios:

1. Next.js hot reload clearing the memory
2. Different execution contexts (API routes vs Payload admin)
3. Server restarts losing all sessions

This fix updates all three test provider endpoints (UI, process, status)
to fetch payment data from the database when not found in memory:

- Tries in-memory session first (fast path)
- Falls back to database query by providerId
- Creates and caches session from database payment
- Handles both string and object collection configurations

This makes the built-in test UI work reliably out of the box, without
requiring users to implement custom session management.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 17:31:20 +01:00
bb5ba83bc3 fix: use built-in UI when customUiRoute is not specified
In v0.1.19, the fix for customUiRoute made it always use the default
route '/test-payment' even when customUiRoute was not specified. This
caused 404 errors because users were unaware of this default behavior.

The plugin actually provides a built-in test payment UI at
/api/payload-billing/test/payment/:id that works out of the box.

This fix ensures the correct behavior:
- When customUiRoute IS specified: Use the custom route
- When customUiRoute is NOT specified: Use the built-in UI route

This allows the testProvider to work out of the box without requiring
users to implement a custom test payment page, while still supporting
custom implementations when needed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 17:15:59 +01:00
79166f7edf fix: respect customUiRoute configuration in test provider
The test provider's customUiRoute parameter was being ignored when
generating checkout URLs. The checkout URL was always using the
hardcoded API endpoint instead of the configured custom UI route.

This fix ensures that when customUiRoute is configured, the generated
checkoutUrl will use the custom route (e.g., /test-payment/:id)
instead of the default API route.

Fixes issue where test provider checkout URLs returned 404 errors
because they pointed to /api/payload-billing/test/payment/:id instead
of the configured custom UI route.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:17:38 +01:00
6de405d07f 0.1.18 2025-11-21 15:39:40 +01:00
7c0b42e35d fix: resolve plugin initialization failure in Next.js API routes
Use Symbol.for() instead of Symbol() for plugin singleton storage to ensure
plugin state persists across different module loading contexts (admin panel,
API routes, server components).

This fixes the "Billing plugin not initialized" error that occurred when
calling payload.create() from Next.js API routes, server components, or
server actions.

Changes:
- Plugin singleton now uses Symbol.for('@xtr-dev/payload-billing')
- Provider singletons (stripe, mollie, test) use global symbols
- Enhanced error message with troubleshooting guidance

Fixes #1

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 15:35:12 +01:00
25b340d818 0.1.17 2025-11-18 23:12:32 +01:00
46bec6bd2e feat: add defaultPopulate configuration to payments collection
- Include defaultPopulate fields to simplify API responses
- Ensure key payment details (amount, status, provider, etc.) are preloaded
2025-11-18 23:12:30 +01:00
4fde492e0f feat: add checkoutUrl field to payment collection
- Add checkoutUrl field to Payment type and collection
- Mollie provider now sets checkoutUrl from _links.checkout.href
- Test provider sets checkoutUrl to interactive payment UI
- Stripe provider doesn't use checkoutUrl (uses client_secret instead)
- Update README with checkoutUrl examples and clarifications
- Make it easier to redirect users to payment pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:01:43 +01:00
a37757ffa1 fix: add better error handling for uninitialized billing plugin
- Fix TypeError when accessing providerConfig on undefined billing plugin
- Add proper type safety: useBillingPlugin now returns BillingPlugin | undefined
- Add clear error message when plugin hasn't been initialized
- Update README quickstart with concise provider response examples

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 22:31:51 +01:00
1867bb2f96 docs: restructure and update README for clarity
- Revise features list for precision and enhanced readability
- Expand and reorganize table of contents for better navigation
- Add detailed configurations and examples for Stripe, Mollie, and test providers
- Include new sections like customer management, payment flows, and webhook setup
- Refine descriptions of automatic behaviors and status synchronization
- Fix minor grammar inconsistencies and improve overall formatting

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 21:43:47 +01:00
89578aeba2 fix: replace path aliases with relative imports to fix published package
- Replace @/ path aliases with relative imports in invoices collection
- This fixes the 'Cannot find package @/plugin' error in published package
- Path aliases don't resolve correctly in the transpiled dist folder

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 16:35:21 +01:00
f50b0c663a 0.1.13 2025-11-08 16:22:50 +01:00
246c547a4c docs: clean up and clarify features list
- Remove misleading 'Complete payment tracking and history' claim
- Consolidate similar features
- Focus on core capabilities
- Make features list more concise and accurate

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 16:21:52 +01:00
27da194942 feat: add automatic payment/invoice status sync and invoice view page
Core Plugin Enhancements:
- Add afterChange hook to payments collection to auto-update linked invoice status to 'paid' when payment succeeds
- Add afterChange hook to invoices collection for bidirectional payment-invoice relationship management
- Add invoice status sync when manually marked as paid
- Update plugin config types to support collection extension options

Demo Application Features:
- Add professional invoice view page with print-friendly layout (/invoice/[id])
- Add custom message field to payment creation form
- Add invoice API endpoint to fetch complete invoice data with customer info
- Add payment API endpoint to retrieve payment with invoice relationship
- Update payment success page with "View Invoice" button
- Implement beforeChange hook to copy custom message from payment metadata to invoice
- Remove customer collection dependency - use direct customerInfo fields instead

Documentation:
- Update README with automatic status synchronization section
- Add collection extension examples to demo README
- Document new features: bidirectional relationships, status sync, invoice view

Technical Improvements:
- Fix total calculation in invoice API (use 'amount' field instead of 'total')
- Add proper TypeScript types with CollectionSlug casting
- Implement Next.js 15 async params pattern in API routes
- Add customer name/email/company fields to payment creation form

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 16:20:01 +01:00
f096b5f17f fix: add null check for session in test-payment page
Resolve TS18047 error by adding null guards before accessing session properties

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 14:39:46 +01:00
da24fa05d9 fix: resolve ESLint errors and warnings
- Add emoji accessibility labels (jsx-a11y/accessible-emoji)
- Remove unused imports and variables
- Fix async functions without await
- Add dev directory to ESLint ignore list
- Add eslint-disable comment for necessary console.error
- Clean up unused route file

All ESLint errors resolved (0 errors, 33 warnings remaining)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 14:37:02 +01:00
3508418698 feat: add comprehensive demo application with custom payment UI
- Custom test payment UI with modern Tailwind CSS design
- Payment method selection (iDEAL, Credit Card, PayPal, Apple Pay, Bank Transfer)
- Test scenario selection (6 scenarios: success, delayed, cancelled, declined, expired, pending)
- Real-time payment status polling
- Success and failure result pages with payment details
- Interactive demo homepage at root path
- Sample data seeding (customers, invoices)
- Customers collection with auto-sync to invoices
- Comprehensive documentation (README.md, DEMO_GUIDE.md)
- Proper cursor styles for all interactive elements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 14:03:28 +01:00
Bas
fa22900db5 Merge pull request #29 from xtr-dev/dev
docs: add comprehensive usage examples to README
2025-11-08 12:45:07 +01:00
857fc663b3 docs: add comprehensive usage examples to README
Add detailed usage examples section with practical code samples for:
- Creating payments with different providers
- Creating invoices with embedded and relationship-based customer data
- Creating refunds
- Querying payments and invoices
- Updating payment status
- Using the test provider for local development
- REST API examples with cURL commands

Also add table of contents for easier navigation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 12:44:31 +01:00
Bas
6a1e6e77ad Merge pull request #28 from xtr-dev/dev
chore: bump package version to 0.1.12
2025-09-30 21:05:20 +02:00
552ec700c2 chore: bump package version to 0.1.12 2025-09-30 21:04:56 +02:00
Bas
7d069e5cf1 Merge pull request #27 from xtr-dev/dev
chore: bump package version to 0.1.11
2025-09-30 21:00:10 +02:00
f7d6066d9a chore: bump package version to 0.1.11 2025-09-30 20:59:53 +02:00
Bas
c5442f9ce2 Merge pull request #26 from xtr-dev/dev
feat: implement structured logging system throughout the codebase
2025-09-20 21:24:59 +02:00
b27b5806b1 fix: resolve inconsistent console usage in logging implementation
- Move Stripe provider webhook warning to onInit where payload is available
- Fix client-side logging in test provider UI generation
- Replace server-side logger calls with browser-compatible console in generated HTML
- Maintain proper logging context separation between server and client code

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 21:21:35 +02:00
da96a0a838 chore: bump package version to 0.1.10 2025-09-20 21:18:40 +02:00
2374dbcec8 feat: implement structured logging system throughout the codebase
- Add logger utility adapted from payload-mailing pattern
- Use PAYLOAD_BILLING_LOG_LEVEL environment variable for configuration
- Replace console.* calls with contextual loggers across providers
- Update webhook utilities to support proper logging
- Export logging utilities for external use
- Maintain fallback console logging for compatibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 21:16:55 +02:00
Bas
2907d0fa9d Merge pull request #25 from xtr-dev/dev
Dev
2025-09-19 14:06:09 +02:00
05d612e606 feat: make InitPayment support both async and non-async functions
- Updated InitPayment type to return Promise<Partial<Payment>> | Partial<Payment>
- Modified initProviderPayment hook to handle both async and sync returns using Promise.resolve()
- Enables payment providers to use either async or synchronous initPayment implementations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 14:00:58 +02:00
dc9bc2db57 chore: bump package version to 0.1.9 and simplify test provider initialization logic 2025-09-19 13:55:48 +02:00
7590a5445c fix: enhance error handling and eliminate type safety issues in test provider
Database Error Handling:
- Add comprehensive error handling utility `updatePaymentInDatabase()`
- Ensure consistent session status updates across all error scenarios
- Prevent inconsistent states with proper error propagation and logging
- Add structured error responses with detailed error messages

Type Safety Improvements:
- Remove all unsafe `as any` casts except for necessary PayloadCMS collection constraints
- Add proper TypeScript interfaces and validation functions
- Fix type compatibility issues with TestModeIndicators using nullish coalescing
- Enhance error type checking with proper instanceof checks

Utility Functions:
- Abstract common collection name extraction pattern into `getPaymentsCollectionName()`
- Centralize database operation patterns for consistency
- Add structured error handling with success/error result patterns
- Improve logging with proper error message extraction

Code Quality:
- Replace ad-hoc error handling with consistent, reusable patterns
- Add proper error propagation throughout the payment processing flow
- Ensure all database errors are caught and handled gracefully
- Maintain session consistency even when database operations fail

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 13:44:13 +02:00
ed27501afc fix: add comprehensive input validation to test provider API endpoints
- Add proper request schema validation for ProcessPaymentRequest interface
- Validate paymentId format and ensure it follows test_pay_ pattern
- Validate scenarioId and method parameters with type safety
- Replace unsafe 'as any' casting with proper validation functions
- Add consistent JSON error responses with appropriate HTTP status codes
- Improve error messages for better debugging and API usability

Security improvements:
- Prevent injection attacks through input validation
- Ensure all API endpoints validate their inputs properly
- Add format validation for payment IDs to prevent invalid requests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 13:19:15 +02:00
Bas
56bd4fc7ce Merge pull request #23 from xtr-dev/claude/issue-22-20250919-1107
Claude/issue 22 20250919 1107
2025-09-19 13:11:49 +02:00
claude[bot]
eaf54ae893 feat: add test provider config endpoint
Add GET /api/payload-billing/test/config endpoint to retrieve test provider configuration including scenarios, payment methods, and test mode indicators.

This allows custom UIs to dynamically sync with plugin configuration instead of hardcoding values.

- Add TestProviderConfigResponse interface
- Export new type in provider index and main index
- Endpoint returns enabled status, scenarios, methods, test mode indicators, default delay, and custom UI route

Resolves #22

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
2025-09-19 11:10:18 +00:00
44 changed files with 5398 additions and 795 deletions

View File

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

166
CLAUDE.md
View File

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

1305
README.md

File diff suppressed because it is too large Load Diff

223
dev/DEMO_GUIDE.md Normal file
View File

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

496
dev/README.md Normal file
View 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

View 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 }
)
}
}

View 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 }
)
}
}

View 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
View File

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

View 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
View File

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

View File

@@ -1,13 +1,4 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { useBillingPlugin } from '../../../src/plugin'
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
View 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>
)
}

View File

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

View File

@@ -0,0 +1,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>
)
}

View 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>
)
}

View File

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

View File

@@ -36,6 +36,7 @@ const buildConfigWithSQLite = () => {
staticDir: path.resolve(dirname, 'media'),
},
},
// Note: No customers collection - the demo uses direct customerInfo fields on invoices
],
db: sqliteAdapter({
client: {
@@ -56,14 +57,68 @@ const buildConfigWithSQLite = () => {
showWarningBanners: true,
showTestBadges: true,
consoleWarnings: true
}
},
customUiRoute: '/test-payment',
})
],
collections: {
payments: 'payments',
invoices: 'invoices',
invoices: {
slug: 'invoices',
// Use extend to add custom fields and hooks to the invoice collection
extend: (config) => ({
...config,
fields: [
...(config.fields || []),
// Add a custom message field to invoices
{
name: 'customMessage',
type: 'textarea',
admin: {
description: 'Custom message from the payment (auto-populated)',
},
},
],
hooks: {
...config.hooks,
beforeChange: [
...(config.hooks?.beforeChange || []),
// Hook to copy the message from payment metadata to invoice
async ({ data, req, operation }) => {
// Only run on create operations
if (operation === 'create' && data.payment) {
try {
// Fetch the related payment
const payment = await req.payload.findByID({
collection: 'payments',
id: typeof data.payment === 'object' ? data.payment.id : data.payment,
})
// Copy the custom message from payment metadata to invoice
if (
payment?.metadata &&
typeof payment.metadata === 'object' &&
'customMessage' in payment.metadata &&
payment.metadata.customMessage
) {
data.customMessage = payment.metadata.customMessage as string
}
} catch (error) {
// Log error but don't fail the invoice creation
req.payload.logger.error('Failed to copy custom message to invoice:', error)
}
}
return data
},
],
},
}),
},
refunds: 'refunds',
},
// 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',

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

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

View File

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

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

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

View File

@@ -20,9 +20,13 @@ export const defaultESLintIgnores = [
'**/build/',
'**/node_modules/',
'**/temp/',
'**/dev/**', // Ignore dev demo directory
]
export default [
{
ignores: defaultESLintIgnores,
},
...payloadEsLintConfig,
{
rules: {

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-billing",
"version": "0.1.8",
"version": "0.1.24",
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT",
"type": "module",
@@ -81,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",
@@ -93,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",
@@ -101,6 +105,8 @@
"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"

942
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,20 @@ import type { Payment } from '../plugin/types/index'
import type { Payload } from 'payload'
import { useBillingPlugin } from '../plugin/index'
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => {
export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
const billing = useBillingPlugin(payload)
if (!billing) {
throw new Error(
'Billing plugin not initialized. Make sure the billingPlugin is properly configured in your Payload config and that Payload has finished initializing. ' +
'If you are calling this from a Next.js API route or Server Component, ensure you are using getPayload() with the same config instance used in your Payload configuration.'
)
}
if (!payment.provider || !billing.providerConfig[payment.provider]) {
throw new Error(`Provider ${payment.provider} not found.`)
}
return billing.providerConfig[payment.provider].initPayment(payload, payment)
// Handle both async and non-async initPayment functions
const result = billing.providerConfig[payment.provider].initPayment(payload, payment)
return await Promise.resolve(result)
}

View File

@@ -1,19 +1,26 @@
import {
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig, Field,
CollectionConfig,
CollectionSlug,
Field,
} from 'payload'
import type { BillingPluginConfig} from '../plugin/config';
import { defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import type { Invoice } from '../plugin/types/invoices'
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(pluginConfig: BillingPluginConfig): CollectionConfig {
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {}
let fields: Field[] = [
// Get slugs for relationships - these need to be determined before building fields
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
const fields: Field[] = [
{
name: 'number',
type: 'text',
@@ -32,7 +39,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
position: 'sidebar' as const,
description: 'Link to customer record (optional)',
},
relationTo: extractSlug(customerRelationSlug),
relationTo: customerRelationSlug as any,
required: false,
}] : []),
// Basic customer info fields (embedded)
@@ -276,7 +283,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
condition: (data) => data.status === 'paid',
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
relationTo: paymentsSlug,
},
{
name: 'notes',
@@ -293,11 +300,9 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
},
},
]
if (overrides?.fields) {
fields = overrides.fields({defaultFields: fields})
}
return {
slug: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
const baseConfig: CollectionConfig = {
slug: invoicesSlug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
@@ -312,9 +317,68 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
fields,
hooks: {
afterChange: [
({ doc, operation, req }) => {
async ({ doc, operation, req, previousDoc }) => {
const logger = createContextLogger(req.payload, 'Invoices Collection')
if (operation === 'create') {
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>[],
@@ -350,7 +414,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
data.billingAddress = extractedInfo.billingAddress
}
} catch (error) {
req.payload.logger.error(`Failed to extract customer info: ${error}`)
const logger = createContextLogger(req.payload, 'Invoices Collection')
logger.error(`Failed to extract customer info: ${String(error)}`)
throw new Error('Failed to extract customer information')
}
}
@@ -387,7 +452,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
] satisfies CollectionBeforeChangeHook<Invoice>[],
beforeValidate: [
({ data }) => {
if (!data) return
if (!data) {return}
// If using extractor, customer relationship is required
if (customerRelationSlug && customerInfoExtractor && !data.customer) {
@@ -426,4 +491,12 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
},
timestamps: true,
}
// Apply collection extension function if provided
const collectionConfig = pluginConfig.collections?.invoices
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
return collectionConfig.extend(baseConfig)
}
return baseConfig
}

View File

@@ -1,13 +1,18 @@
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
import type { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
import type { BillingPluginConfig} from '../plugin/config';
import { defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import type { Payment } from '../plugin/types/payments'
import { initProviderPayment } from './hooks'
import { createContextLogger } from '../utils/logger'
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
let fields: Field[] = [
// Get slugs for relationships - these need to be determined before building fields
const invoicesSlug = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const fields: Field[] = [
{
name: 'provider',
type: 'select',
@@ -73,13 +78,28 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
description: 'Payment description',
},
},
{
name: 'checkoutUrl',
type: 'text',
admin: {
description: 'Checkout URL where user can complete payment (if applicable)',
readOnly: true,
},
},
{
name: 'redirectUrl',
type: 'text',
admin: {
description: 'URL to redirect user after payment completion',
},
},
{
name: 'invoice',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
relationTo: invoicesSlug,
},
{
name: 'metadata',
@@ -104,7 +124,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
readOnly: true,
},
hasMany: true,
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
relationTo: refundsSlug,
},
{
name: 'version',
@@ -116,12 +136,10 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
index: true, // Index for optimistic locking performance
},
]
if (overrides?.fields) {
fields = overrides?.fields({defaultFields: fields})
}
return {
slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
access: overrides?.access || {
const baseConfig: CollectionConfig = {
slug: paymentsSlug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
@@ -131,10 +149,55 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
group: 'Billing',
useAsTitle: 'id',
...overrides?.admin
},
fields,
defaultPopulate: {
id: true,
provider: true,
status: true,
amount: true,
currency: true,
description: true,
checkoutUrl: true,
providerId: true,
metadata: true,
providerData: true,
},
hooks: {
afterChange: [
async ({ doc, operation, req, previousDoc }) => {
const logger = createContextLogger(req.payload, 'Payments Collection')
// Only process when payment status changes to a successful state
const successStatuses = ['paid', 'succeeded']
const paymentSucceeded = successStatuses.includes(doc.status)
const statusChanged = operation === 'update' && previousDoc && previousDoc.status !== doc.status
if (paymentSucceeded && (operation === 'create' || statusChanged)) {
// If payment has a linked invoice, update the invoice status to paid
if (doc.invoice) {
try {
const invoiceId = typeof doc.invoice === 'object' ? doc.invoice.id : doc.invoice
logger.info(`Payment ${doc.id} succeeded, updating invoice ${invoiceId} to paid`)
await req.payload.update({
collection: invoicesSlug,
id: invoiceId,
data: {
status: 'paid',
},
})
logger.info(`Invoice ${invoiceId} marked as paid`)
} catch (error) {
logger.error(`Failed to update invoice status: ${error}`)
// Don't throw - we don't want to fail the payment update
}
}
}
},
] satisfies CollectionAfterChangeHook<Payment>[],
beforeChange: [
async ({ data, operation, req, originalDoc }) => {
if (operation === 'create') {
@@ -167,4 +230,12 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
},
timestamps: true,
}
// Apply collection extension function if provided
const collectionConfig = pluginConfig.collections?.payments
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
return collectionConfig.extend(baseConfig)
}
return baseConfig
}

View File

@@ -1,12 +1,17 @@
import type { AccessArgs, CollectionConfig } from 'payload'
import { BillingPluginConfig, defaults } from '../plugin/config'
import type { BillingPluginConfig} from '../plugin/config';
import { defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import { Payment } from '../plugin/types/index'
import type { Payment } from '../plugin/types/index'
import { createContextLogger } from '../utils/logger'
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
// TODO: finish collection overrides
return {
slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
// Get slugs for relationships - these need to be determined before building fields
const paymentsSlug = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const refundsSlug = extractSlug(pluginConfig.collections?.refunds, defaults.refundsCollection)
const baseConfig: CollectionConfig = {
slug: refundsSlug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
@@ -35,7 +40,7 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
admin: {
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
relationTo: paymentsSlug,
required: true,
},
{
@@ -111,25 +116,27 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
afterChange: [
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: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
collection: paymentsSlug,
}) as Payment
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
await req.payload.update({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
collection: paymentsSlug,
data: {
refunds: [...refundIds, doc.id],
},
})
} catch (error) {
req.payload.logger.error(`Failed to update payment refunds: ${error}`)
const logger = createContextLogger(req.payload, 'Refunds Collection')
logger.error(`Failed to update payment refunds: ${error}`)
}
}
},
@@ -155,4 +162,12 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
},
timestamps: true,
}
// Apply collection extension function if provided
const collectionConfig = pluginConfig.collections?.refunds
if (typeof collectionConfig === 'object' && collectionConfig.extend) {
return collectionConfig.extend(baseConfig)
}
return baseConfig
}

View File

@@ -62,7 +62,7 @@ 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
if (!visible) {return null}
return (
<div style={{
@@ -75,13 +75,13 @@ export const TestModeWarningBanner: React.FC<{ visible?: boolean }> = ({ visible
marginBottom: '20px',
borderRadius: '4px'
}}>
🧪 TEST MODE - Payment system is running in test mode for development
<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
if (!visible) {return null}
return (
<span style={{
@@ -127,7 +127,7 @@ export const TestPaymentControls: React.FC<{
return (
<div style={{ border: '1px solid #e9ecef', borderRadius: '8px', padding: '16px', margin: '16px 0' }}>
<h4 style={{ marginBottom: '12px', color: '#2c3e50' }}>🧪 Test Payment Controls</h4>
<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>

View File

@@ -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({

View File

@@ -5,12 +5,16 @@ export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderCo
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
export type { PaymentProvider, ProviderData } from './providers/types.js'
// Export logging utilities
export { getPluginLogger, createContextLogger } from './utils/logger.js'
// Export all providers
export { testProvider } from './providers/test.js'
export type {
StripeProviderConfig,
MollieProviderConfig,
TestProviderConfig,
TestProviderConfigResponse,
PaymentOutcome,
PaymentMethod,
PaymentScenario

View File

@@ -1,6 +1,6 @@
import { CollectionConfig } from 'payload'
import { FieldsOverride } from './utils'
import { PaymentProvider } from './types/index'
import type { CollectionConfig } from 'payload'
import type { FieldsOverride } from './utils'
import type { PaymentProvider } from './types/index'
export const defaults = {
paymentsCollection: 'payments',
@@ -41,6 +41,14 @@ export interface CustomerInfoExtractor {
}
}
// Collection configuration type
export type CollectionExtension =
| string
| {
slug: string
extend?: (config: CollectionConfig) => CollectionConfig
}
// Plugin configuration
export interface BillingPluginConfig {
admin?: {
@@ -48,13 +56,13 @@ export interface BillingPluginConfig {
dashboard?: boolean
}
collections?: {
invoices?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
payments?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
refunds?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
invoices?: CollectionExtension
payments?: CollectionExtension
refunds?: CollectionExtension
}
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
customerRelationSlug?: string // Customer collection slug for relationship
disabled?: boolean
providers?: PaymentProvider[]
providers?: (PaymentProvider | undefined | null)[]
}

View File

@@ -4,7 +4,7 @@ import type { Config, Payload } from 'payload'
import { createSingleton } from './singleton'
import type { PaymentProvider } from '../providers/index'
const singleton = createSingleton(Symbol('billingPlugin'))
const singleton = createSingleton(Symbol.for('@xtr-dev/payload-billing'))
type BillingPlugin = {
config: BillingPluginConfig
@@ -13,7 +13,7 @@ type BillingPlugin = {
}
}
export const useBillingPlugin = (payload: Payload) => singleton.get(payload) as BillingPlugin
export const useBillingPlugin = (payload: Payload) => singleton.get(payload) as BillingPlugin | undefined
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) {
@@ -28,8 +28,8 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
];
(pluginConfig.providers || [])
.filter(provider => provider.onConfig)
.forEach(provider => provider.onConfig!(config, pluginConfig))
.filter(provider => provider?.onConfig)
.forEach(provider => provider?.onConfig!(config, pluginConfig))
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
@@ -38,17 +38,17 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
}
singleton.set(payload, {
config: pluginConfig,
providerConfig: (pluginConfig.providers || []).reduce(
providerConfig: (pluginConfig.providers || []).filter(Boolean).reduce(
(record, provider) => {
record[provider.key] = 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)))
.filter(provider => provider?.onInit)
.map(provider => provider?.onInit!(payload)))
}
return config

View File

@@ -1,5 +1,5 @@
import { Payment } from './payments'
import { Id } from './id'
import type { Payment } from './payments'
import type { Id } from './id'
export interface Invoice<TCustomer = unknown> {
id: Id;

View File

@@ -1,6 +1,6 @@
import { Refund } from './refunds'
import { Invoice } from './invoices'
import { Id } from './id'
import type { Refund } from './refunds'
import type { Invoice } from './invoices'
import type { Id } from './id'
export interface Payment {
id: Id;
@@ -22,6 +22,14 @@ export interface Payment {
* Payment description
*/
description?: string | null;
/**
* Checkout URL where user can complete payment (if applicable)
*/
checkoutUrl?: string | null;
/**
* URL to redirect user after payment completion
*/
redirectUrl?: string | null;
invoice?: (Id | null) | Invoice;
/**
* Additional metadata for the payment

View File

@@ -1,4 +1,4 @@
import { Payment } from './payments'
import type { Payment } from './payments'
export interface Refund {
id: number;

View File

@@ -1,10 +1,23 @@
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
import type { Id } from './types/index'
import type { CollectionExtension } from './config'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export const extractSlug =
(arg: string | Partial<CollectionConfig>) => (typeof arg === 'string' ? arg : arg.slug!) as CollectionSlug
/**
* Extract the slug from a collection configuration
* Returns the slug from the configuration or the default slug if not provided
*/
export const extractSlug = (arg: CollectionExtension | undefined, defaultSlug: string): CollectionSlug => {
if (!arg) {
return defaultSlug as CollectionSlug
}
if (typeof arg === 'string') {
return arg as CollectionSlug
}
// arg is an object with slug property
return arg.slug as CollectionSlug
}
/**
* Safely cast ID types for PayloadCMS operations

View File

@@ -7,4 +7,4 @@ export * from './currency'
// Re-export provider configurations and types
export type { StripeProviderConfig } from './stripe'
export type { MollieProviderConfig } from './mollie'
export type { TestProviderConfig, PaymentOutcome, PaymentMethod, PaymentScenario } from './test'
export type { TestProviderConfig, TestProviderConfigResponse, PaymentOutcome, PaymentMethod, PaymentScenario } from './test'

View File

@@ -12,8 +12,9 @@ import {
validateProductionUrl
} from './utils'
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('mollie')
const symbol = Symbol.for('@xtr-dev/payload-billing/mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
/**
@@ -84,11 +85,15 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
// Update the payment status and provider data
// Use toPlainObject if available, otherwise spread the object
const providerData = typeof molliePayment.toPlainObject === 'function'
? molliePayment.toPlainObject()
: { ...molliePayment }
const updateSuccess = await updatePaymentStatus(
payload,
payment.id,
status,
molliePayment.toPlainObject(),
providerData,
pluginConfig
)
@@ -96,12 +101,13 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
console.warn(`[Mollie Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
const logger = createContextLogger(payload, 'Mollie Webhook')
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Mollie', error)
return handleWebhookError('Mollie', error, undefined, req.payload)
}
}
}
@@ -132,11 +138,24 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
}
// Setup URLs with development defaults
// Only use localhost fallbacks in non-production environments
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`
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL
// Priority: payment.redirectUrl > config.redirectUrl > dev fallback
let redirectUrl = payment.redirectUrl || mollieConfig.redirectUrl
if (!redirectUrl && !isProduction) {
redirectUrl = 'https://localhost:3000/payment/success'
}
let webhookUrl = mollieConfig.webhookUrl
if (!webhookUrl) {
if (serverUrl) {
webhookUrl = `${serverUrl}/api/payload-billing/mollie/webhook`
} else if (!isProduction) {
webhookUrl = 'https://localhost:3000/api/payload-billing/mollie/webhook'
}
}
// Validate URLs for production
validateProductionUrl(redirectUrl, 'Redirect')
@@ -152,7 +171,11 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
webhookUrl,
});
payment.providerId = molliePayment.id
payment.providerData = molliePayment.toPlainObject()
// Use toPlainObject if available, otherwise spread the object (for compatibility with different Mollie client versions)
payment.providerData = typeof molliePayment.toPlainObject === 'function'
? molliePayment.toPlainObject()
: { ...molliePayment }
payment.checkoutUrl = molliePayment._links?.checkout?.href || null
return payment
},
} satisfies PaymentProvider

View File

@@ -12,8 +12,9 @@ import {
logWebhookEvent
} from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('stripe')
const symbol = Symbol.for('@xtr-dev/payload-billing/stripe')
export interface StripeProviderConfig {
secretKey: string
@@ -60,13 +61,13 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
return webhookResponses.missingBody()
}
} catch (error) {
return handleWebhookError('Stripe', error, 'Failed to read request body')
return handleWebhookError('Stripe', error, 'Failed to read request body', req.payload)
}
const signature = req.headers.get('stripe-signature')
if (!signature) {
return webhookResponses.error('Missing webhook signature', 400)
return webhookResponses.error('Missing webhook signature', 400, req.payload)
}
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
@@ -76,7 +77,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
try {
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
} catch (err) {
return handleWebhookError('Stripe', err, 'Signature verification failed')
return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload)
}
// Handle different event types
@@ -90,7 +91,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
if (!payment) {
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`)
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`, undefined, req.payload)
return webhookResponses.success() // Still return 200 to acknowledge receipt
}
@@ -129,7 +130,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
console.warn(`[Stripe Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
const logger = createContextLogger(payload, 'Stripe Webhook')
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
}
break
}
@@ -172,7 +174,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
)
if (!updateSuccess) {
console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`)
const logger = createContextLogger(payload, 'Stripe Webhook')
logger.warn(`Failed to update refund status for payment ${payment.id}`)
}
}
break
@@ -180,19 +183,16 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
default:
// Unhandled event type
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Stripe', error)
return handleWebhookError('Stripe', error, undefined, req.payload)
}
}
}
]
} else {
// Log that webhook endpoint is not registered
console.warn('[Stripe Provider] Webhook endpoint not registered - webhookSecret not configured')
}
},
onInit: async (payload: Payload) => {
@@ -201,6 +201,12 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
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
@@ -228,6 +234,9 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
const stripe = singleton.get(payload)
// Priority: payment.redirectUrl > config.returnUrl
const returnUrl = payment.redirectUrl || stripeConfig.returnUrl
// Create a payment intent
const paymentIntent = await stripe.paymentIntents.create({
amount: payment.amount, // Stripe handles currency conversion internally
@@ -244,6 +253,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
automatic_payment_methods: {
enabled: true,
},
...(returnUrl && { return_url: returnUrl }),
})
payment.providerId = paymentIntent.id

View File

@@ -1,9 +1,113 @@
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 type { CollectionSlug, Payload } from 'payload'
import { handleWebhookError, logWebhookEvent } from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const TestModeWarningSymbol = Symbol.for('@xtr-dev/payload-billing/test-mode-warning')
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'
@@ -31,6 +135,23 @@ export interface TestProviderConfig {
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
@@ -39,6 +160,7 @@ export interface TestPaymentSession {
method?: PaymentMethod
createdAt: Date
status: PaymentOutcome
redirectUrl?: string
}
// Use the proper BillingPluginConfig type
@@ -103,17 +225,14 @@ const testPaymentSessions = new Map<string, TestPaymentSession>()
export const testProvider = (testConfig: TestProviderConfig) => {
if (!testConfig.enabled) {
throw new Error('Test provider is disabled')
return
}
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
const baseUrl = testConfig.baseUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL || 'http://localhost:3000'
const uiRoute = testConfig.customUiRoute || '/test-payment'
// Log test mode warnings if enabled
if (testConfig.testModeIndicators?.consoleWarnings !== false) {
console.warn('🧪 [TEST PROVIDER] Payment system is running in test mode')
}
// Test mode warnings will be logged in onInit when payload is available
return {
key: 'test',
@@ -127,23 +246,106 @@ export const testProvider = (testConfig: TestProviderConfig) => {
handler: async (req) => {
// Extract payment ID from URL path
const urlParts = req.url?.split('/') || []
const paymentId = urlParts[urlParts.length - 1]
if (!paymentId) {
return new Response('Payment ID required', { status: 400 })
let paymentId = urlParts[urlParts.length - 1]
// Remove query parameters if present
if (paymentId?.includes('?')) {
paymentId = paymentId.split('?')[0]
}
const session = testPaymentSessions.get(paymentId)
if (!session) {
return new Response('Payment session not found', { status: 404 })
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' }
})
}
// Try to get session from memory first (for backward compatibility)
let session = testPaymentSessions.get(paymentId)
// If not in memory, fetch from database
if (!session && req.payload) {
try {
const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({
collection: paymentSlug as CollectionSlug,
where: {
providerId: {
equals: paymentId
}
},
limit: 1
})
if (result.docs && result.docs.length > 0) {
const payment = result.docs[0] as Payment
// Create session from database payment
session = {
id: paymentId,
payment: payment,
createdAt: new Date(payment.createdAt || Date.now()),
status: 'pending' as PaymentOutcome
}
// Store in memory for future requests
testPaymentSessions.set(paymentId, session)
}
} catch (error) {
console.error('Error fetching payment from database:', error)
}
}
if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
})
}
// Determine redirect URL: session.redirectUrl > payment.redirectUrl > default
const redirectUrl = session.redirectUrl || (session.payment as Payment)?.redirectUrl || `${baseUrl}/payment/success`
// Generate test payment UI
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig)
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig, redirectUrl)
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',
@@ -151,9 +353,53 @@ export const testProvider = (testConfig: TestProviderConfig) => {
try {
const payload = req.payload
const body = await req.json?.() || {}
const { paymentId, scenarioId, method } = body as any
const session = testPaymentSessions.get(paymentId)
// 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!
// Try to get session from memory first
let session = testPaymentSessions.get(paymentId)
// If not in memory, fetch from database
if (!session && req.payload) {
try {
const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({
collection: paymentSlug as CollectionSlug,
where: {
providerId: {
equals: paymentId
}
},
limit: 1
})
if (result.docs && result.docs.length > 0) {
const payment = result.docs[0] as Payment
// Create session from database payment
session = {
id: paymentId,
payment: payment,
createdAt: new Date(payment.createdAt || Date.now()),
status: 'pending' as PaymentOutcome
}
// Store in memory for future requests
testPaymentSessions.set(paymentId, session)
}
} catch (error) {
console.error('Error fetching payment from database:', error)
}
}
if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
@@ -163,7 +409,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
const scenario = scenarios.find(s => s.id === scenarioId)
if (!scenario) {
return new Response(JSON.stringify({ error: 'Invalid scenario' }), {
return new Response(JSON.stringify({ error: 'Invalid scenario ID' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
@@ -177,36 +423,39 @@ export const testProvider = (testConfig: TestProviderConfig) => {
// Process payment after delay
setTimeout(() => {
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
console.error('[Test Provider] Failed to process payment:', error)
const logger = createContextLogger(payload, 'Test Provider')
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`Failed to process payment: ${errorMessage}`)
// Ensure session status is updated consistently
session.status = 'failed'
// Also update the payment record in database
try {
const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string'
? pluginConfig.collections.payments
: 'payments') as any
const payments = await payload.find({
collection: paymentsCollection,
where: { providerId: { equals: session.id } },
limit: 1
})
if (payments.docs.length > 0) {
await payload.update({
collection: paymentsCollection,
id: payments.docs[0].id,
data: {
status: 'failed',
providerData: {
raw: { error: error.message, processedAt: new Date().toISOString() },
// 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'
}
}
})
}
} catch (dbError) {
console.error('[Test Provider] Failed to update payment in database:', dbError)
// 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)
@@ -220,7 +469,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return handleWebhookError('Test Provider', error, 'Failed to process test payment')
return handleWebhookError('Test Provider', error, 'Failed to process test payment', req.payload)
}
}
},
@@ -230,7 +479,13 @@ export const testProvider = (testConfig: TestProviderConfig) => {
handler: async (req) => {
// Extract payment ID from URL path
const urlParts = req.url?.split('/') || []
const paymentId = urlParts[urlParts.length - 1]
let paymentId = urlParts[urlParts.length - 1]
// Remove query parameters if present
if (paymentId?.includes('?')) {
paymentId = paymentId.split('?')[0]
}
if (!paymentId) {
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
status: 400,
@@ -238,7 +493,50 @@ export const testProvider = (testConfig: TestProviderConfig) => {
})
}
const session = testPaymentSessions.get(paymentId)
// 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' }
})
}
// Try to get session from memory first
let session = testPaymentSessions.get(paymentId)
// If not in memory, fetch from database
if (!session && req.payload) {
try {
const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({
collection: paymentSlug,
where: {
providerId: {
equals: paymentId
}
},
limit: 1
})
if (result.docs && result.docs.length > 0) {
const payment = result.docs[0] as Payment
// Create session from database payment
session = {
id: paymentId,
payment: payment,
createdAt: new Date(payment.createdAt || Date.now()),
status: 'pending' as PaymentOutcome
}
// Store in memory for future requests
testPaymentSessions.set(paymentId, session)
}
} catch (error) {
console.error('Error fetching payment from database:', error)
}
}
if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
@@ -257,8 +555,15 @@ export const testProvider = (testConfig: TestProviderConfig) => {
}
]
},
onInit: async (payload: Payload) => {
logWebhookEvent('Test Provider', 'Test payment provider initialized')
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(() => {
@@ -270,7 +575,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
})
}, 10 * 60 * 1000) // Clean every 10 minutes
},
initPayment: async (payload, payment) => {
initPayment: (payload, payment) => {
// Validate required fields
if (!payment.amount) {
throw new Error('Amount is required')
@@ -292,18 +597,23 @@ export const testProvider = (testConfig: TestProviderConfig) => {
// Generate unique test payment ID
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// Create test payment session
const session = {
// Create test payment session with redirect URL
const session: TestPaymentSession = {
id: testPaymentId,
payment: { ...payment },
createdAt: new Date(),
status: 'pending' as PaymentOutcome
status: 'pending' as PaymentOutcome,
redirectUrl: payment.redirectUrl || undefined
}
testPaymentSessions.set(testPaymentId, session)
// Set provider ID and data
payment.providerId = testPaymentId
// Use custom UI route if specified, otherwise use built-in UI endpoint
const paymentUrl = testConfig.customUiRoute
? `${baseUrl}${testConfig.customUiRoute}/${testPaymentId}`
: `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`
const providerData: ProviderData = {
raw: {
id: testPaymentId,
@@ -312,7 +622,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
description: payment.description,
status: 'pending',
testMode: true,
paymentUrl: `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`,
paymentUrl,
scenarios: scenarios.map(s => ({ id: s.id, name: s.name, description: s.description })),
methods: Object.entries(PAYMENT_METHODS).map(([key, value]) => ({
id: key,
@@ -324,6 +634,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
provider: 'test'
}
payment.providerData = providerData
payment.checkoutUrl = paymentUrl
return payment
},
@@ -337,7 +648,7 @@ async function processTestPayment(
pluginConfig: BillingPluginConfig
): Promise<void> {
try {
if (!session.scenario) return
if (!session.scenario) {return}
// Map scenario outcome to payment status
let finalStatus: Payment['status'] = 'pending'
@@ -362,23 +673,6 @@ async function processTestPayment(
// Update session status
session.status = session.scenario.outcome
// Find and update the payment in the database
const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string'
? pluginConfig.collections.payments
: 'payments') as any
const payments = await payload.find({
collection: paymentsCollection,
where: {
providerId: {
equals: session.id
}
},
limit: 1
})
if (payments.docs.length > 0) {
const payment = payments.docs[0]
// Update payment with final status and provider data
const updatedProviderData: ProviderData = {
raw: {
@@ -394,20 +688,31 @@ async function processTestPayment(
provider: 'test'
}
await payload.update({
collection: paymentsCollection,
id: payment.id,
data: {
status: finalStatus,
providerData: updatedProviderData
}
})
// Use the utility function for database operations
const dbResult = await updatePaymentInDatabase(
payload,
session.id,
finalStatus,
updatedProviderData,
pluginConfig
)
logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`)
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) {
console.error('[Test Provider] Failed to process payment:', 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
}
}
@@ -417,7 +722,8 @@ function generateTestPaymentUI(
scenarios: PaymentScenario[],
uiRoute: string,
baseUrl: string,
testConfig: TestProviderConfig
testConfig: TestProviderConfig,
redirectUrl: string
): string {
const payment = session.payment
const testModeIndicators = testConfig.testModeIndicators || {}
@@ -731,9 +1037,9 @@ function generateTestPaymentUI(
if (result.status === 'paid') {
status.className = 'status success';
status.textContent = '✅ Payment successful!';
status.textContent = '✅ Payment successful! Redirecting...';
setTimeout(() => {
window.location.href = '${baseUrl}/success';
window.location.href = '${redirectUrl}';
}, 2000);
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
status.className = 'status error';
@@ -746,12 +1052,12 @@ function generateTestPaymentUI(
setTimeout(() => pollStatus(), 2000);
}
} catch (error) {
console.error('Failed to poll status:', error);
console.error('[Test Provider] Failed to poll status:', error);
}
}
${testModeIndicators.consoleWarnings !== false ? `
console.warn('🧪 TEST MODE: This is a simulated payment interface for development purposes');
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface for development purposes');
` : ''}
</script>
</body>

View File

@@ -2,7 +2,7 @@ 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>>
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
export type PaymentProvider = {
key: string

View File

@@ -4,6 +4,7 @@ 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
@@ -11,9 +12,14 @@ import { extractSlug, toPayloadId } from '../plugin/utils'
*/
export const webhookResponses = {
success: () => Response.json({ received: true }, { status: 200 }),
error: (message: string, status = 400) => {
error: (message: string, status = 400, payload?: Payload) => {
// Log error internally but don't expose details
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 }),
@@ -29,7 +35,7 @@ export async function findPaymentByProviderId(
providerId: string,
pluginConfig: BillingPluginConfig
): Promise<Payment | null> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const payments = await payload.find({
collection: paymentsCollection,
@@ -53,7 +59,7 @@ export async function updatePaymentStatus(
providerData: ProviderData<any>,
pluginConfig: BillingPluginConfig
): Promise<boolean> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
try {
// First, fetch the current payment to get the current version
@@ -63,7 +69,8 @@ export async function updatePaymentStatus(
}) as Payment
if (!currentPayment) {
console.error(`[Payment Update] Payment ${paymentId} not found`)
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Payment ${paymentId} not found`)
return false
}
@@ -74,7 +81,8 @@ export async function updatePaymentStatus(
const transactionID = await payload.db.beginTransaction()
if (!transactionID) {
console.error(`[Payment Update] Failed to begin transaction`)
const logger = createContextLogger(payload, 'Payment Update')
logger.error('Failed to begin transaction')
return false
}
@@ -83,13 +91,14 @@ export async function updatePaymentStatus(
const paymentInTransaction = await payload.findByID({
collection: paymentsCollection,
id: toPayloadId(paymentId),
req: { transactionID: transactionID }
req: { transactionID }
}) as Payment
// Check if version still matches
if ((paymentInTransaction.version || 1) !== currentVersion) {
// Version conflict detected - payment was modified by another process
console.warn(`[Payment Update] Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
const logger = createContextLogger(payload, 'Payment Update')
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
await payload.db.rollbackTransaction(transactionID)
return false
}
@@ -106,7 +115,7 @@ export async function updatePaymentStatus(
},
version: currentVersion + 1
},
req: { transactionID: transactionID }
req: { transactionID }
})
await payload.db.commitTransaction(transactionID)
@@ -116,7 +125,13 @@ export async function updatePaymentStatus(
throw error
}
} catch (error) {
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
const logger = createContextLogger(payload, 'Payment Update')
const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : undefined
logger.error(`Failed to update payment ${paymentId}: ${errorMessage}`)
if (errorStack) {
logger.error(`Stack trace: ${errorStack}`)
}
return false
}
}
@@ -129,9 +144,9 @@ export async function updateInvoiceOnPaymentSuccess(
payment: Payment,
pluginConfig: BillingPluginConfig
): Promise<void> {
if (!payment.invoice) return
if (!payment.invoice) {return}
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices, defaults.invoicesCollection)
const invoiceId = typeof payment.invoice === 'object'
? payment.invoice.id
: payment.invoice
@@ -152,13 +167,26 @@ export async function updateInvoiceOnPaymentSuccess(
export function handleWebhookError(
provider: string,
error: unknown,
context?: string
context?: string,
payload?: Payload
): Response {
const message = error instanceof Error ? error.message : 'Unknown error'
const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]`
const message = error instanceof Error ? error.message : String(error)
const stack = error instanceof Error ? error.stack : undefined
const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook`
// Log detailed error internally for debugging
console.error(`${fullContext} Error:`, error)
if (payload) {
const logger = createContextLogger(payload, fullContext)
logger.error(`Error: ${message}`)
if (stack) {
logger.error(`Stack trace: ${stack}`)
}
} else {
console.error(`[${fullContext}] Error: ${message}`)
if (stack) {
console.error(`[${fullContext}] Stack trace:`, stack)
}
}
// Return generic response to avoid information disclosure
return Response.json({
@@ -173,9 +201,15 @@ export function handleWebhookError(
export function logWebhookEvent(
provider: string,
event: string,
details?: any
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) : '')
}
}
/**
@@ -184,7 +218,7 @@ export function logWebhookEvent(
export function validateProductionUrl(url: string | undefined, urlType: string): void {
const isProduction = process.env.NODE_ENV === 'production'
if (!isProduction) return
if (!isProduction) {return}
if (!url) {
throw new Error(`${urlType} URL is required for production`)

48
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { Payload } from 'payload'
let pluginLogger: any = null
/**
* Get or create the plugin logger instance
* Uses PAYLOAD_BILLING_LOG_LEVEL environment variable to configure log level
* Defaults to 'info' if not set
*/
export function getPluginLogger(payload: Payload) {
if (!pluginLogger && payload.logger) {
const logLevel = process.env.PAYLOAD_BILLING_LOG_LEVEL || 'info'
pluginLogger = payload.logger.child({
level: logLevel,
plugin: '@xtr-dev/payload-billing'
})
// Log the configured log level on first initialization
pluginLogger.info(`Logger initialized with level: ${logLevel}`)
}
// Fallback to console if logger not available (shouldn't happen in normal operation)
if (!pluginLogger) {
return {
debug: (...args: any[]) => console.log('[BILLING DEBUG]', ...args),
info: (...args: any[]) => console.log('[BILLING INFO]', ...args),
warn: (...args: any[]) => console.warn('[BILLING WARN]', ...args),
error: (...args: any[]) => console.error('[BILLING ERROR]', ...args),
}
}
return pluginLogger
}
/**
* Create a context-specific logger for a particular operation
*/
export function createContextLogger(payload: Payload, context: string) {
const logger = getPluginLogger(payload)
return {
debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args),
info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args),
warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args),
error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args),
}
}