50 Commits

Author SHA1 Message Date
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
Bas
f89ffb2c7e Merge pull request #21 from xtr-dev/dev
Dev
2025-09-19 12:15:21 +02:00
d5a47a05b1 fix: resolve module import issues for Next.js/Turbopack compatibility
- Remove .js extensions from all TypeScript imports throughout codebase
- Update dev config to use testProvider instead of mollieProvider for testing
- Fix module resolution issues preventing development server startup
- Enable proper testing of billing plugin functionality with test provider

This resolves the "Module not found: Can't resolve" errors that were
preventing the development server from starting with Next.js/Turbopack.
All TypeScript imports now use extension-less imports as required.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 12:12:39 +02:00
64c58552cb chore: bump package version to 0.1.8 2025-09-19 11:22:41 +02:00
be57924525 fix: resolve critical template literal and error handling issues
Critical fixes:
- Fix template literal bug in paymentId that prevented payment processing
- Enhance error handling to update both session and database on failures
- Consolidate duplicate type definitions to single source of truth

Technical details:
- Template literal interpolation now properly provides actual session IDs
- Promise rejections in setTimeout now update payment records in database
- Removed duplicate AdvancedTestProviderConfig, now re-exports TestProviderConfig
- Enhanced error handling with comprehensive database state consistency

Prevents payment processing failures and data inconsistency issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 11:19:05 +02:00
2d10bd82e7 fix: improve code quality with type safety and error handling
- Add proper TypeScript interfaces (TestPaymentSession, BillingPluginConfig)
- Fix error handling for async operations in setTimeout with proper .catch()
- Fix template literal security issues in string interpolation
- Add null safety checks for payment.amount to prevent runtime errors
- Improve collection type safety with proper PayloadCMS slug handling
- Remove unused webhookResponses import to clean up dependencies

Resolves type safety, error handling, and security issues identified in code review.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 11:09:53 +02:00
8e6385caa3 feat: implement advanced test provider with interactive UI and multiple scenarios
- Add comprehensive test provider with configurable payment outcomes (paid, failed, cancelled, expired, pending)
- Support multiple payment methods (iDEAL, Credit Card, PayPal, Apple Pay, Bank Transfer)
- Interactive test payment UI with responsive design and real-time processing simulation
- Test mode indicators including warning banners, badges, and console warnings
- React components for admin UI integration (TestModeWarningBanner, TestModeBadge, TestPaymentControls)
- API endpoints for test payment processing and status polling
- Configurable scenarios with custom delays and outcomes
- Production safety mechanisms and clear test mode indicators
- Complete documentation and usage examples

Implements GitHub issue #20 for advanced test provider functionality.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 10:37:56 +02:00
83251bb404 docs: add npm version badge to README
- Add npm version badge showing current package version
- Badge links to npm package page
- Positioned prominently after the title
- Uses badge.fury.io for reliable version display

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 10:37:29 +02:00
Bas
7b8c89a0a2 Merge pull request #19 from xtr-dev/dev
chore: remove deprecated Claude workflows
2025-09-19 09:56:15 +02:00
d651e8199c chore: remove all Claude configuration and documentation files
- Delete `.github/claude-config.json` and `.github/CLAUDE_PR_ASSISTANT.md`
- Clean up repository by removing unused Claude-related files
- Bump package version to `0.1.7` for metadata update
2025-09-19 09:50:46 +02:00
f77719716f chore: remove deprecated Claude workflows
- Delete `claude-implement-issue.yml` and `claude-pr-assistant.yml` workflows
- Streamline repository automation by removing redundant workflows
- Prepare for future updates with simplified automation setup
2025-09-19 09:28:04 +02:00
Bas
c6e51892e6 Merge pull request #18 from xtr-dev/claude/issue-17-20250918-1938
docs: update README to reflect current codebase features
2025-09-18 21:53:10 +02:00
claude[bot]
38c8c3677d fix: remove non-existent defaultCustomerInfoExtractor from README
Replace defaultCustomerInfoExtractor import and usage with a proper
working example that shows how to define a CustomerInfoExtractor function.

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
2025-09-18 19:51:01 +00:00
claude[bot]
e74a2410e6 docs: update README to reflect current codebase features
- Update version info from v0.0.x to v0.1.x
- Add comprehensive customer management documentation
- Include customer info extractor examples and configuration
- Document flexible customer data handling modes
- Add missing TypeScript exports to imports section
- Update features list with callback-based syncing and embedded customer info

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
2025-09-18 19:40:12 +00:00
Bas
27b86132e9 Merge pull request #16 from xtr-dev/dev
Dev
2025-09-18 21:36:05 +02:00
ec635fb707 fix: simplify Claude workflows with clean username checks
- Simplify all permission checks to single username validation
- Remove complex permission logic for cleaner workflows
- Streamline issue implementation workflow
- Streamline PR assistant workflow
- Keep only essential functionality
- Fix YAML syntax issues
- Validate all workflows successfully

Changes:
- Single username check: context.actor !== 'bvdaakster'
- Simplified error messages
- Clean YAML structure
- Reduced complexity while maintaining functionality

All workflows now use simple, reliable permission checks.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 21:32:12 +02:00
cabe6eda96 feat: add Claude PR Assistant workflow for direct PR improvements
- Create new workflow for PR comment-based Claude assistance
- Support multiple commands: implement, fix, improve, update, refactor, help
- Work directly on PR branches without creating new PRs
- Include comprehensive permission checks (bvdaakster only)
- Add detailed documentation and usage examples
- Support quality checks: build, typecheck, lint, test
- Include smart context awareness of PR changes

Features:
- Direct PR branch modification
- Multiple trigger commands for different types of assistance
- Comprehensive error handling and user feedback
- Quality assurance with automated checks
- Detailed commit messages with attribution

Commands:
- @claude implement - Add new functionality
- @claude fix - Fix bugs or errors
- @claude improve - Enhance existing code
- @claude update - Update to requirements
- @claude refactor - Restructure code
- @claude help - General assistance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 21:28:54 +02:00
a3108a0f49 Bump package version to 0.1.6 2025-09-18 21:28:03 +02:00
Bas
113a0d36c0 Merge pull request #15 from xtr-dev/claude/issue-14-20250918-1914
fix: export mollieProvider and stripeProvider from main package
2025-09-18 21:27:27 +02:00
8ac328e14f feat: enhance Claude issue workflow with robust PR creation
- Improve change detection to check both staged and unstaged changes
- Add detailed file listing in PR description
- Include comprehensive review checklist with build/lint checks
- Add fallback PR creation mechanism for error resilience
- Enhance success messaging with detailed implementation summary
- Add debugging output for change detection
- Include deployment instructions in PR template

Key improvements:
- More robust change detection
- Error handling with fallback PR creation
- Better PR descriptions with changed files list
- Enhanced issue update messages
- Quality check reminders

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 21:24:25 +02:00
7a3d6ec26e fix: restrict Claude workflows to only bvdaakster user
- Change issue implementation workflow to only allow bvdaakster
- Update code review workflow to only trigger for bvdaakster's PRs
- Update configuration to reflect single-user access
- Remove other privileged users from the list

Only bvdaakster can now:
- Trigger Claude issue implementations with @claude comments
- Have PRs automatically reviewed by Claude

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 21:20:39 +02:00
534b0e440f feat: add comprehensive user permission controls for Claude workflows
- Add multi-level permission checking for issue implementation workflow
- Support multiple permission strategies: privileged users, admins only, combined, org-based
- Add permission validation with detailed error messages
- Restrict code review workflow to privileged users and repository members
- Create permission configuration file (.github/claude-config.json)
- Add comprehensive permission documentation

Permission strategies available:
- Privileged users only (most restrictive)
- Repository admins only
- Admins OR privileged users (default)
- Organization members with write access
- Everyone with write access (least restrictive)

Current configuration:
- Issue implementation: admins OR privileged users (bastiaan, xtr-dev-team)
- Code reviews: privileged users and repository members only

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 21:16:51 +02:00
claude[bot]
669a9decd5 fix: export mollieProvider and stripeProvider from main package
- Add re-exports for mollieProvider and stripeProvider in src/index.ts
- Export related provider types: PaymentProvider, ProviderData
- Export provider config types: MollieProviderConfig, StripeProviderConfig
- Resolves issue where providers were not accessible despite being documented

Fixes #14

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
2025-09-18 19:15:54 +00:00
bfa214aed6 fix: make providerId optional and add version field to Payment type
- Update `providerId` to be optional in Payment interface for flexibility
- Add `version` field to support potential data versioning requirements
2025-09-18 21:06:03 +02:00
c083ae183c fix: update Claude issue workflow to use official anthropics/claude-code-action@beta
- Replace placeholder implementation with official Anthropic Claude Code action
- Update required secret from CLAUDE_API_KEY to CLAUDE_CODE_OAUTH_TOKEN
- Add id-token: write permission for Claude Code action
- Include allowed_tools for build, typecheck, lint, and test commands
- Update documentation with correct secret name and technical details

The workflow now uses the official Claude Code action for reliable,
production-ready issue implementations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 21:02:20 +02:00
d09fe3054a feat: add Claude issue implementation automation workflow
- Add GitHub workflow that triggers on issue comments with @claude implement
- Creates branches under claude/ namespace for each implementation
- Automatically creates PRs with Claude-generated implementations
- Includes permission checks and proper error handling
- Add comprehensive documentation for usage

Triggers:
- @claude implement
- @claude fix
- @claude create

Features:
- Unique branch naming: claude/issue-{number}-{timestamp}
- Permission validation (write access required)
- Automatic PR creation with detailed descriptions
- Progress tracking via issue comments
- Branch cleanup for failed implementations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 20:56:46 +02:00
Bas
50ab001e94 Merge pull request #13 from xtr-dev/dev
fix: resolve module resolution errors by replacing path aliases with …
2025-09-18 20:51:35 +02:00
29db6635b8 fix: resolve module resolution errors by replacing path aliases with relative imports
- Replace all @/ path aliases with proper relative imports and .js extensions
- Update @mollie/api-client peer dependency to support v4.x (^3.7.0 || ^4.0.0)
- Bump version to 0.1.5
- Ensure ESM compatibility for plugin distribution

Fixes module resolution error: "Cannot find package '@/collections'" when using
the plugin in external PayloadCMS projects.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 20:48:53 +02:00
48 changed files with 5383 additions and 616 deletions

View File

@@ -12,11 +12,8 @@ on:
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
# Only allow bvdaakster to trigger reviews
if: github.event.pull_request.user.login == 'bvdaakster'
runs-on: ubuntu-latest
permissions:

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

395
README.md
View File

@@ -1,17 +1,38 @@
# @xtr-dev/payload-billing
A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Mollie, and local testing with comprehensive tracking.
[![npm version](https://badge.fury.io/js/@xtr-dev%2Fpayload-billing.svg)](https://badge.fury.io/js/@xtr-dev%2Fpayload-billing)
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Mollie, and local testing with comprehensive tracking and flexible customer data management.
⚠️ **Pre-release Warning**: This package is currently in active development (v0.1.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
## Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Imports](#imports)
- [Usage Examples](#usage-examples)
- [Creating a Payment](#creating-a-payment)
- [Creating an Invoice](#creating-an-invoice)
- [Creating a Refund](#creating-a-refund)
- [Querying Payments](#querying-payments)
- [Using REST API](#using-rest-api)
- [Provider Types](#provider-types)
- [Collections](#collections)
- [Webhook Endpoints](#webhook-endpoints)
- [Development](#development)
## Features
- 💳 Multiple payment providers (Stripe, Mollie, Test)
- 🧾 Invoice generation and management
- 📊 Complete payment tracking and history
- 🧾 Invoice generation with line items and tax calculation
- 👥 Flexible customer data management (relationship or embedded)
- 🔄 Automatic payment/invoice status synchronization
- 🪝 Secure webhook processing for all providers
- 🔗 Bidirectional payment-invoice-refund relationships
- 🎨 Collection extension support for custom fields and hooks
- 🧪 Built-in test provider for local development
- 📱 Payment management in PayloadCMS admin
- 🔒 Full TypeScript support
## Installation
@@ -42,6 +63,8 @@ pnpm add @mollie/api-client
## Quick Start
### Basic Configuration
```typescript
import { buildConfig } from 'payload'
import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
@@ -70,6 +93,68 @@ export default buildConfig({
})
```
### With Customer Management
```typescript
import { billingPlugin, CustomerInfoExtractor } from '@xtr-dev/payload-billing'
// Define how to extract customer info from your customer collection
const customerExtractor: 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,
}
})
billingPlugin({
// ... providers
collections: {
payments: 'payments',
invoices: 'invoices',
refunds: 'refunds',
},
customerRelationSlug: 'customers', // Enable customer relationships
customerInfoExtractor: customerExtractor, // Auto-sync customer data
})
```
### Custom Customer Data Extraction
```typescript
import { CustomerInfoExtractor } from '@xtr-dev/payload-billing'
const customExtractor: 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,
}
})
billingPlugin({
// ... other config
customerRelationSlug: 'clients',
customerInfoExtractor: customExtractor,
})
```
## Imports
```typescript
@@ -80,7 +165,17 @@ import { billingPlugin } from '@xtr-dev/payload-billing'
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
// Types
import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing'
import type {
PaymentProvider,
Payment,
Invoice,
Refund,
BillingPluginConfig,
CustomerInfoExtractor,
MollieProviderConfig,
StripeProviderConfig,
ProviderData
} from '@xtr-dev/payload-billing'
```
## Provider Types
@@ -99,9 +194,295 @@ Local development testing with configurable scenarios, automatic completion, deb
The plugin adds these collections:
- **payments** - Payment transactions with status and provider data
- **invoices** - Invoice generation with line items and PDF support
- **invoices** - Invoice generation with line items and embedded customer info
- **refunds** - Refund tracking and management
### Automatic Status Synchronization
The plugin automatically keeps payments and invoices in sync:
- **Payment → Invoice**: When a payment status changes to `paid` or `succeeded`, any linked invoice is automatically updated to `paid` status
- **Invoice → Payment**: When an invoice is created with a payment link, the payment is automatically linked back (bidirectional relationship)
- **Manual Invoice Payment**: When an invoice status is manually changed to `paid`, the linked payment is updated to `succeeded`
This ensures data consistency without manual intervention and works seamlessly with webhook updates from payment providers.
### Customer Data Management
The plugin supports flexible customer data handling:
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
## Usage Examples
### Creating a Payment
Payments are created through PayloadCMS's local API or REST API. The plugin automatically initializes the payment with the configured provider.
```typescript
// Using Payload Local API
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'stripe', // or 'mollie' or 'test'
amount: 2000, // Amount in cents ($20.00)
currency: 'USD',
description: 'Product purchase',
status: 'pending',
metadata: {
orderId: 'order-123',
customerId: 'cust-456'
}
}
})
```
### Creating an Invoice
Invoices can be created with customer information embedded or linked via relationship:
```typescript
// Create invoice with embedded customer info
const invoice = await payload.create({
collection: 'invoices',
data: {
customerInfo: {
name: 'John Doe',
email: 'john@example.com',
phone: '+1234567890',
company: 'Acme Corp',
taxId: 'TAX-123'
},
billingAddress: {
line1: '123 Main St',
line2: 'Suite 100',
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US'
},
currency: 'USD',
items: [
{
description: 'Web Development Services',
quantity: 10,
unitAmount: 5000 // $50.00 per hour
},
{
description: 'Hosting (Monthly)',
quantity: 1,
unitAmount: 2500 // $25.00
}
],
taxAmount: 7500, // $75.00 tax
status: 'open'
}
})
console.log(`Invoice created: ${invoice.number}`)
console.log(`Total amount: $${invoice.amount / 100}`)
```
### Creating an Invoice with Customer Relationship
If you've configured a customer collection with `customerRelationSlug` and `customerInfoExtractor`:
```typescript
// Create invoice linked to customer (info auto-populated)
const invoice = await payload.create({
collection: 'invoices',
data: {
customer: 'customer-id-123', // Customer relationship
currency: 'USD',
items: [
{
description: 'Subscription - Pro Plan',
quantity: 1,
unitAmount: 9900 // $99.00
}
],
status: 'open'
// customerInfo and billingAddress are auto-populated from customer
}
})
```
### Creating a Refund
Refunds are linked to existing payments:
```typescript
const refund = await payload.create({
collection: 'refunds',
data: {
payment: payment.id, // Link to payment
providerId: 'refund-provider-id', // Provider's refund ID
amount: 1000, // Partial refund: $10.00
currency: 'USD',
status: 'succeeded',
reason: 'requested_by_customer',
description: 'Customer requested partial refund'
}
})
```
### Querying Payments
```typescript
// Find all successful payments
const payments = await payload.find({
collection: 'payments',
where: {
status: {
equals: 'succeeded'
}
}
})
// Find payments for a specific invoice
const invoicePayments = await payload.find({
collection: 'payments',
where: {
invoice: {
equals: invoiceId
}
}
})
```
### Updating Payment Status
Payment status is typically updated via webhooks, but you can also update manually:
```typescript
const updatedPayment = await payload.update({
collection: 'payments',
id: payment.id,
data: {
status: 'succeeded',
providerData: {
// Provider-specific data
raw: providerResponse,
timestamp: new Date().toISOString(),
provider: 'stripe'
}
}
})
```
### Marking an Invoice as Paid
```typescript
const paidInvoice = await payload.update({
collection: 'invoices',
id: invoice.id,
data: {
status: 'paid',
payment: payment.id // Link to payment
// paidAt is automatically set by the plugin
}
})
```
### Using the Test Provider
The test provider is useful for local development:
```typescript
// In your payload.config.ts
import { billingPlugin, testProvider } from '@xtr-dev/payload-billing'
billingPlugin({
providers: [
testProvider({
enabled: true,
testModeIndicators: {
showWarningBanners: true,
showTestBadges: true,
consoleWarnings: true
}
})
],
collections: {
payments: 'payments',
invoices: 'invoices',
refunds: 'refunds',
}
})
```
Then create test payments:
```typescript
const testPayment = await payload.create({
collection: 'payments',
data: {
provider: 'test',
amount: 5000,
currency: 'USD',
description: 'Test payment',
status: 'pending'
}
})
// Test provider automatically processes the payment
```
### Using REST API
All collections can be accessed via PayloadCMS REST API:
```bash
# Create a payment
curl -X POST http://localhost:3000/api/payments \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"provider": "stripe",
"amount": 2000,
"currency": "USD",
"description": "Product purchase",
"status": "pending"
}'
# Create an invoice
curl -X POST http://localhost:3000/api/invoices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"customerInfo": {
"name": "John Doe",
"email": "john@example.com"
},
"billingAddress": {
"line1": "123 Main St",
"city": "New York",
"postalCode": "10001",
"country": "US"
},
"currency": "USD",
"items": [
{
"description": "Service",
"quantity": 1,
"unitAmount": 5000
}
],
"status": "open"
}'
# Get all payments
curl http://localhost:3000/api/payments \
-H "Authorization: Bearer YOUR_TOKEN"
# Get a specific invoice
curl http://localhost:3000/api/invoices/INVOICE_ID \
-H "Authorization: Bearer YOUR_TOKEN"
```
## Webhook Endpoints
Automatic webhook endpoints are created for configured providers:

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".
@@ -158,7 +182,7 @@ export interface Payment {
/**
* The payment ID from the payment provider
*/
providerId: string;
providerId?: string | null;
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
/**
* Amount in cents (e.g., 2000 = $20.00)
@@ -198,6 +222,7 @@ export interface Payment {
| boolean
| null;
refunds?: (number | Refund)[] | null;
version?: number | null;
updatedAt: string;
createdAt: string;
}
@@ -212,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
*/
@@ -237,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
*/
@@ -256,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';
/**
@@ -401,6 +430,10 @@ export interface PayloadLockedDocument {
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'customers';
value: number | Customer;
} | null)
| ({
relationTo: 'payments';
value: number | Payment;
@@ -484,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".
@@ -499,6 +555,7 @@ export interface PaymentsSelect<T extends boolean = true> {
metadata?: T;
providerData?: T;
refunds?: T;
version?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -508,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

@@ -8,7 +8,7 @@ import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter'
import { seed } from './seed'
import billingPlugin from '../src/plugin'
import { mollieProvider } from '../src/providers'
import { testProvider } from '../src/providers'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -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: {
@@ -50,15 +51,74 @@ const buildConfigWithSQLite = () => {
plugins: [
billingPlugin({
providers: [
mollieProvider({
apiKey: process.env.MOLLIE_KEY!
testProvider({
enabled: true,
testModeIndicators: {
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

@@ -0,0 +1,147 @@
# Advanced Test Provider Example
The advanced test provider allows you to test complex payment scenarios with an interactive UI for development purposes.
## Basic Configuration
```typescript
import { billingPlugin, testProvider } from '@xtr-dev/payload-billing'
// Configure the test provider
const testProviderConfig = {
enabled: true, // Enable the test provider
defaultDelay: 2000, // Default delay in milliseconds
baseUrl: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000',
customUiRoute: '/test-payment', // Custom route for test payment UI
testModeIndicators: {
showWarningBanners: true, // Show warning banners in test mode
showTestBadges: true, // Show test badges
consoleWarnings: true, // Show console warnings
}
}
// Add to your payload config
export default buildConfig({
plugins: [
billingPlugin({
providers: [
testProvider(testProviderConfig)
]
})
]
})
```
## Custom Scenarios
You can define custom payment scenarios:
```typescript
const customScenarios = [
{
id: 'quick-success',
name: 'Quick Success',
description: 'Payment succeeds in 1 second',
outcome: 'paid' as const,
delay: 1000,
method: 'creditcard' as const
},
{
id: 'network-timeout',
name: 'Network Timeout',
description: 'Simulates network timeout',
outcome: 'failed' as const,
delay: 10000
},
{
id: 'user-abandonment',
name: 'User Abandonment',
description: 'User closes payment window',
outcome: 'cancelled' as const,
delay: 5000
}
]
const testProviderConfig = {
enabled: true,
scenarios: customScenarios,
// ... other config
}
```
## Available Payment Outcomes
- `paid` - Payment succeeds
- `failed` - Payment fails
- `cancelled` - Payment is cancelled by user
- `expired` - Payment expires
- `pending` - Payment remains pending
## Available Payment Methods
- `ideal` - iDEAL (Dutch banking)
- `creditcard` - Credit/Debit Cards
- `paypal` - PayPal
- `applepay` - Apple Pay
- `banktransfer` - Bank Transfer
## Using the Test UI
1. Create a payment using the test provider
2. The payment will return a `paymentUrl` in the provider data
3. Navigate to this URL to access the interactive test interface
4. Select a payment method and scenario
5. Click "Process Test Payment" to simulate the payment
6. The payment status will update automatically based on the selected scenario
## React Components
Use the provided React components in your admin interface:
```tsx
import { TestModeWarningBanner, TestModeBadge, TestPaymentControls } from '@xtr-dev/payload-billing/client'
// Show warning banner when in test mode
<TestModeWarningBanner visible={isTestMode} />
// Add test badge to payment status
<div>
Payment Status: {status}
<TestModeBadge visible={isTestMode} />
</div>
// Payment testing controls
<TestPaymentControls
paymentId={paymentId}
onScenarioSelect={(scenario) => console.log('Selected scenario:', scenario)}
onMethodSelect={(method) => console.log('Selected method:', method)}
/>
```
## API Endpoints
The test provider automatically registers these endpoints:
- `GET /api/payload-billing/test/payment/:id` - Test payment UI
- `POST /api/payload-billing/test/process` - Process test payment
- `GET /api/payload-billing/test/status/:id` - Get payment status
## Development Tips
1. **Console Warnings**: Keep `consoleWarnings: true` to get notifications about test mode
2. **Visual Indicators**: Use warning banners and badges to clearly mark test payments
3. **Custom Scenarios**: Create scenarios that match your specific use cases
4. **Automated Testing**: Use the test provider in your e2e tests for predictable payment outcomes
5. **Method Testing**: Test different payment methods to ensure your UI handles them correctly
## Production Safety
The test provider includes several safety mechanisms:
- Must be explicitly enabled with `enabled: true`
- Clearly marked with test indicators
- Console warnings when active
- Separate endpoint namespace (`/payload-billing/test/`)
- No real payment processing
**Important**: Never use the test provider in production environments!

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.4",
"version": "0.1.13",
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT",
"type": "module",
@@ -81,9 +81,11 @@
"@playwright/test": "^1.52.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.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 +95,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,12 +104,13 @@
"sharp": "0.34.2",
"sort-package-json": "^2.10.0",
"stripe": "^18.5.0",
"tailwindcss": "^4.1.17",
"typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2"
},
"peerDependencies": {
"@mollie/api-client": "^3.7.0",
"@mollie/api-client": "^3.7.0 || ^4.0.0",
"payload": "^3.37.0",
"stripe": "^18.5.0"
},

File diff suppressed because one or more lines are too long

888
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,13 @@
import type { Payment } from '@/plugin/types'
import type { Payment } from '../plugin/types/index'
import type { Payload } from 'payload'
import { useBillingPlugin } from '@/plugin'
import { useBillingPlugin } from '../plugin/index'
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => {
export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
const billing = useBillingPlugin(payload)
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 { createContextLogger } from '@/utils/logger'
import type { Invoice } from '@/plugin/types'
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 { BillingPluginConfig} from '@/plugin/config';
import { defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils'
import type { Payment } from '@/plugin/types/payments'
import { initProviderPayment } from '@/collections/hooks'
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',
@@ -79,7 +84,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
admin: {
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
relationTo: invoicesSlug,
},
{
name: 'metadata',
@@ -104,7 +109,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
readOnly: true,
},
hasMany: true,
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
relationTo: refundsSlug,
},
{
name: 'version',
@@ -116,12 +121,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 +134,43 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
group: 'Billing',
useAsTitle: 'id',
...overrides?.admin
},
fields,
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 +203,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 { extractSlug } from '@/plugin/utils'
import { Payment } from '@/plugin/types'
import type { BillingPluginConfig} from '../plugin/config';
import { defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import type { Payment } from '../plugin/types/index'
import { createContextLogger } from '../utils/logger'
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

@@ -60,9 +60,130 @@ export const PaymentStatusBadge: React.FC<{ status: string }> = ({ status }) =>
)
}
// Test mode indicator components
export const TestModeWarningBanner: React.FC<{ visible?: boolean }> = ({ visible = true }) => {
if (!visible) {return null}
return (
<div style={{
background: 'linear-gradient(90deg, #ff6b6b, #ffa726)',
color: 'white',
padding: '12px 20px',
textAlign: 'center',
fontWeight: 600,
fontSize: '14px',
marginBottom: '20px',
borderRadius: '4px'
}}>
<span role="img" aria-label="test tube">🧪</span> TEST MODE - Payment system is running in test mode for development
</div>
)
}
export const TestModeBadge: React.FC<{ visible?: boolean }> = ({ visible = true }) => {
if (!visible) {return null}
return (
<span style={{
display: 'inline-block',
background: '#6c757d',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase',
marginLeft: '8px'
}}>
Test
</span>
)
}
export const TestPaymentControls: React.FC<{
paymentId?: string
onScenarioSelect?: (scenario: string) => void
onMethodSelect?: (method: string) => void
}> = ({ paymentId, onScenarioSelect, onMethodSelect }) => {
const [selectedScenario, setSelectedScenario] = React.useState('')
const [selectedMethod, setSelectedMethod] = React.useState('')
const scenarios = [
{ id: 'instant-success', name: 'Instant Success', description: 'Payment succeeds immediately' },
{ id: 'delayed-success', name: 'Delayed Success', description: 'Payment succeeds after delay' },
{ id: 'cancelled-payment', name: 'Cancelled Payment', description: 'User cancels payment' },
{ id: 'declined-payment', name: 'Declined Payment', description: 'Payment declined' },
{ id: 'expired-payment', name: 'Expired Payment', description: 'Payment expires' },
{ id: 'pending-payment', name: 'Pending Payment', description: 'Payment stays pending' }
]
const methods = [
{ id: 'ideal', name: 'iDEAL', icon: '🏦' },
{ id: 'creditcard', name: 'Credit Card', icon: '💳' },
{ id: 'paypal', name: 'PayPal', icon: '🅿️' },
{ id: 'applepay', name: 'Apple Pay', icon: '🍎' },
{ id: 'banktransfer', name: 'Bank Transfer', icon: '🏛️' }
]
return (
<div style={{ border: '1px solid #e9ecef', borderRadius: '8px', padding: '16px', margin: '16px 0' }}>
<h4 style={{ marginBottom: '12px', color: '#2c3e50' }}><span role="img" aria-label="test tube">🧪</span> Test Payment Controls</h4>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Payment Method:</label>
<select
value={selectedMethod}
onChange={(e) => {
setSelectedMethod(e.target.value)
onMethodSelect?.(e.target.value)
}}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
>
<option value="">Select payment method...</option>
{methods.map(method => (
<option key={method.id} value={method.id}>
{method.icon} {method.name}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Test Scenario:</label>
<select
value={selectedScenario}
onChange={(e) => {
setSelectedScenario(e.target.value)
onScenarioSelect?.(e.target.value)
}}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
>
<option value="">Select test scenario...</option>
{scenarios.map(scenario => (
<option key={scenario.id} value={scenario.id}>
{scenario.name} - {scenario.description}
</option>
))}
</select>
</div>
{paymentId && (
<div style={{ marginTop: '12px', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<small style={{ color: '#6c757d' }}>
Payment ID: <code>{paymentId}</code>
</small>
</div>
)}
</div>
)
}
export default {
BillingDashboardWidget,
formatCurrency,
getPaymentStatusColor,
PaymentStatusBadge,
TestModeWarningBanner,
TestModeBadge,
TestPaymentControls,
}

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

@@ -1,4 +1,21 @@
export { billingPlugin } from './plugin'
export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config'
export type { Invoice, Payment, Refund } from './plugin/types'
export { billingPlugin } from './plugin/index.js'
export { mollieProvider, stripeProvider } from './providers/index.js'
export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderConfig } from './plugin/config.js'
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
export type { PaymentProvider, ProviderData } from './providers/types.js'
// Export logging utilities
export { getPluginLogger, createContextLogger } from './utils/logger.js'
// Export all providers
export { testProvider } from './providers/test.js'
export type {
StripeProviderConfig,
MollieProviderConfig,
TestProviderConfig,
TestProviderConfigResponse,
PaymentOutcome,
PaymentMethod,
PaymentScenario
} from './providers/index.js'

View File

@@ -1,6 +1,6 @@
import { CollectionConfig } from 'payload'
import { FieldsOverride } from '@/plugin/utils'
import { PaymentProvider } from '@/plugin/types'
import type { CollectionConfig } from 'payload'
import type { FieldsOverride } from './utils'
import type { PaymentProvider } from './types/index'
export const defaults = {
paymentsCollection: 'payments',
@@ -19,6 +19,9 @@ export interface TestProviderConfig {
simulateFailures?: boolean
}
// Re-export the actual test provider config instead of duplicating
export type { TestProviderConfig as AdvancedTestProviderConfig } from '../providers/test'
// Customer info extractor callback type
export interface CustomerInfoExtractor {
(customer: any): {
@@ -38,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?: {
@@ -45,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

@@ -1,8 +1,8 @@
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '@/collections'
import type { BillingPluginConfig } from '@/plugin/config'
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index'
import type { BillingPluginConfig } from './config'
import type { Config, Payload } from 'payload'
import { createSingleton } from '@/plugin/singleton'
import type { PaymentProvider } from '@/providers'
import { createSingleton } from './singleton'
import type { PaymentProvider } from '../providers/index'
const singleton = createSingleton(Symbol('billingPlugin'))
@@ -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 @@
export * from './id'
export * from './invoices'
export * from './payments'
export * from './refunds'
export * from '../../providers/types'
export * from './id.js'
export * from './invoices.js'
export * from './payments.js'
export * from './refunds.js'
export * from '../../providers/types.js'

View File

@@ -1,6 +1,5 @@
import { Payment } from '@/plugin/types/payments'
import { Id } from '@/plugin/types/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 '@/plugin/types/refunds'
import { Invoice } from '@/plugin/types/invoices'
import { Id } from '@/plugin/types/id'
import type { Refund } from './refunds'
import type { Invoice } from './invoices'
import type { Id } from './id'
export interface Payment {
id: Id;

View File

@@ -1,4 +1,4 @@
import { Payment } from '@/plugin/types/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 '@/plugin/types'
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

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

View File

@@ -1,7 +1,7 @@
import type { Payment } from '@/plugin/types/payments'
import type { PaymentProvider } from '@/plugin/types'
import type { Payment } from '../plugin/types/payments'
import type { PaymentProvider } from '../plugin/types/index'
import type { Payload } from 'payload'
import { createSingleton } from '@/plugin/singleton'
import { createSingleton } from '../plugin/singleton'
import type { createMollieClient, MollieClient } from '@mollie/api-client'
import {
webhookResponses,
@@ -12,6 +12,7 @@ import {
validateProductionUrl
} from './utils'
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
@@ -96,12 +97,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)
}
}
}

View File

@@ -1,7 +1,7 @@
import type { Payment } from '@/plugin/types/payments'
import type { PaymentProvider, ProviderData } from '@/plugin/types'
import type { Payment } from '../plugin/types/payments'
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
import type { Payload } from 'payload'
import { createSingleton } from '@/plugin/singleton'
import { createSingleton } from '../plugin/singleton'
import type Stripe from 'stripe'
import {
webhookResponses,
@@ -12,6 +12,7 @@ import {
logWebhookEvent
} from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('stripe')
@@ -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

941
src/providers/test.ts Normal file
View File

@@ -0,0 +1,941 @@
import type { Payment } from '../plugin/types/payments'
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
import type { BillingPluginConfig } from '../plugin/config'
import type { Payload } from 'payload'
import { handleWebhookError, logWebhookEvent } from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const TestModeWarningSymbol = Symbol('TestModeWarning')
const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis
const setTestModeWarning = () => ((<any>globalThis)[TestModeWarningSymbol] = true)
// Request validation schemas
interface ProcessPaymentRequest {
paymentId: string
scenarioId: string
method: PaymentMethod
}
// Validation functions
function validateProcessPaymentRequest(body: any): { isValid: boolean; data?: ProcessPaymentRequest; error?: string } {
if (!body || typeof body !== 'object') {
return { isValid: false, error: 'Request body must be a valid JSON object' }
}
const { paymentId, scenarioId, method } = body
if (!paymentId || typeof paymentId !== 'string') {
return { isValid: false, error: 'paymentId is required and must be a string' }
}
if (!scenarioId || typeof scenarioId !== 'string') {
return { isValid: false, error: 'scenarioId is required and must be a string' }
}
if (!method || typeof method !== 'string') {
return { isValid: false, error: 'method is required and must be a string' }
}
// Validate method is a valid payment method
const validMethods: PaymentMethod[] = ['ideal', 'creditcard', 'paypal', 'applepay', 'banktransfer']
if (!validMethods.includes(method as PaymentMethod)) {
return { isValid: false, error: `method must be one of: ${validMethods.join(', ')}` }
}
return {
isValid: true,
data: { paymentId, scenarioId, method: method as PaymentMethod }
}
}
function validatePaymentId(paymentId: string): { isValid: boolean; error?: string } {
if (!paymentId || typeof paymentId !== 'string') {
return { isValid: false, error: 'Payment ID is required and must be a string' }
}
// Validate payment ID format (should match test payment ID pattern)
if (!paymentId.startsWith('test_pay_')) {
return { isValid: false, error: 'Invalid payment ID format' }
}
return { isValid: true }
}
// Utility function to safely extract collection name
function getPaymentsCollectionName(pluginConfig: BillingPluginConfig): string {
if (typeof pluginConfig.collections?.payments === 'string') {
return pluginConfig.collections.payments
}
return 'payments'
}
// Enhanced error handling utility for database operations
async function updatePaymentInDatabase(
payload: Payload,
sessionId: string,
status: Payment['status'],
providerData: ProviderData,
pluginConfig: BillingPluginConfig
): Promise<{ success: boolean; error?: string }> {
try {
const paymentsCollection = getPaymentsCollectionName(pluginConfig)
const payments = await payload.find({
collection: paymentsCollection as any, // PayloadCMS collection type constraint
where: { providerId: { equals: sessionId } },
limit: 1
})
if (payments.docs.length === 0) {
return { success: false, error: 'Payment not found in database' }
}
await payload.update({
collection: paymentsCollection as any, // PayloadCMS collection type constraint
id: payments.docs[0].id,
data: {
status,
providerData
}
})
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown database error'
const logger = createContextLogger(payload, 'Test Provider')
logger.error('Database update failed:', errorMessage)
return { success: false, error: errorMessage }
}
}
export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending'
export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer'
export interface PaymentScenario {
id: string
name: string
description: string
outcome: PaymentOutcome
delay?: number // Delay in milliseconds before processing
method?: PaymentMethod
}
export interface TestProviderConfig {
enabled: boolean
scenarios?: PaymentScenario[]
customUiRoute?: string
testModeIndicators?: {
showWarningBanners?: boolean
showTestBadges?: boolean
consoleWarnings?: boolean
}
defaultDelay?: number
baseUrl?: string
}
export interface TestProviderConfigResponse {
enabled: boolean
scenarios: PaymentScenario[]
methods: Array<{
id: string
name: string
icon: string
}>
testModeIndicators: {
showWarningBanners: boolean
showTestBadges: boolean
consoleWarnings: boolean
}
defaultDelay: number
customUiRoute: string
}
// Properly typed session interface
export interface TestPaymentSession {
id: string
payment: Partial<Payment>
scenario?: PaymentScenario
method?: PaymentMethod
createdAt: Date
status: PaymentOutcome
}
// Use the proper BillingPluginConfig type
// Default payment scenarios
const DEFAULT_SCENARIOS: PaymentScenario[] = [
{
id: 'instant-success',
name: 'Instant Success',
description: 'Payment succeeds immediately',
outcome: 'paid',
delay: 0
},
{
id: 'delayed-success',
name: 'Delayed Success',
description: 'Payment succeeds after a delay',
outcome: 'paid',
delay: 3000
},
{
id: 'cancelled-payment',
name: 'Cancelled Payment',
description: 'User cancels the payment',
outcome: 'cancelled',
delay: 1000
},
{
id: 'declined-payment',
name: 'Declined Payment',
description: 'Payment is declined by the provider',
outcome: 'failed',
delay: 2000
},
{
id: 'expired-payment',
name: 'Expired Payment',
description: 'Payment expires before completion',
outcome: 'expired',
delay: 5000
},
{
id: 'pending-payment',
name: 'Pending Payment',
description: 'Payment remains in pending state',
outcome: 'pending',
delay: 1500
}
]
// Payment method configurations
const PAYMENT_METHODS: Record<PaymentMethod, { name: string; icon: string }> = {
ideal: { name: 'iDEAL', icon: '🏦' },
creditcard: { name: 'Credit Card', icon: '💳' },
paypal: { name: 'PayPal', icon: '🅿️' },
applepay: { name: 'Apple Pay', icon: '🍎' },
banktransfer: { name: 'Bank Transfer', icon: '🏛️' }
}
// In-memory storage for test payment sessions
const testPaymentSessions = new Map<string, TestPaymentSession>()
export const testProvider = (testConfig: TestProviderConfig) => {
if (!testConfig.enabled) {
return
}
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
const uiRoute = testConfig.customUiRoute || '/test-payment'
// Test mode warnings will be logged in onInit when payload is available
return {
key: 'test',
onConfig: (config, pluginConfig) => {
// Register test payment UI endpoint
config.endpoints = [
...(config.endpoints || []),
{
path: '/payload-billing/test/payment/:id',
method: 'get',
handler: (req) => {
// Extract payment ID from URL path
const urlParts = req.url?.split('/') || []
const paymentId = urlParts[urlParts.length - 1]
if (!paymentId) {
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
// Validate payment ID format
const validation = validatePaymentId(paymentId)
if (!validation.isValid) {
return new Response(JSON.stringify({ error: validation.error }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
const session = testPaymentSessions.get(paymentId)
if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
})
}
// Generate test payment UI
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig)
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
})
}
},
{
path: '/payload-billing/test/config',
method: 'get',
handler: () => {
const response: TestProviderConfigResponse = {
enabled: testConfig.enabled,
scenarios,
methods: Object.entries(PAYMENT_METHODS).map(([id, method]) => ({
id,
name: method.name,
icon: method.icon
})),
testModeIndicators: {
showWarningBanners: testConfig.testModeIndicators?.showWarningBanners ?? true,
showTestBadges: testConfig.testModeIndicators?.showTestBadges ?? true,
consoleWarnings: testConfig.testModeIndicators?.consoleWarnings ?? true
},
defaultDelay: testConfig.defaultDelay || 1000,
customUiRoute: uiRoute
}
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
})
}
},
{
path: '/payload-billing/test/process',
method: 'post',
handler: async (req) => {
try {
const payload = req.payload
const body = await req.json?.() || {}
// Validate request body
const validation = validateProcessPaymentRequest(body)
if (!validation.isValid) {
return new Response(JSON.stringify({ error: validation.error }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
const { paymentId, scenarioId, method } = validation.data!
const session = testPaymentSessions.get(paymentId)
if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
})
}
const scenario = scenarios.find(s => s.id === scenarioId)
if (!scenario) {
return new Response(JSON.stringify({ error: 'Invalid scenario ID' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
// Update session with selected scenario and method
session.scenario = scenario
session.method = method
session.status = 'pending'
// Process payment after delay
setTimeout(() => {
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
const logger = createContextLogger(payload, 'Test Provider')
logger.error('Failed to process payment:', error)
// Ensure session status is updated consistently
session.status = 'failed'
// Create error provider data
const errorProviderData: ProviderData = {
raw: {
error: error instanceof Error ? error.message : 'Unknown processing error',
processedAt: new Date().toISOString(),
testMode: true
},
timestamp: new Date().toISOString(),
provider: 'test'
}
// Update payment record in database with enhanced error handling
const dbResult = await updatePaymentInDatabase(
payload,
session.id,
'failed',
errorProviderData,
pluginConfig
)
if (!dbResult.success) {
const logger = createContextLogger(payload, 'Test Provider')
logger.error('Database error during failure handling:', dbResult.error)
// Even if database update fails, we maintain session consistency
} else {
logWebhookEvent('Test Provider', `Payment ${session.id} marked as failed after processing error`, undefined, req.payload)
}
})
}, scenario.delay || testConfig.defaultDelay || 1000)
return new Response(JSON.stringify({
success: true,
status: 'processing',
scenario: scenario.name,
delay: scenario.delay || testConfig.defaultDelay || 1000
}), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return handleWebhookError('Test Provider', error, 'Failed to process test payment', req.payload)
}
}
},
{
path: '/payload-billing/test/status/:id',
method: 'get',
handler: (req) => {
// Extract payment ID from URL path
const urlParts = req.url?.split('/') || []
const paymentId = urlParts[urlParts.length - 1]
if (!paymentId) {
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
// Validate payment ID format
const validation = validatePaymentId(paymentId)
if (!validation.isValid) {
return new Response(JSON.stringify({ error: validation.error }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
const session = testPaymentSessions.get(paymentId)
if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
})
}
return new Response(JSON.stringify({
status: session.status,
scenario: session.scenario?.name,
method: session.method ? PAYMENT_METHODS[session.method]?.name : undefined
}), {
headers: { 'Content-Type': 'application/json' }
})
}
}
]
},
onInit: (payload: Payload) => {
logWebhookEvent('Test Provider', 'Test payment provider initialized', undefined, payload)
// Log test mode warnings if enabled
if (testConfig.testModeIndicators?.consoleWarnings !== false && !hasGivenTestModeWarning()) {
setTestModeWarning()
const logger = createContextLogger(payload, 'Test Provider')
logger.warn('🧪 Payment system is running in test mode')
}
// Clean up old sessions periodically (older than 1 hour)
setInterval(() => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
testPaymentSessions.forEach((session, id) => {
if (session.createdAt < oneHourAgo) {
testPaymentSessions.delete(id)
}
})
}, 10 * 60 * 1000) // Clean every 10 minutes
},
initPayment: (payload, payment) => {
// Validate required fields
if (!payment.amount) {
throw new Error('Amount is required')
}
if (!payment.currency) {
throw new Error('Currency is required')
}
// Validate amount
if (!isValidAmount(payment.amount)) {
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
}
// Validate currency code
if (!isValidCurrencyCode(payment.currency)) {
throw new Error('Invalid currency: must be a 3-letter ISO code')
}
// Generate unique test payment ID
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// Create test payment session
const session = {
id: testPaymentId,
payment: { ...payment },
createdAt: new Date(),
status: 'pending' as PaymentOutcome
}
testPaymentSessions.set(testPaymentId, session)
// Set provider ID and data
payment.providerId = testPaymentId
const providerData: ProviderData = {
raw: {
id: testPaymentId,
amount: payment.amount,
currency: payment.currency,
description: payment.description,
status: 'pending',
testMode: true,
paymentUrl: `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`,
scenarios: scenarios.map(s => ({ id: s.id, name: s.name, description: s.description })),
methods: Object.entries(PAYMENT_METHODS).map(([key, value]) => ({
id: key,
name: value.name,
icon: value.icon
}))
},
timestamp: new Date().toISOString(),
provider: 'test'
}
payment.providerData = providerData
return payment
},
} satisfies PaymentProvider
}
// Helper function to process test payment based on scenario
async function processTestPayment(
payload: Payload,
session: TestPaymentSession,
pluginConfig: BillingPluginConfig
): Promise<void> {
try {
if (!session.scenario) {return}
// Map scenario outcome to payment status
let finalStatus: Payment['status'] = 'pending'
switch (session.scenario.outcome) {
case 'paid':
finalStatus = 'succeeded'
break
case 'failed':
finalStatus = 'failed'
break
case 'cancelled':
finalStatus = 'canceled'
break
case 'expired':
finalStatus = 'canceled' // Treat expired as canceled
break
case 'pending':
finalStatus = 'pending'
break
}
// Update session status
session.status = session.scenario.outcome
// Update payment with final status and provider data
const updatedProviderData: ProviderData = {
raw: {
...session.payment,
id: session.id,
status: session.scenario.outcome,
scenario: session.scenario.name,
method: session.method,
processedAt: new Date().toISOString(),
testMode: true
},
timestamp: new Date().toISOString(),
provider: 'test'
}
// Use the utility function for database operations
const dbResult = await updatePaymentInDatabase(
payload,
session.id,
finalStatus,
updatedProviderData,
pluginConfig
)
if (dbResult.success) {
logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`, undefined, payload)
} else {
const logger = createContextLogger(payload, 'Test Provider')
logger.error('Failed to update payment in database:', dbResult.error)
// Update session status to indicate database error, but don't throw
// This allows the UI to still show the intended test result
session.status = 'failed'
throw new Error(`Database update failed: ${dbResult.error}`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown processing error'
const logger = createContextLogger(payload, 'Test Provider')
logger.error('Failed to process payment:', errorMessage)
session.status = 'failed'
throw error // Re-throw to be handled by the caller
}
}
// Helper function to generate test payment UI
function generateTestPaymentUI(
session: TestPaymentSession,
scenarios: PaymentScenario[],
uiRoute: string,
baseUrl: string,
testConfig: TestProviderConfig
): string {
const payment = session.payment
const testModeIndicators = testConfig.testModeIndicators || {}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Payment - ${payment.description || 'Payment'}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
${testModeIndicators.showWarningBanners !== false ? `
.test-banner {
background: linear-gradient(90deg, #ff6b6b, #ffa726);
color: white;
padding: 12px 20px;
text-align: center;
font-weight: 600;
font-size: 14px;
}
` : ''}
.header {
background: #f8f9fa;
padding: 30px 40px 20px;
border-bottom: 1px solid #e9ecef;
}
.title {
font-size: 24px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 8px;
}
.amount {
font-size: 32px;
font-weight: 800;
color: #27ae60;
margin-bottom: 16px;
}
.description {
color: #6c757d;
font-size: 16px;
line-height: 1.5;
}
.content { padding: 40px; }
.section { margin-bottom: 30px; }
.section-title {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.payment-methods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.method {
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 16px 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.method:hover {
border-color: #007bff;
background: #f8f9ff;
}
.method.selected {
border-color: #007bff;
background: #007bff;
color: white;
}
.method-icon { font-size: 24px; margin-bottom: 8px; }
.method-name { font-size: 12px; font-weight: 500; }
.scenarios {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
.scenario {
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.scenario:hover {
border-color: #28a745;
background: #f8fff9;
}
.scenario.selected {
border-color: #28a745;
background: #28a745;
color: white;
}
.scenario-name { font-weight: 600; margin-bottom: 4px; }
.scenario-desc { font-size: 14px; opacity: 0.8; }
.process-btn {
width: 100%;
background: linear-gradient(135deg, #007bff, #0056b3);
color: white;
border: none;
padding: 16px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 20px;
}
.process-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,123,255,0.3);
}
.process-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.status {
text-align: center;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
font-weight: 600;
}
.status.processing { background: #fff3cd; color: #856404; }
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
${testModeIndicators.showTestBadges !== false ? `
.test-badge {
display: inline-block;
background: #6c757d;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
margin-left: 8px;
}
` : ''}
</style>
</head>
<body>
<div class="container">
${testModeIndicators.showWarningBanners !== false ? `
<div class="test-banner">
🧪 TEST MODE - This is a simulated payment for development purposes
</div>
` : ''}
<div class="header">
<div class="title">
Test Payment Checkout
${testModeIndicators.showTestBadges !== false ? '<span class="test-badge">Test</span>' : ''}
</div>
<div class="amount">${payment.currency?.toUpperCase()} ${payment.amount ? (payment.amount / 100).toFixed(2) : '0.00'}</div>
${payment.description ? `<div class="description">${payment.description}</div>` : ''}
</div>
<div class="content">
<div class="section">
<div class="section-title">
💳 Select Payment Method
</div>
<div class="payment-methods">
${Object.entries(PAYMENT_METHODS).map(([key, method]) => `
<div class="method" data-method="${key}">
<div class="method-icon">${method.icon}</div>
<div class="method-name">${method.name}</div>
</div>
`).join('')}
</div>
</div>
<div class="section">
<div class="section-title">
🎭 Select Test Scenario
</div>
<div class="scenarios">
${scenarios.map(scenario => `
<div class="scenario" data-scenario="${scenario.id}">
<div class="scenario-name">${scenario.name}</div>
<div class="scenario-desc">${scenario.description}</div>
</div>
`).join('')}
</div>
</div>
<button class="process-btn" id="processBtn" disabled>
Process Test Payment
</button>
<div id="status" class="status" style="display: none;"></div>
</div>
</div>
<script>
let selectedMethod = null;
let selectedScenario = null;
// Payment method selection
document.querySelectorAll('.method').forEach(method => {
method.addEventListener('click', () => {
document.querySelectorAll('.method').forEach(m => m.classList.remove('selected'));
method.classList.add('selected');
selectedMethod = method.dataset.method;
updateProcessButton();
});
});
// Scenario selection
document.querySelectorAll('.scenario').forEach(scenario => {
scenario.addEventListener('click', () => {
document.querySelectorAll('.scenario').forEach(s => s.classList.remove('selected'));
scenario.classList.add('selected');
selectedScenario = scenario.dataset.scenario;
updateProcessButton();
});
});
function updateProcessButton() {
const btn = document.getElementById('processBtn');
btn.disabled = !selectedMethod || !selectedScenario;
}
// Process payment
document.getElementById('processBtn').addEventListener('click', async () => {
const btn = document.getElementById('processBtn');
const status = document.getElementById('status');
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>Processing...';
try {
const response = await fetch('/api/payload-billing/test/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentId: '${session.id}',
scenarioId: selectedScenario,
method: selectedMethod
})
});
const result = await response.json();
if (result.success) {
status.className = 'status processing';
status.style.display = 'block';
status.innerHTML = \`<span class="loading"></span>Processing payment with \${result.scenario}...\`;
// Poll for status updates
setTimeout(() => pollStatus(), result.delay || 1000);
} else {
throw new Error(result.error || 'Failed to process payment');
}
} catch (error) {
status.className = 'status error';
status.style.display = 'block';
status.textContent = 'Error: ' + error.message;
btn.disabled = false;
btn.textContent = 'Process Test Payment';
}
});
async function pollStatus() {
try {
const response = await fetch('/api/payload-billing/test/status/${session.id}');
const result = await response.json();
const status = document.getElementById('status');
const btn = document.getElementById('processBtn');
if (result.status === 'paid') {
status.className = 'status success';
status.textContent = '✅ Payment successful!';
setTimeout(() => {
window.location.href = '${baseUrl}/success';
}, 2000);
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
status.className = 'status error';
status.textContent = \`❌ Payment \${result.status}\`;
btn.disabled = false;
btn.textContent = 'Try Again';
} else if (result.status === 'pending') {
status.className = 'status processing';
status.innerHTML = '<span class="loading"></span>Payment is still pending...';
setTimeout(() => pollStatus(), 2000);
}
} catch (error) {
console.error('[Test Provider] Failed to poll status:', error);
}
}
${testModeIndicators.consoleWarnings !== false ? `
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface for development purposes');
` : ''}
</script>
</body>
</html>`
}

View File

@@ -1,8 +1,8 @@
import type { Payment } from '@/plugin/types/payments'
import type { Payment } from '../plugin/types/payments'
import type { Config, Payload } from 'payload'
import type { BillingPluginConfig } from '@/plugin/config'
import type { BillingPluginConfig } from '../plugin/config'
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
export type PaymentProvider = {
key: string

View File

@@ -1,9 +1,10 @@
import type { Payload } from 'payload'
import type { Payment } from '@/plugin/types/payments'
import type { BillingPluginConfig } from '@/plugin/config'
import type { Payment } from '../plugin/types/payments'
import type { BillingPluginConfig } from '../plugin/config'
import type { ProviderData } from './types'
import { defaults } from '@/plugin/config'
import { extractSlug, toPayloadId } from '@/plugin/utils'
import { 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,8 @@ export async function updatePaymentStatus(
throw error
}
} catch (error) {
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Failed to update payment ${paymentId}:`, error)
return false
}
}
@@ -129,9 +139,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 +162,19 @@ 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 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:', error)
} else {
console.error(`[${fullContext}] Error:`, error)
}
// Return generic response to avoid information disclosure
return Response.json({
@@ -173,9 +189,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 +206,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),
}
}