82 Commits

Author SHA1 Message Date
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
Bas
b1c1a11225 Merge pull request #12 from xtr-dev/dev
Dev
2025-09-18 19:40:22 +02:00
de30372453 feat: add optimistic locking to prevent payment race conditions
- Add version field to Payment interface and collection schema
- Implement transaction-based optimistic locking in updatePaymentStatus
- Auto-increment version on manual updates via beforeChange hook
- Log version conflicts for monitoring concurrent update attempts

This prevents race conditions when multiple webhook events arrive
simultaneously for the same payment, ensuring data consistency.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 19:36:13 +02:00
4fbab7942f Bump package version to 0.1.4 2025-09-18 19:35:05 +02:00
claude[bot]
84099196b1 feat: implement optimistic locking for payment updates
- Add version field to Payment interface and collection schema
- Implement atomic updates using updateMany with version checks
- Add collection hook to auto-increment version for manual admin updates
- Prevent race conditions in concurrent webhook processing
- Index version field for performance

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
2025-09-18 17:15:10 +00:00
a25111444a Completely remove all race condition and optimistic locking logic
- Remove webhook context tracking system (context.ts file)
- Eliminate updatePaymentFromWebhook wrapper function
- Simplify payment providers to use updatePaymentStatus directly
- Remove all version-based optimistic locking references
- Clean up webhook context parameters and metadata
- Streamline codebase assuming providers don't send duplicate webhooks

The payment system now operates with simple, direct updates without any
race condition handling, as payment providers typically don't send
duplicate webhook requests for the same event.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 18:51:23 +02:00
b6c27ff3a3 Simplify payment updates by removing race condition logic
- Remove complex optimistic locking and version-based updates
- Simplify payment status updates assuming providers don't send duplicates
- Remove version field from Payment type and collection schema
- Clean up webhook detection logic in payment hooks
- Streamline updatePaymentStatus function for better maintainability

Payment providers typically don't send duplicate webhook requests, so the
complex race condition handling was unnecessary overhead.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 20:17:18 +02:00
479f1bbd0e Fix ESLint issues and remove unnecessary type assertions 2025-09-17 20:09:47 +02:00
876501d94f Enhance webhook detection with explicit context tracking and database optimization
- Add database index on version field for optimistic locking performance
- Implement explicit webhook context tracking with symbols to avoid conflicts
- Replace fragile webhook detection logic with robust context-based approach
- Add request metadata support for enhanced debugging and audit trails
- Simplify version management in payment collection hooks
- Fix TypeScript compilation errors and improve type safety

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 20:07:02 +02:00
a5b6bb9bfd fix: Address type safety and error handling concerns
🔧 Type Safety Improvements:
- Add missing ProviderData import to fix compilation errors
- Create toPayloadId utility for safe ID type conversion
- Replace all 'as any' casts with typed utility function
- Improve type safety while maintaining PayloadCMS compatibility

🛡️ Error Handling Enhancements:
- Add try-catch for version check in payment hooks
- Handle missing documents gracefully with fallback to version 1
- Add detailed logging for debugging race conditions
- Prevent hook failures from blocking payment operations

 Version Logic Improvements:
- Distinguish between webhook updates and manual admin updates
- Only auto-increment version for manual updates, not webhook updates
- Check for webhook-specific fields to determine update source
- Reduce race condition risks with explicit update type detection

🔍 Code Quality:
- Centralized type casting in utility function
- Better error messages and logging context
- More explicit logic flow for version handling
- Improved maintainability and debugging capabilities

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 19:32:32 +02:00
10f9b4f47b fix: Add type cast for PayloadCMS updateMany ID parameter
- Cast paymentId to 'any' in updateMany where clause to resolve type mismatch
- Maintain atomic optimistic locking functionality while fixing TypeScript errors
- PayloadCMS type system requires specific ID type that conflicts with our Id union type

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 19:22:45 +02:00
555e52f0b8 fix: Simplify PayloadCMS query structure for optimistic locking
- Remove complex 'and' clause structure that caused type issues
- Use direct field matching in where clause for better compatibility
- Maintain atomic optimistic locking functionality
- Fix TypeScript compilation errors in updateMany operation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 19:21:35 +02:00
d757c6942c fix: Implement true atomic optimistic locking and enhance type safety
🔒 Critical Race Condition Fixes:
- Add version field to payment schema for atomic updates
- Implement true optimistic locking using PayloadCMS updateMany with version checks
- Eliminate race condition window between conflict check and update
- Auto-increment version in beforeChange hooks

🛡️ Type Safety Improvements:
- Replace 'any' type with proper ProviderData<T> generic
- Maintain type safety throughout payment provider operations
- Enhanced intellisense and compile-time error detection

 Performance & Reliability:
- Atomic version-based locking prevents lost updates
- Proper conflict detection with detailed logging
- Graceful handling of concurrent modifications
- Version field hidden from admin UI but tracked internally

🔧 Configuration Validation:
- All critical validation moved to provider initialization
- Early failure detection prevents runtime issues
- Clear error messages for configuration problems

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 19:20:06 +02:00
Bas
03b3451b02 Merge pull request #11 from xtr-dev/dev
Dev
2025-09-17 19:16:45 +02:00
07dbda12e8 fix: Resolve TypeScript errors with PayloadCMS ID types
- Add type casts to resolve mismatch between Id type and PayloadCMS types
- Fix findByID and update calls with proper type handling
- Ensure compatibility between internal Id type and PayloadCMS API

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 19:11:49 +02:00
031350ec6b fix: Address critical webhook and optimistic locking issues
🔒 Critical Fixes:
- Implement proper optimistic locking with conflict detection and verification
- Only register webhook endpoints when providers are properly configured
- Move provider validation to initialization for early error detection
- Fix TypeScript query structure for payment conflict checking

🛡️ Security Improvements:
- Stripe webhooks only registered when webhookSecret is provided
- Mollie validation ensures API key is present at startup
- Prevent exposure of unconfigured webhook endpoints

🚀 Reliability Enhancements:
- Payment update conflicts are properly detected and logged
- Invoice updates only proceed when payment updates succeed
- Enhanced error handling with graceful degradation
- Return boolean success indicators for better error tracking

🐛 Bug Fixes:
- Fix PayloadCMS query structure for optimistic locking
- Proper webhook endpoint conditional registration
- Early validation prevents runtime configuration errors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 19:06:09 +02:00
50f1267941 security: Enhance production security and reliability
🔒 Security Enhancements:
- Add HTTPS validation for production URLs with comprehensive checks
- Implement type-safe Mollie status mapping to prevent type confusion
- Add robust request body handling with proper error boundaries

🚀 Reliability Improvements:
- Implement optimistic locking to prevent webhook race conditions
- Add providerId field indexing for efficient payment lookups
- Include webhook processing metadata for audit trails

📊 Performance Optimizations:
- Index providerId field for faster webhook payment queries
- Optimize concurrent webhook handling with version checking
- Add graceful degradation for update conflicts

🛡️ Production Readiness:
- Validate HTTPS protocol enforcement in production
- Prevent localhost URLs in production environments
- Enhanced error context and logging for debugging

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 18:50:30 +02:00
a000fd3753 Bump package version to 0.1.3 2025-09-17 18:40:16 +02:00
bf9940924c security: Address critical security vulnerabilities and improve code quality
🔒 Security Fixes:
- Make webhook signature validation required for production
- Prevent information disclosure by returning 200 for all webhook responses
- Sanitize external error messages while preserving internal logging

🔧 Code Quality Improvements:
- Add URL validation to prevent localhost usage in production
- Create currency utilities for proper handling of non-centesimal currencies
- Replace unsafe 'any' types with type-safe ProviderData wrapper
- Add comprehensive input validation for amounts, currencies, and descriptions
- Set default Stripe API version for consistency

📦 New Features:
- Currency conversion utilities supporting JPY, KRW, and other special cases
- Type-safe provider data structure with metadata
- Enhanced validation functions for payment data

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 18:38:44 +02:00
209b683a8a refactor: Extract common provider utilities to reduce duplication
- Create shared utilities module for payment providers
- Add webhook response helpers for consistent API responses
- Extract common database operations (find payment, update status)
- Implement shared invoice update logic
- Add consistent error handling and logging utilities
- Refactor Mollie provider to use shared utilities
- Refactor Stripe provider to use shared utilities
- Remove duplicate code between providers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 18:24:45 +02:00
d08bb221ec feat: Add Stripe provider implementation with webhook support
- Implement Stripe payment provider with PaymentIntent creation
- Add webhook handler with signature verification and event processing
- Handle payment status updates and refund events
- Move Stripe to peer dependencies for better compatibility
- Update README with peer dependency installation instructions
- Document new provider configuration patterns and webhook endpoints

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 23:27:13 +02:00
9fbc720d6a feat: Expand Mollie provider to handle dynamic webhooks and update payment/invoice statuses
- Add webhook handling for Mollie payment status updates
- Map Mollie payment
2025-09-16 23:02:04 +02:00
2aad0d2538 feat: Add support for provider-level configuration in billing plugin
- Introduce `onConfig` callback for payment providers
- Add dynamic endpoint registration for Mollie webhook handling
- Remove unused provider-specific configurations from plugin types
- Update initialization to include provider-level configurations
2025-09-16 22:55:30 +02:00
Bas
6dd419c745 Merge pull request #6 from xtr-dev/dev
Dev
2025-09-16 22:15:43 +02:00
e3a58fe6bc feat: Add Mollie payment provider support
- Introduce `mollieProvider` for handling Mollie payments
- Add configurable payment hooks for initialization and processing
- Implement `initPayment` logic to create Mollie payments and update metadata
- Include types for Mollie integration in payments and refunds
- Update `package.json` to include `@mollie/api-client` dependency
- Refactor existing payment-related types into modular files for better maintainability
2025-09-16 22:10:47 +02:00
0308e30ebd refactor: Replace hardcoded billing data seeding with plugin-configurable collection overrides
- Remove `seedBillingData` function for sample data creation
- Update refunds, invoices, and payments collections to use pluginConfig for dynamic overrides
- Introduce utility functions like `extractSlug` for customizable collection slugs
- Streamline customer relation and data extractor logic across collections
2025-09-16 00:06:18 +02:00
f17b4c064e chore: Remove unused billing-related collections, types, and utility modules
- Drop `customers` collection and associated types (`types/index.ts`, `payload.ts`)
- Remove generated `payload-types.ts` file
- Clean up unused exports and dependencies across modules
- Streamline codebase by eliminating redundant billing logic
2025-09-15 23:14:25 +02:00
28e9e8d208 docs: Update CLAUDE.md to reflect current implementation
- Remove outdated payment provider and testing information
- Focus on current customer data management features
- Document customer info extractor pattern and usage
- Include clear configuration examples
- Remove references to unimplemented features

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 21:40:41 +02:00
Bas
3cb2b33b6e Merge pull request #4 from xtr-dev/dev
Dev
2025-09-15 21:39:47 +02:00
c14299e1fb fix: Address validation and consistency issues
- Restore missing customers collection import and creation
- Fix required field validation: customerInfo fields only required when no extractor
- Fix linting warnings in webhook handler
- Ensure consistent typing across all interfaces

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 21:17:29 +02:00
5f8fee33bb refactor: Remove unused createCustomersCollection export and related usage
- Eliminate `createCustomersCollection` from collections and main index files
- Update `config.collections` logic to remove customer collection dependency
2025-09-15 21:15:18 +02:00
a340e5d9e7 refactor: Replace conditional fields with customer info extractor callback
- Add CustomerInfoExtractor callback type for flexible customer data extraction
- Implement automatic customer info sync via beforeChange hook
- Make customer info fields read-only when using extractor
- Add defaultCustomerInfoExtractor for built-in customer collection
- Update validation to require customer selection when using extractor
- Keep customer info in sync when relationship changes

Breaking change: Plugin users must now provide customerInfoExtractor callback
to enable customer relationship syncing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 21:11:42 +02:00
7fb45570a7 chore: Remove unused utility modules and related test files
- Remove currency, logger, validation utilities, and base/test provider logic
- Delete associated tests and TypeScript definitions for deprecated modules
- Clean up exports in `src/utils` to reflect module removals
2025-09-15 21:07:22 +02:00
b3368ba34f fix: Improve invoice customer data handling and validation
- Make customerInfo fields conditionally required based on customer relationship
- Add admin UI conditional visibility to hide embedded fields when relationship exists
- Fix address field naming inconsistency (postal_code -> postalCode)
- Update types to properly reflect optional customerInfo/billingAddress
- Add validation to ensure either customer relationship or embedded info is provided

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 21:04:35 +02:00
c561dcb026 feat: Add embedded customer info to invoices with configurable relationship
- Add customerInfo and billingAddress fields to invoice collection
- Make customer relationship optional and configurable via plugin config
- Update TypeScript types to reflect new invoice structure
- Allow disabling customer relationship with customerRelation: false

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 20:55:25 +02:00
Bas
d97cac4c38 Merge pull request #2 from xtr-dev/dev
Disable tests temporarily, update .gitignore, and simplify README con…
2025-09-14 23:29:25 +02:00
2c5459e457 Bump package version to 0.1.1 2025-09-14 23:29:16 +02:00
e0a10213fa Disable tests temporarily, update .gitignore, and simplify README content 2025-09-14 23:24:57 +02:00
74f2c99c7c Update README, switch to SQLite, and adjust ESLint/perfectionist rules
- Simplified and clarified README structure and content.
- Replaced `mongooseAdapter` with `sqliteAdapter` for database configuration.
- Disabled all `perfectionist` ESLint rules for consistency.
- Updated `billingPlugin` import path in dev config.
2025-09-14 23:21:43 +02:00
6a49e00e7a Add GitHub Actions workflows for version check and NPM publish 2025-09-13 17:19:53 +02:00
Bas
069fb9d170 Merge pull request #1 from xtr-dev/add-claude-github-actions-1757776739010
Add Claude Code GitHub Workflow
2025-09-13 17:19:17 +02:00
48 changed files with 3580 additions and 2524 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:

43
.github/workflows/pr-version-check.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: PR Version Check
on:
pull_request:
branches:
- main
types: [opened, synchronize]
jobs:
version-check:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get PR branch package.json version
id: pr-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Get main branch package.json version
id: main-version
run: |
git checkout main
echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Compare versions
run: |
PR_VERSION="${{ steps.pr-version.outputs.version }}"
MAIN_VERSION="${{ steps.main-version.outputs.version }}"
echo "PR branch version: $PR_VERSION"
echo "Main branch version: $MAIN_VERSION"
if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
echo "❌ Version must be updated in package.json"
echo "Current version: $MAIN_VERSION"
echo "Please increment the version number before merging to main"
exit 1
else
echo "✅ Version has been updated from $MAIN_VERSION to $PR_VERSION"
fi

View File

@@ -0,0 +1,49 @@
name: Publish to NPM
on:
push:
branches:
- main
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
- name: Run build
run: pnpm build
- name: Get package version
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Create and push git tag
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git tag -a "v${{ steps.package-version.outputs.version }}" -m "Release v${{ steps.package-version.outputs.version }}"
git push origin "v${{ steps.package-version.outputs.version }}"
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

@@ -97,4 +97,5 @@ tmp/
temp/
# Local development
.local
.local
/dev/payload.sqlite

232
CLAUDE.md
View File

@@ -2,161 +2,165 @@
## Project Overview
This is a PayloadCMS plugin that provides billing and payment functionality with multiple payment provider integrations (Stripe, Mollie) and a test payment provider for local development.
This is a PayloadCMS plugin that provides billing and payment functionality with flexible customer data management and invoice generation capabilities.
## Architecture Principles
### Core Design
- **Provider Abstraction**: All payment providers implement a common interface for consistency
- **TypeScript First**: Full TypeScript support with strict typing throughout
- **PayloadCMS Integration**: Deep integration with Payload collections, hooks, and admin UI
- **Extensible**: Easy to add new payment providers through the common interface
- **Developer Experience**: Comprehensive testing tools and local development support
### Payment Provider Interface
All payment providers must implement the `PaymentProvider` interface:
```typescript
interface PaymentProvider {
createPayment(options: CreatePaymentOptions): Promise<Payment>
retrievePayment(id: string): Promise<Payment>
cancelPayment(id: string): Promise<Payment>
refundPayment(id: string, amount?: number): Promise<Refund>
handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
}
```
- **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
- **Invoices**: Invoice generation and management
- **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/
├── providers/ # Payment provider implementations
│ ├── stripe/ # Stripe integration
│ ├── mollie/ # Mollie integration
│ ├── test/ # Test provider for development
│ └── base/ # Base provider interface and utilities
├── collections/ # PayloadCMS collection configurations
├── endpoints/ # API endpoints (webhooks, etc.)
├── hooks/ # PayloadCMS lifecycle hooks
├── admin/ # Admin UI components and extensions
├── types/ # TypeScript type definitions
└── utils/ # Shared utilities and helpers
└── index.ts # Main plugin entry point
```
## Development Guidelines
## Customer Data Management
### Payment Provider Development
1. **Implement Base Interface**: All providers must implement `PaymentProvider`
2. **Error Handling**: Use consistent error types and proper error propagation
3. **Webhook Security**: Always verify webhook signatures and implement replay protection
4. **Idempotency**: Support idempotent operations where possible
5. **Logging**: Use structured logging for debugging and monitoring
### Customer Info Extractor Pattern
### Testing Strategy
- **Unit Tests**: Test individual provider methods and utilities
- **Integration Tests**: Test provider integrations with mock APIs
- **E2E Tests**: Test complete payment flows using test provider
- **Webhook Tests**: Test webhook handling with various scenarios
The plugin uses a callback-based approach to extract customer information from customer relationships:
### TypeScript Guidelines
- Use strict TypeScript configuration
- Define proper interfaces for all external API responses
- Use discriminated unions for provider-specific data
- Implement proper generic types for extensibility
### PayloadCMS Integration
- Follow PayloadCMS plugin patterns and conventions
- Use proper collection configurations with access control
- Implement admin UI components using PayloadCMS patterns
- Utilize PayloadCMS hooks for business logic
### Security Considerations
- **Webhook Verification**: Always verify webhook signatures
- **API Key Storage**: Use environment variables for sensitive data
- **Access Control**: Implement proper PayloadCMS access control
- **Input Validation**: Validate all inputs and sanitize data
- **Audit Logging**: Log all payment operations for audit trails
## Environment Configuration
### Required Environment Variables
```bash
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Mollie Configuration
MOLLIE_API_KEY=test_...
MOLLIE_WEBHOOK_URL=https://yourapp.com/api/billing/webhooks/mollie
# Test Provider Configuration
NODE_ENV=development # Enables test provider
```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,
}
})
```
### Development Setup
1. Use test provider for local development
2. Configure webhook forwarding tools (ngrok, etc.) for local webhook testing
3. Use provider sandbox/test modes during development
4. Implement comprehensive logging for debugging
### 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({
providers: {
// Provider configurations
},
collections: {
// Collection name overrides
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
},
webhooks: {
// Webhook configuration
},
admin: {
// Admin UI configuration
}
customerInfoExtractor: defaultCustomerInfoExtractor, // For built-in customer collection
})
```
### Advanced Configuration
- Custom collection schemas
- Provider-specific options
- Webhook endpoint customization
- Admin UI customization
### 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,
}
})
})
```
## Error Handling Strategy
## Development Guidelines
### Provider Errors
- Map provider-specific errors to common error types
- Preserve original error information for debugging
- Implement proper retry logic for transient failures
### TypeScript Guidelines
- Use strict TypeScript configuration
- All customer info extractors must implement `CustomerInfoExtractor` interface
- Ensure consistent camelCase naming for all address fields
### Webhook Errors
- Handle duplicate webhooks gracefully
- Implement proper error responses for webhook failures
- Log webhook processing errors with context
### 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
- Implement proper caching where appropriate
- Use database indexes for payment queries
- Optimize webhook processing for high throughput
- Consider rate limiting for API endpoints
## Monitoring and Observability
- Log all payment operations with structured data
- Track payment success/failure rates
- Monitor webhook processing times
- Implement health check endpoints
- 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 integration guides for each payment provider
- Include troubleshooting guides for common issues
- Provide clear customer info extractor examples
- Include configuration guides for different use cases
- Maintain up-to-date TypeScript documentation

302
README.md
View File

@@ -1,195 +1,223 @@
# 💳 @xtr-dev/payload-billing
# @xtr-dev/payload-billing
PayloadCMS plugin for billing and payment provider integrations with comprehensive tracking and local testing support.
[![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.
## 🚀 Features
⚠️ **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.
### Payment Providers
- **🔶 Stripe Integration** - Full Stripe payment processing support
- **🟠 Mollie Integration** - Complete Mollie payment gateway integration
- **🧪 Test Provider** - Local development and testing payment provider
- **🔧 Extensible Architecture** - Easy to add new payment providers
## Features
### Payment Tracking & Management
- **📊 Transaction History** - Complete payment tracking and history
- **🔄 Payment Status Management** - Real-time payment status updates
- **💰 Amount & Currency Handling** - Multi-currency support
- **📋 Invoice Generation** - Automatic invoice creation and management
- **🏷️ Metadata Support** - Custom metadata for payments and customers
- 💳 Multiple payment providers (Stripe, Mollie, Test)
- 🧾 Invoice generation and management with embedded customer info
- 👥 Flexible customer data management with relationship support
- 📊 Complete payment tracking and history
- 🪝 Secure webhook processing for all providers
- 🧪 Built-in test provider for local development
- 📱 Payment management in PayloadCMS admin
- 🔄 Callback-based customer data syncing
- 🔒 Full TypeScript support
### Developer Experience
- **🛠️ Local Development** - Test provider for local development
- **🪝 Webhook Handling** - Robust webhook processing for all providers
- **📝 TypeScript Support** - Full TypeScript definitions and type safety
- **🔍 Debugging Tools** - Built-in logging and debugging capabilities
- **📚 Documentation** - Comprehensive API documentation
### PayloadCMS Integration
- **⚡ Admin UI Extensions** - Payment management directly in Payload admin
- **🗃️ Collections** - Pre-configured payment, customer, and invoice collections
- **🔐 Access Control** - Secure payment data with proper permissions
- **🎯 Hooks & Events** - PayloadCMS lifecycle hooks for payment events
## 🏗️ Installation
## Installation
```bash
npm install @xtr-dev/payload-billing
# or
yarn add @xtr-dev/payload-billing
# or
pnpm add @xtr-dev/payload-billing
# or
yarn add @xtr-dev/payload-billing
```
## ⚙️ Quick Setup
### Provider Dependencies
Payment providers are peer dependencies and must be installed separately based on which providers you plan to use:
```bash
# For Stripe support
npm install stripe
# or
pnpm add stripe
# For Mollie support
npm install @mollie/api-client
# or
pnpm add @mollie/api-client
```
## Quick Start
### Basic Configuration
```typescript
import { billingPlugin } from '@xtr-dev/payload-billing'
import { buildConfig } from 'payload'
import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
export default buildConfig({
// ... your config
plugins: [
billingPlugin({
providers: {
stripe: {
providers: [
stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
webhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!,
},
mollie: {
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
mollieProvider({
apiKey: process.env.MOLLIE_API_KEY!,
webhookUrl: process.env.MOLLIE_WEBHOOK_URL!,
},
// Test provider for local development
test: {
enabled: process.env.NODE_ENV === 'development',
autoComplete: true, // Automatically complete payments
}
},
webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
}),
],
collections: {
payments: 'payments',
customers: 'customers',
invoices: 'invoices',
refunds: 'refunds',
}
})
]
})
```
## 📋 Collections Added
### With Customer Management
### Payments Collection
- Payment tracking with status, amount, currency
- Provider-specific payment data
- Customer relationships
- Transaction metadata
### Customers Collection
- Customer information and billing details
- Payment method storage
- Transaction history
- Subscription management
### Invoices Collection
- Invoice generation and management
- PDF generation support
- Payment status tracking
- Line item details
## 🔌 Provider APIs
### Stripe Integration
```typescript
import { getPaymentProvider } from '@xtr-dev/payload-billing'
import { billingPlugin, CustomerInfoExtractor } from '@xtr-dev/payload-billing'
const stripe = getPaymentProvider('stripe')
// Create a payment
const payment = await stripe.createPayment({
amount: 2000, // $20.00
currency: 'usd',
customer: 'customer_id',
metadata: { orderId: '12345' }
// 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,
}
})
// Handle webhooks
await stripe.handleWebhook(request, signature)
```
### Mollie Integration
```typescript
const mollie = getPaymentProvider('mollie')
// Create a payment
const payment = await mollie.createPayment({
amount: { value: '20.00', currency: 'EUR' },
description: 'Order #12345',
redirectUrl: 'https://example.com/return',
webhookUrl: 'https://example.com/webhook'
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
// Main plugin
import { billingPlugin } from '@xtr-dev/payload-billing'
// Payment providers
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
// Types
import type {
PaymentProvider,
Payment,
Invoice,
Refund,
BillingPluginConfig,
CustomerInfoExtractor,
MollieProviderConfig,
StripeProviderConfig,
ProviderData
} from '@xtr-dev/payload-billing'
```
## Provider Types
### Stripe
Credit card payments, subscriptions, webhook processing, automatic payment method storage.
### Mollie
European payment methods (iDEAL, SEPA, etc.), multi-currency support, refund processing.
### Test Provider
```typescript
const testProvider = getPaymentProvider('test')
Local development testing with configurable scenarios, automatic completion, debug mode.
// Simulate payment scenarios
const payment = await testProvider.createPayment({
amount: 2000,
currency: 'usd',
// Test-specific options
simulateFailure: false,
delayMs: 1000
})
```
## Collections
## 🪝 Webhook Handling
The plugin adds these collections:
The plugin automatically sets up webhook endpoints for all configured providers:
- **payments** - Payment transactions with status and provider data
- **invoices** - Invoice generation with line items and embedded customer info
- **refunds** - Refund tracking and management
- `/api/billing/webhooks/stripe` - Stripe webhook endpoint
- `/api/billing/webhooks/mollie` - Mollie webhook endpoint
- `/api/billing/webhooks/test` - Test provider webhook endpoint
### Customer Data Management
## 🛠️ Development
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
## Webhook Endpoints
Automatic webhook endpoints are created for configured providers:
- `/api/payload-billing/stripe/webhook` - Stripe payment notifications
- `/api/payload-billing/mollie/webhook` - Mollie payment notifications
## Requirements
- PayloadCMS ^3.37.0
- Node.js ^18.20.2 || >=20.9.0
- pnpm ^9 || ^10
## Development
```bash
# Clone the repository
git clone https://github.com/xtr-dev/payload-billing.git
cd payload-billing
# Install dependencies
pnpm install
# Build the plugin
# Build plugin
pnpm build
# Run tests
pnpm test
# Start development server
# Development server
pnpm dev
```
## 📚 Documentation
## License
- [API Reference](./docs/api.md)
- [Provider Integration Guide](./docs/providers.md)
- [Webhook Setup](./docs/webhooks.md)
- [Testing Guide](./docs/testing.md)
- [TypeScript Types](./docs/types.md)
## 🤝 Contributing
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
## 📄 License
MIT License - see [LICENSE](LICENSE) file for details.
## 🔗 Links
- [PayloadCMS](https://payloadcms.com)
- [Stripe Documentation](https://stripe.com/docs)
- [Mollie Documentation](https://docs.mollie.com)
- [GitHub Repository](https://github.com/xtr-dev/payload-billing)
MIT

View File

@@ -1,11 +1,13 @@
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,
})
return Response.json({
message: 'This is an example of a custom route.',
})

View File

@@ -70,7 +70,6 @@ export interface Config {
posts: Post;
media: Media;
payments: Payment;
customers: Customer;
invoices: Invoice;
refunds: Refund;
users: User;
@@ -83,7 +82,6 @@ export interface Config {
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
payments: PaymentsSelect<false> | PaymentsSelect<true>;
customers: CustomersSelect<false> | CustomersSelect<true>;
invoices: InvoicesSelect<false> | InvoicesSelect<true>;
refunds: RefundsSelect<false> | RefundsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
@@ -92,7 +90,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
@@ -128,7 +126,7 @@ export interface UserAuthOperations {
* via the `definition` "posts".
*/
export interface Post {
id: string;
id: number;
updatedAt: string;
createdAt: string;
}
@@ -137,7 +135,7 @@ export interface Post {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -155,12 +153,12 @@ export interface Media {
* via the `definition` "payments".
*/
export interface Payment {
id: string;
id: number;
provider: 'stripe' | 'mollie' | 'test';
/**
* The payment ID from the payment provider
*/
providerId: string;
providerId?: string | null;
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
/**
* Amount in cents (e.g., 2000 = $20.00)
@@ -174,8 +172,7 @@ export interface Payment {
* Payment description
*/
description?: string | null;
customer?: (string | null) | Customer;
invoice?: (string | null) | Invoice;
invoice?: (number | null) | Invoice;
/**
* Additional metadata for the payment
*/
@@ -200,71 +197,8 @@ export interface Payment {
| number
| boolean
| null;
refunds?: (string | Refund)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers".
*/
export interface Customer {
id: string;
/**
* Customer email address
*/
email?: string | null;
/**
* Customer full name
*/
name?: string | null;
/**
* Customer phone number
*/
phone?: string | null;
address?: {
line1?: string | null;
line2?: string | null;
city?: string | null;
state?: string | null;
postal_code?: string | null;
/**
* ISO 3166-1 alpha-2 country code
*/
country?: string | null;
};
/**
* Customer IDs from payment providers
*/
providerIds?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Additional customer metadata
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Customer payments
*/
payments?: (string | Payment)[] | null;
/**
* Customer invoices
*/
invoices?: (string | Invoice)[] | null;
refunds?: (number | Refund)[] | null;
version?: number | null;
updatedAt: string;
createdAt: string;
}
@@ -273,12 +207,62 @@ export interface Customer {
* via the `definition` "invoices".
*/
export interface Invoice {
id: string;
id: number;
/**
* Invoice number (e.g., INV-001)
*/
number: string;
customer: string | Customer;
/**
* Customer billing information
*/
customerInfo: {
/**
* Customer name
*/
name: string;
/**
* Customer email address
*/
email: string;
/**
* Customer phone number
*/
phone?: string | null;
/**
* Company name (optional)
*/
company?: string | null;
/**
* Tax ID or VAT number
*/
taxId?: string | null;
};
/**
* Billing address
*/
billingAddress: {
/**
* Address line 1
*/
line1: string;
/**
* Address line 2
*/
line2?: string | null;
city: string;
/**
* State or province
*/
state?: string | null;
/**
* Postal or ZIP code
*/
postalCode: string;
/**
* Country code (e.g., US, GB)
*/
country: string;
};
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
/**
* ISO 4217 currency code (e.g., USD, EUR)
@@ -311,7 +295,7 @@ export interface Invoice {
amount?: number | null;
dueDate?: string | null;
paidAt?: string | null;
payment?: (string | null) | Payment;
payment?: (number | null) | Payment;
/**
* Internal notes
*/
@@ -336,12 +320,12 @@ export interface Invoice {
* via the `definition` "refunds".
*/
export interface Refund {
id: string;
id: number;
/**
* The refund ID from the payment provider
*/
providerId: string;
payment: string | Payment;
payment: number | Payment;
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled';
/**
* Refund amount in cents
@@ -391,7 +375,7 @@ export interface Refund {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -408,40 +392,36 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'payments';
value: string | Payment;
} | null)
| ({
relationTo: 'customers';
value: string | Customer;
value: number | Payment;
} | null)
| ({
relationTo: 'invoices';
value: string | Invoice;
value: number | Invoice;
} | null)
| ({
relationTo: 'refunds';
value: string | Refund;
value: number | Refund;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -451,10 +431,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -474,7 +454,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -516,36 +496,11 @@ export interface PaymentsSelect<T extends boolean = true> {
amount?: T;
currency?: T;
description?: T;
customer?: T;
invoice?: T;
metadata?: T;
providerData?: T;
refunds?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers_select".
*/
export interface CustomersSelect<T extends boolean = true> {
email?: T;
name?: T;
phone?: T;
address?:
| T
| {
line1?: T;
line2?: T;
city?: T;
state?: T;
postal_code?: T;
country?: T;
};
providerIds?: T;
metadata?: T;
payments?: T;
invoices?: T;
version?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -555,7 +510,25 @@ export interface CustomersSelect<T extends boolean = true> {
*/
export interface InvoicesSelect<T extends boolean = true> {
number?: T;
customer?: T;
customerInfo?:
| T
| {
name?: T;
email?: T;
phone?: T;
company?: T;
taxId?: T;
};
billingAddress?:
| T
| {
line1?: T;
line2?: T;
city?: T;
state?: T;
postalCode?: T;
country?: T;
};
status?: T;
currency?: T;
items?:

View File

@@ -1,14 +1,14 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import { buildConfig } from 'payload'
import { billingPlugin } from '../src/index.js'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed } from './seed.js'
import { testEmailAdapter } from './helpers/testEmailAdapter'
import { seed } from './seed'
import billingPlugin from '../src/plugin'
import { testProvider } from '../src/providers'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -17,18 +17,7 @@ if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname
}
const buildConfigWithMemoryDB = async () => {
if (process.env.NODE_ENV === 'test') {
const memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 3,
dbName: 'payloadmemory',
},
})
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
}
const buildConfigWithSQLite = () => {
return buildConfig({
admin: {
importMap: {
@@ -48,9 +37,10 @@ const buildConfigWithMemoryDB = async () => {
},
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
db: sqliteAdapter({
client: {
url: `file:${path.resolve(dirname, 'payload.sqlite')}`,
},
}),
editor: lexicalEditor(),
email: testEmailAdapter,
@@ -59,18 +49,21 @@ const buildConfigWithMemoryDB = async () => {
},
plugins: [
billingPlugin({
providers: {
test: {
providers: [
testProvider({
enabled: true,
autoComplete: true,
}
},
testModeIndicators: {
showWarningBanners: true,
showTestBadges: true,
consoleWarnings: true
}
})
],
collections: {
payments: 'payments',
customers: 'customers',
invoices: 'invoices',
refunds: 'refunds',
}
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
@@ -81,4 +74,4 @@ const buildConfigWithMemoryDB = async () => {
})
}
export default buildConfigWithMemoryDB()
export default buildConfigWithSQLite()

View File

@@ -1,6 +1,6 @@
import type { Payload } from 'payload'
import { devUser } from './helpers/credentials.js'
import { devUser } from './helpers/credentials'
export const seed = async (payload: Payload) => {
// Seed default user first
@@ -21,129 +21,9 @@ 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...')
try {
// Check if we already have sample data
const existingCustomers = await payload.count({
collection: 'customers',
where: {
email: {
equals: 'john.doe@example.com',
},
},
})
if (existingCustomers.totalDocs > 0) {
payload.logger.info('Sample billing data already exists, skipping seed')
return
}
// Create a sample customer
const customer = await payload.create({
collection: 'customers',
data: {
email: 'john.doe@example.com',
name: 'John Doe',
phone: '+1-555-0123',
address: {
line1: '123 Main St',
city: 'New York',
state: 'NY',
postal_code: '10001',
country: 'US'
},
metadata: {
source: 'seed',
created_by: 'system'
}
}
})
payload.logger.info(`Created sample customer: ${customer.id}`)
// Create a sample invoice
const invoice = await payload.create({
collection: 'invoices',
data: {
number: 'INV-001-SAMPLE',
customer: customer.id,
currency: 'USD',
items: [
{
description: 'Web Development Services',
quantity: 10,
unitAmount: 5000, // $50.00 per hour
totalAmount: 50000 // $500.00 total
},
{
description: 'Design Consultation',
quantity: 2,
unitAmount: 7500, // $75.00 per hour
totalAmount: 15000 // $150.00 total
}
],
subtotal: 65000, // $650.00
taxAmount: 5200, // $52.00 (8% tax)
amount: 70200, // $702.00 total
status: 'open',
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
notes: 'Payment terms: Net 30 days. This is sample data for development.',
metadata: {
project: 'website-redesign',
billable_hours: 12,
sample: true
}
}
})
payload.logger.info(`Created sample invoice: ${invoice.number}`)
// Create a sample payment using test provider
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'test',
providerId: `test_pay_sample_${Date.now()}`,
status: 'succeeded',
amount: 70200, // $702.00
currency: 'USD',
description: `Sample payment for invoice ${invoice.number}`,
customer: customer.id,
invoice: invoice.id,
metadata: {
invoice_number: invoice.number,
payment_method: 'test_card',
sample: true
},
providerData: {
testMode: true,
simulatedPayment: true,
autoCompleted: true
}
}
})
payload.logger.info(`Created sample payment: ${payment.id}`)
// Update invoice status to paid
await payload.update({
collection: 'invoices',
id: invoice.id,
data: {
status: 'paid',
payment: payment.id,
paidAt: new Date().toISOString()
}
})
payload.logger.info('Billing sample data seeded successfully!')
} catch (error) {
payload.logger.error('Error seeding billing data:', error)
}
}
// async function seedBillingData(payload: Payload): Promise<void> {
// payload.logger.info('Seeding billing sample data...')
// }

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

@@ -27,6 +27,24 @@ export default [
{
rules: {
'no-restricted-exports': 'off',
// Disable all perfectionist rules
'perfectionist/sort-array-includes': 'off',
'perfectionist/sort-classes': 'off',
'perfectionist/sort-enums': 'off',
'perfectionist/sort-exports': 'off',
'perfectionist/sort-imports': 'off',
'perfectionist/sort-interfaces': 'off',
'perfectionist/sort-jsx-props': 'off',
'perfectionist/sort-maps': 'off',
'perfectionist/sort-named-exports': 'off',
'perfectionist/sort-named-imports': 'off',
'perfectionist/sort-object-types': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-sets': 'off',
'perfectionist/sort-switch-case': 'off',
'perfectionist/sort-union-types': 'off',
'perfectionist/sort-variable-declarations': 'off',
'perfectionist/sort-intersection-types': 'off',
},
},
{

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-billing",
"version": "0.1.0",
"version": "0.1.12",
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT",
"type": "module",
@@ -41,7 +41,7 @@
"lint": "eslint",
"lint:fix": "eslint ./src --fix",
"prepublishOnly": "pnpm clean && pnpm build",
"test": "pnpm test:int && pnpm test:e2e",
"test": "echo 'Tests disabled for now'",
"test:e2e": "playwright test",
"test:int": "vitest",
"typecheck": "tsc --noEmit",
@@ -70,6 +70,7 @@
"devDependencies": {
"@changesets/cli": "^2.27.1",
"@eslint/eslintrc": "^3.2.0",
"@mollie/api-client": "^3.7.0",
"@payloadcms/db-mongodb": "3.37.0",
"@payloadcms/db-postgres": "3.37.0",
"@payloadcms/db-sqlite": "3.37.0",
@@ -99,16 +100,17 @@
"rimraf": "3.0.2",
"sharp": "0.34.2",
"sort-package-json": "^2.10.0",
"stripe": "^18.5.0",
"typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2"
},
"peerDependencies": {
"payload": "^3.37.0"
"@mollie/api-client": "^3.7.0 || ^4.0.0",
"payload": "^3.37.0",
"stripe": "^18.5.0"
},
"dependencies": {
"stripe": "^14.15.0",
"@mollie/api-client": "^3.7.0",
"zod": "^3.22.4"
},
"engines": {

File diff suppressed because one or more lines are too long

32
pnpm-lock.yaml generated
View File

@@ -8,12 +8,6 @@ importers:
.:
dependencies:
'@mollie/api-client':
specifier: ^3.7.0
version: 3.7.0
stripe:
specifier: ^14.15.0
version: 14.25.0
zod:
specifier: ^3.22.4
version: 3.25.76
@@ -24,6 +18,9 @@ importers:
'@eslint/eslintrc':
specifier: ^3.2.0
version: 3.3.1
'@mollie/api-client':
specifier: ^3.7.0
version: 3.7.0
'@payloadcms/db-mongodb':
specifier: 3.37.0
version: 3.37.0(payload@3.37.0(graphql@16.11.0)(typescript@5.7.3))
@@ -111,6 +108,9 @@ importers:
sort-package-json:
specifier: ^2.10.0
version: 2.15.1
stripe:
specifier: ^18.5.0
version: 18.5.0(@types/node@22.18.1)
typescript:
specifier: 5.7.3
version: 5.7.3
@@ -5165,9 +5165,14 @@ packages:
strip-literal@3.0.0:
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
stripe@14.25.0:
resolution: {integrity: sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==}
stripe@18.5.0:
resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==}
engines: {node: '>=12.*'}
peerDependencies:
'@types/node': '>=12.x.x'
peerDependenciesMeta:
'@types/node':
optional: true
strtok3@10.3.4:
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
@@ -8875,7 +8880,7 @@ snapshots:
eslint: 9.35.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0)
eslint-plugin-react: 7.37.5(eslint@9.35.0)
eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0)
@@ -8909,7 +8914,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0)
eslint-plugin-import-x: 4.4.2(eslint@9.35.0)(typescript@5.7.3)
transitivePeerDependencies:
- supports-color
@@ -8960,7 +8965,7 @@ snapshots:
- typescript
optional: true
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -11434,10 +11439,11 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@14.25.0:
stripe@18.5.0(@types/node@22.18.1):
dependencies:
'@types/node': 22.18.1
qs: 6.14.0
optionalDependencies:
'@types/node': 22.18.1
strtok3@10.3.4:
dependencies:

View File

@@ -1,283 +0,0 @@
import type { TestProviderConfig} from '../types';
import { TestPaymentProvider } from '../providers/test/provider'
import { PaymentStatus } from '../types'
describe('TestPaymentProvider', () => {
let provider: TestPaymentProvider
let config: TestProviderConfig
beforeEach(() => {
config = {
autoComplete: true,
defaultDelay: 0,
enabled: true,
}
provider = new TestPaymentProvider(config)
})
afterEach(() => {
provider.clearStoredData()
})
describe('createPayment', () => {
it('should create a payment with succeeded status when autoComplete is true', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
description: 'Test payment',
})
expect(payment).toMatchObject({
amount: 2000,
currency: 'USD',
description: 'Test payment',
provider: 'test',
status: 'succeeded',
})
expect(payment.id).toBeDefined()
expect(payment.createdAt).toBeDefined()
expect(payment.updatedAt).toBeDefined()
expect(payment.providerData?.testMode).toBe(true)
})
it('should create a payment with pending status when autoComplete is false', async () => {
config.autoComplete = false
provider = new TestPaymentProvider(config)
const payment = await provider.createPayment({
amount: 1500,
currency: 'EUR',
})
expect(payment).toMatchObject({
amount: 1500,
currency: 'EUR',
status: 'pending',
})
})
it('should create a failed payment when simulateFailure is true', async () => {
const payment = await provider.createPayment({
amount: 1000,
currency: 'USD',
metadata: {
test: { simulateFailure: true },
},
})
expect(payment.status).toBe('failed')
expect(payment.providerData?.simulatedFailure).toBe(true)
})
it('should apply delay when specified', async () => {
const startTime = Date.now()
await provider.createPayment({
amount: 1000,
currency: 'USD',
metadata: {
test: { delayMs: 100 },
},
})
const endTime = Date.now()
expect(endTime - startTime).toBeGreaterThanOrEqual(100)
})
it('should store payment data', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const stored = provider.getStoredPayment(payment.id)
expect(stored).toEqual(payment)
})
})
describe('retrievePayment', () => {
it('should retrieve an existing payment', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const retrieved = await provider.retrievePayment(payment.id)
expect(retrieved).toEqual(payment)
})
it('should throw error for non-existent payment', async () => {
await expect(provider.retrievePayment('non-existent')).rejects.toThrow(
'Payment non-existent not found'
)
})
})
describe('cancelPayment', () => {
it('should cancel a pending payment', async () => {
config.autoComplete = false
provider = new TestPaymentProvider(config)
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const canceled = await provider.cancelPayment(payment.id)
expect(canceled.status).toBe('canceled')
expect(canceled.updatedAt).not.toBe(payment.updatedAt)
})
it('should not cancel a succeeded payment', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
await expect(provider.cancelPayment(payment.id)).rejects.toThrow(
'Cannot cancel a succeeded payment'
)
})
it('should throw error for non-existent payment', async () => {
await expect(provider.cancelPayment('non-existent')).rejects.toThrow(
'Payment non-existent not found'
)
})
})
describe('refundPayment', () => {
it('should create a full refund for succeeded payment', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const refund = await provider.refundPayment(payment.id)
expect(refund).toMatchObject({
amount: 2000,
currency: 'USD',
paymentId: payment.id,
status: 'succeeded',
})
expect(refund.id).toBeDefined()
expect(refund.createdAt).toBeDefined()
// Check payment status is updated
const updatedPayment = await provider.retrievePayment(payment.id)
expect(updatedPayment.status).toBe('refunded')
})
it('should create a partial refund', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const refund = await provider.refundPayment(payment.id, 1000)
expect(refund.amount).toBe(1000)
// Check payment status is updated to partially_refunded
const updatedPayment = await provider.retrievePayment(payment.id)
expect(updatedPayment.status).toBe('partially_refunded')
})
it('should not refund a non-succeeded payment', async () => {
config.autoComplete = false
provider = new TestPaymentProvider(config)
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
await expect(provider.refundPayment(payment.id)).rejects.toThrow(
'Can only refund succeeded payments'
)
})
it('should not refund more than payment amount', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
await expect(provider.refundPayment(payment.id, 3000)).rejects.toThrow(
'Refund amount cannot exceed payment amount'
)
})
})
describe('handleWebhook', () => {
it('should handle webhook event', async () => {
const mockRequest = {
text: () => Promise.resolve(JSON.stringify({
type: 'payment.succeeded',
data: { paymentId: 'test_pay_123' }
}))
} as Request
const event = await provider.handleWebhook(mockRequest)
expect(event).toMatchObject({
type: 'payment.succeeded',
data: { paymentId: 'test_pay_123' },
provider: 'test',
verified: true,
})
expect(event.id).toBeDefined()
})
it('should throw error for invalid JSON', async () => {
const mockRequest = {
text: () => Promise.resolve('invalid json')
} as Request
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
'Invalid JSON in webhook body'
)
})
it('should throw error when provider is disabled', async () => {
config.enabled = false
provider = new TestPaymentProvider(config)
const mockRequest = {
text: () => Promise.resolve('{}')
} as Request
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
'Test provider is not enabled'
)
})
})
describe('data management', () => {
it('should clear all stored data', async () => {
await provider.createPayment({ amount: 1000, currency: 'USD' })
expect(provider.getAllPayments()).toHaveLength(1)
provider.clearStoredData()
expect(provider.getAllPayments()).toHaveLength(0)
expect(provider.getAllRefunds()).toHaveLength(0)
})
it('should return all payments and refunds', async () => {
const payment1 = await provider.createPayment({ amount: 1000, currency: 'USD' })
const payment2 = await provider.createPayment({ amount: 2000, currency: 'EUR' })
const refund = await provider.refundPayment(payment1.id)
const payments = provider.getAllPayments()
const refunds = provider.getAllRefunds()
expect(payments).toHaveLength(2)
expect(refunds).toHaveLength(1)
expect(refunds[0]).toEqual(refund)
})
})
})

View File

@@ -1,149 +0,0 @@
import type { CollectionConfig } from 'payload'
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CustomerData,
CustomerDocument
} from '../types/payload'
export function createCustomersCollection(slug: string = 'customers'): CollectionConfig {
return {
slug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
update: ({ req: { user } }: AccessArgs) => !!user,
},
admin: {
defaultColumns: ['email', 'name', 'createdAt'],
group: 'Billing',
useAsTitle: 'email',
},
fields: [
{
name: 'email',
type: 'email',
admin: {
description: 'Customer email address',
},
index: true,
unique: true,
},
{
name: 'name',
type: 'text',
admin: {
description: 'Customer full name',
},
},
{
name: 'phone',
type: 'text',
admin: {
description: 'Customer phone number',
},
},
{
name: 'address',
type: 'group',
fields: [
{
name: 'line1',
type: 'text',
label: 'Address Line 1',
},
{
name: 'line2',
type: 'text',
label: 'Address Line 2',
},
{
name: 'city',
type: 'text',
label: 'City',
},
{
name: 'state',
type: 'text',
label: 'State/Province',
},
{
name: 'postal_code',
type: 'text',
label: 'Postal Code',
},
{
name: 'country',
type: 'text',
admin: {
description: 'ISO 3166-1 alpha-2 country code',
},
label: 'Country',
maxLength: 2,
},
],
},
{
name: 'providerIds',
type: 'json',
admin: {
description: 'Customer IDs from payment providers',
readOnly: true,
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional customer metadata',
},
},
{
name: 'payments',
type: 'relationship',
admin: {
description: 'Customer payments',
readOnly: true,
},
hasMany: true,
relationTo: 'payments',
},
{
name: 'invoices',
type: 'relationship',
admin: {
description: 'Customer invoices',
readOnly: true,
},
hasMany: true,
relationTo: 'invoices',
},
],
hooks: {
afterChange: [
({ doc, operation, req }: CollectionAfterChangeHook<CustomerDocument>) => {
if (operation === 'create') {
req.payload.logger.info(`Customer created: ${doc.id} (${doc.email})`)
}
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<CustomerData>) => {
if (operation === 'create' || operation === 'update') {
// Normalize country code
if (data.address?.country) {
data.address.country = data.address.country.toUpperCase()
if (!/^[A-Z]{2}$/.test(data.address.country)) {
throw new Error('Country must be a 2-letter ISO code')
}
}
}
},
],
},
timestamps: true,
}
}

13
src/collections/hooks.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { Payment } from '../plugin/types/index'
import type { Payload } from 'payload'
import { useBillingPlugin } from '../plugin/index'
export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
const billing = useBillingPlugin(payload)
if (!payment.provider || !billing.providerConfig[payment.provider]) {
throw new Error(`Provider ${payment.provider} not found.`)
}
// Handle both async and non-async initPayment functions
const result = billing.providerConfig[payment.provider].initPayment(payload, payment)
return await Promise.resolve(result)
}

View File

@@ -1,4 +1,3 @@
export { createCustomersCollection } from './customers'
export { createInvoicesCollection } from './invoices'
export { createPaymentsCollection } from './payments'
export { createRefundsCollection } from './refunds'
export { createRefundsCollection } from './refunds'

View File

@@ -1,18 +1,304 @@
import type { CollectionConfig } from 'payload'
import type {
import {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
InvoiceData,
InvoiceDocument,
InvoiceItemData
} from '../types/payload'
CollectionConfig, Field,
} from 'payload'
import type { BillingPluginConfig} from '../plugin/config';
import { defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import { createContextLogger } from '../utils/logger'
import type { Invoice } from '../plugin/types/invoices'
export function createInvoicesCollection(slug: string = 'invoices'): CollectionConfig {
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {}
let fields: Field[] = [
{
name: 'number',
type: 'text',
admin: {
description: 'Invoice number (e.g., INV-001)',
},
index: true,
required: true,
unique: true,
},
// Optional customer relationship
...(customerRelationSlug ? [{
name: 'customer',
type: 'relationship' as const,
admin: {
position: 'sidebar' as const,
description: 'Link to customer record (optional)',
},
relationTo: extractSlug(customerRelationSlug),
required: false,
}] : []),
// Basic customer info fields (embedded)
{
name: 'customerInfo',
type: 'group',
admin: {
description: customerRelationSlug && customerInfoExtractor
? 'Customer billing information (auto-populated from customer relationship)'
: 'Customer billing information',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
fields: [
{
name: 'name',
type: 'text',
admin: {
description: 'Customer name',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'email',
type: 'email',
admin: {
description: 'Customer email address',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'phone',
type: 'text',
admin: {
description: 'Customer phone number',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'company',
type: 'text',
admin: {
description: 'Company name (optional)',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'taxId',
type: 'text',
admin: {
description: 'Tax ID or VAT number',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
],
},
{
name: 'billingAddress',
type: 'group',
admin: {
description: customerRelationSlug && customerInfoExtractor
? 'Billing address (auto-populated from customer relationship)'
: 'Billing address',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
fields: [
{
name: 'line1',
type: 'text',
admin: {
description: 'Address line 1',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'line2',
type: 'text',
admin: {
description: 'Address line 2',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'city',
type: 'text',
admin: {
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'state',
type: 'text',
admin: {
description: 'State or province',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
},
{
name: 'postalCode',
type: 'text',
admin: {
description: 'Postal or ZIP code',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
required: !customerRelationSlug || !customerInfoExtractor,
},
{
name: 'country',
type: 'text',
admin: {
description: 'Country code (e.g., US, GB)',
readOnly: !!(customerRelationSlug && customerInfoExtractor),
},
maxLength: 2,
required: !customerRelationSlug || !customerInfoExtractor,
},
],
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Open', value: 'open' },
{ label: 'Paid', value: 'paid' },
{ label: 'Void', value: 'void' },
{ label: 'Uncollectible', value: 'uncollectible' },
],
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
defaultValue: 'USD',
maxLength: 3,
required: true,
},
{
name: 'items',
type: 'array',
admin: {
// Custom row labeling can be added here when needed
},
fields: [
{
name: 'description',
type: 'text',
admin: {
width: '40%',
},
required: true,
},
{
name: 'quantity',
type: 'number',
admin: {
width: '15%',
},
defaultValue: 1,
min: 1,
required: true,
},
{
name: 'unitAmount',
type: 'number',
admin: {
description: 'Amount in cents',
width: '20%',
},
min: 0,
required: true,
},
{
name: 'totalAmount',
type: 'number',
admin: {
description: 'Calculated: quantity × unitAmount',
readOnly: true,
width: '20%',
},
},
],
minRows: 1,
required: true,
},
{
name: 'subtotal',
type: 'number',
admin: {
description: 'Sum of all line items',
readOnly: true,
},
},
{
name: 'taxAmount',
type: 'number',
admin: {
description: 'Tax amount in cents',
},
defaultValue: 0,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Total amount (subtotal + tax)',
readOnly: true,
},
},
{
name: 'dueDate',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'paidAt',
type: 'date',
admin: {
condition: (data) => data.status === 'paid',
readOnly: true,
},
},
{
name: 'payment',
type: 'relationship',
admin: {
condition: (data) => data.status === 'paid',
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
},
{
name: 'notes',
type: 'textarea',
admin: {
description: 'Internal notes',
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional invoice metadata',
},
},
]
if (overrides?.fields) {
fields = overrides.fields({defaultFields: fields})
}
return {
slug,
slug: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
@@ -20,179 +306,59 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
update: ({ req: { user } }: AccessArgs) => !!user,
},
admin: {
defaultColumns: ['number', 'customer', 'status', 'amount', 'currency', 'dueDate'],
defaultColumns: ['number', 'customerInfo.name', 'status', 'amount', 'currency', 'dueDate'],
group: 'Billing',
useAsTitle: 'number',
},
fields: [
{
name: 'number',
type: 'text',
admin: {
description: 'Invoice number (e.g., INV-001)',
},
index: true,
required: true,
unique: true,
},
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'customers',
required: true,
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Open', value: 'open' },
{ label: 'Paid', value: 'paid' },
{ label: 'Void', value: 'void' },
{ label: 'Uncollectible', value: 'uncollectible' },
],
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
defaultValue: 'USD',
maxLength: 3,
required: true,
},
{
name: 'items',
type: 'array',
admin: {
// Custom row labeling can be added here when needed
},
fields: [
{
name: 'description',
type: 'text',
admin: {
width: '40%',
},
required: true,
},
{
name: 'quantity',
type: 'number',
admin: {
width: '15%',
},
defaultValue: 1,
min: 1,
required: true,
},
{
name: 'unitAmount',
type: 'number',
admin: {
description: 'Amount in cents',
width: '20%',
},
min: 0,
required: true,
},
{
name: 'totalAmount',
type: 'number',
admin: {
description: 'Calculated: quantity × unitAmount',
readOnly: true,
width: '20%',
},
},
],
minRows: 1,
required: true,
},
{
name: 'subtotal',
type: 'number',
admin: {
description: 'Sum of all line items',
readOnly: true,
},
},
{
name: 'taxAmount',
type: 'number',
admin: {
description: 'Tax amount in cents',
},
defaultValue: 0,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Total amount (subtotal + tax)',
readOnly: true,
},
},
{
name: 'dueDate',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'paidAt',
type: 'date',
admin: {
condition: (data: InvoiceData) => data.status === 'paid',
readOnly: true,
},
},
{
name: 'payment',
type: 'relationship',
admin: {
condition: (data: InvoiceData) => data.status === 'paid',
position: 'sidebar',
},
relationTo: 'payments',
},
{
name: 'notes',
type: 'textarea',
admin: {
description: 'Internal notes',
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional invoice metadata',
},
},
],
fields,
hooks: {
afterChange: [
({ doc, operation, req }: CollectionAfterChangeHook<InvoiceDocument>) => {
({ doc, operation, req }) => {
if (operation === 'create') {
req.payload.logger.info(`Invoice created: ${doc.number}`)
const logger = createContextLogger(req.payload, 'Invoices Collection')
logger.info(`Invoice created: ${doc.number}`)
}
},
],
] satisfies CollectionAfterChangeHook<Invoice>[],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<InvoiceData>) => {
async ({ data, operation, req, originalDoc }) => {
// Sync customer info from relationship if extractor is provided
if (customerRelationSlug && customerInfoExtractor && data.customer) {
// Check if customer changed or this is a new invoice
const customerChanged = operation === 'create' ||
(originalDoc && originalDoc.customer !== data.customer)
if (customerChanged) {
try {
// Fetch the customer data
const customer = await req.payload.findByID({
collection: customerRelationSlug as never,
id: data.customer as never,
})
// Extract customer info using the provided callback
const extractedInfo = customerInfoExtractor(customer)
// Update the invoice data with extracted info
data.customerInfo = {
name: extractedInfo.name,
email: extractedInfo.email,
phone: extractedInfo.phone,
company: extractedInfo.company,
taxId: extractedInfo.taxId,
}
if (extractedInfo.billingAddress) {
data.billingAddress = extractedInfo.billingAddress
}
} catch (error) {
const logger = createContextLogger(req.payload, 'Invoices Collection')
logger.error(`Failed to extract customer info: ${error}`)
throw new Error('Failed to extract customer information')
}
}
}
if (operation === 'create') {
// Generate invoice number if not provided
if (!data.number) {
@@ -221,19 +387,37 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
data.paidAt = new Date().toISOString()
}
},
],
] satisfies CollectionBeforeChangeHook<Invoice>[],
beforeValidate: [
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
({ data }) => {
if (!data) return
// If using extractor, customer relationship is required
if (customerRelationSlug && customerInfoExtractor && !data.customer) {
throw new Error('Please select a customer')
}
// If not using extractor but have customer collection, either relationship or info is required
if (customerRelationSlug && !customerInfoExtractor &&
!data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) {
throw new Error('Either select a customer or provide customer information')
}
// If no customer collection, ensure customer info is provided
if (!customerRelationSlug && (!data.customerInfo?.name || !data.customerInfo?.email)) {
throw new Error('Customer name and email are required')
}
if (data && data.items && Array.isArray(data.items)) {
// Calculate totals for each line item
data.items = data.items.map((item: InvoiceItemData) => ({
data.items = data.items.map((item) => ({
...item,
totalAmount: (item.quantity || 0) * (item.unitAmount || 0),
}))
// Calculate subtotal
data.subtotal = data.items.reduce(
(sum: number, item: InvoiceItemData) => sum + (item.totalAmount || 0),
(sum: number, item) => sum + (item.totalAmount || 0),
0
)
@@ -241,8 +425,8 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
data.amount = (data.subtotal || 0) + (data.taxAmount || 0)
}
},
],
] satisfies CollectionBeforeValidateHook<Invoice>[],
},
timestamps: true,
}
}
}

View File

@@ -1,17 +1,127 @@
import type { CollectionConfig } from 'payload'
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 './hooks'
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
PaymentData,
PaymentDocument
} from '../types/payload'
export function createPaymentsCollection(slug: string = 'payments'): CollectionConfig {
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
let fields: Field[] = [
{
name: 'provider',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Stripe', value: 'stripe' },
{ label: 'Mollie', value: 'mollie' },
{ label: 'Test', value: 'test' },
],
required: true,
},
{
name: 'providerId',
type: 'text',
admin: {
description: 'The payment ID from the payment provider',
},
label: 'Provider Payment ID',
unique: true,
index: true, // Ensure this field is indexed for webhook lookups
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Processing', value: 'processing' },
{ label: 'Succeeded', value: 'succeeded' },
{ label: 'Failed', value: 'failed' },
{ label: 'Canceled', value: 'canceled' },
{ label: 'Refunded', value: 'refunded' },
{ label: 'Partially Refunded', value: 'partially_refunded' },
],
required: true,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Amount in cents (e.g., 2000 = $20.00)',
},
min: 1,
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
maxLength: 3,
required: true,
},
{
name: 'description',
type: 'text',
admin: {
description: 'Payment description',
},
},
{
name: 'invoice',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection),
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional metadata for the payment',
},
},
{
name: 'providerData',
type: 'json',
admin: {
description: 'Raw data from the payment provider',
readOnly: true,
},
},
{
name: 'refunds',
type: 'relationship',
admin: {
position: 'sidebar',
readOnly: true,
},
hasMany: true,
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
},
{
name: 'version',
type: 'number',
defaultValue: 1,
admin: {
hidden: true, // Hide from admin UI to prevent manual tampering
},
index: true, // Index for optimistic locking performance
},
]
if (overrides?.fields) {
fields = overrides?.fields({defaultFields: fields})
}
return {
slug,
access: {
slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
access: overrides?.access || {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
@@ -21,131 +131,18 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
group: 'Billing',
useAsTitle: 'id',
...overrides?.admin
},
fields: [
{
name: 'provider',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Stripe', value: 'stripe' },
{ label: 'Mollie', value: 'mollie' },
{ label: 'Test', value: 'test' },
],
required: true,
},
{
name: 'providerId',
type: 'text',
admin: {
description: 'The payment ID from the payment provider',
},
label: 'Provider Payment ID',
required: true,
unique: true,
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Processing', value: 'processing' },
{ label: 'Succeeded', value: 'succeeded' },
{ label: 'Failed', value: 'failed' },
{ label: 'Canceled', value: 'canceled' },
{ label: 'Refunded', value: 'refunded' },
{ label: 'Partially Refunded', value: 'partially_refunded' },
],
required: true,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Amount in cents (e.g., 2000 = $20.00)',
},
min: 1,
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
maxLength: 3,
required: true,
},
{
name: 'description',
type: 'text',
admin: {
description: 'Payment description',
},
},
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'customers',
},
{
name: 'invoice',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'invoices',
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional metadata for the payment',
},
},
{
name: 'providerData',
type: 'json',
admin: {
description: 'Raw data from the payment provider',
readOnly: true,
},
},
{
name: 'refunds',
type: 'relationship',
admin: {
position: 'sidebar',
readOnly: true,
},
hasMany: true,
relationTo: 'refunds',
},
],
fields,
hooks: {
afterChange: [
({ doc, operation, req }: CollectionAfterChangeHook<PaymentDocument>) => {
if (operation === 'create') {
req.payload.logger.info(`Payment created: ${doc.id} (${doc.provider})`)
}
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<PaymentData>) => {
async ({ data, operation, req, originalDoc }) => {
if (operation === 'create') {
// Validate amount format
if (data.amount && !Number.isInteger(data.amount)) {
throw new Error('Amount must be an integer (in cents)')
}
// Validate currency format
if (data.currency) {
data.currency = data.currency.toUpperCase()
@@ -153,10 +150,21 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC
throw new Error('Currency must be a 3-letter ISO code')
}
}
await initProviderPayment(req.payload, data)
}
// Auto-increment version for manual updates (not webhook updates)
// Webhook updates handle their own versioning in updatePaymentStatus
if (operation === 'update' && !data.version) {
// If version is not being explicitly set (i.e., manual admin update),
// increment it automatically
const currentVersion = (originalDoc as Payment)?.version || 1
data.version = currentVersion + 1
}
},
],
] satisfies CollectionBeforeChangeHook<Payment>[],
},
timestamps: true,
}
}
}

View File

@@ -1,16 +1,13 @@
import type { CollectionConfig } from 'payload'
import type { AccessArgs, CollectionConfig } from 'payload'
import { BillingPluginConfig, defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import { Payment } from '../plugin/types/index'
import { createContextLogger } from '../utils/logger'
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
RefundData,
RefundDocument
} from '../types/payload'
export function createRefundsCollection(slug: string = 'refunds'): CollectionConfig {
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
// TODO: finish collection overrides
return {
slug,
slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
@@ -39,7 +36,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
admin: {
position: 'sidebar',
},
relationTo: 'payments',
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
required: true,
},
{
@@ -113,40 +110,41 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
],
hooks: {
afterChange: [
async ({ doc, operation, req }: CollectionAfterChangeHook<RefundDocument>) => {
async ({ doc, operation, req }) => {
if (operation === 'create') {
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
const logger = createContextLogger(req.payload, 'Refunds Collection')
logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
// Update the related payment's refund relationship
try {
const payment = await req.payload.findByID({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments',
})
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
}) as Payment
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
await req.payload.update({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments',
collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
data: {
refunds: [...refundIds, doc.id],
},
})
} catch (error) {
req.payload.logger.error(`Failed to update payment refunds: ${error}`)
const logger = createContextLogger(req.payload, 'Refunds Collection')
logger.error(`Failed to update payment refunds: ${error}`)
}
}
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<RefundData>) => {
({ data, operation }) => {
if (operation === 'create') {
// Validate amount format
if (data.amount && !Number.isInteger(data.amount)) {
throw new Error('Amount must be an integer (in cents)')
}
// Validate currency format
if (data.currency) {
data.currency = data.currency.toUpperCase()
@@ -160,4 +158,4 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
},
timestamps: true,
}
}
}

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'
}}>
🧪 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' }}>🧪 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

@@ -1,132 +1,21 @@
import type { Config } from 'payload'
import type { BillingPluginConfig } from './types'
export { billingPlugin } from './plugin/index.js'
export { mollieProvider, stripeProvider } from './providers/index.js'
export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderConfig } from './plugin/config.js'
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
export type { PaymentProvider, ProviderData } from './providers/types.js'
import { createCustomersCollection } from './collections/customers'
import { createInvoicesCollection } from './collections/invoices'
import { createPaymentsCollection } from './collections/payments'
import { createRefundsCollection } from './collections/refunds'
import { providerRegistry } from './providers/base/provider'
import { TestPaymentProvider } from './providers/test/provider'
// Export logging utilities
export { getPluginLogger, createContextLogger } from './utils/logger.js'
export * from './providers/base/provider'
export * from './providers/test/provider'
export * from './types'
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) {
return config
}
// Initialize collections
if (!config.collections) {
config.collections = []
}
config.collections.push(
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
createCustomersCollection(pluginConfig.collections?.customers || 'customers'),
createInvoicesCollection(pluginConfig.collections?.invoices || 'invoices'),
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
)
// Initialize endpoints
if (!config.endpoints) {
config.endpoints = []
}
config.endpoints?.push(
// Webhook endpoints
{
handler: async (req) => {
try {
const provider = providerRegistry.get(req.routeParams?.provider as string)
if (!provider) {
return Response.json({ error: 'Provider not found' }, { status: 404 })
}
const signature = req.headers.get('stripe-signature') ||
req.headers.get('x-mollie-signature')
const event = await provider.handleWebhook(req as unknown as Request, signature || '')
// TODO: Process webhook event and update database
return Response.json({ eventId: event.id, received: true })
} catch (error) {
console.error('[BILLING] Webhook error:', error)
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
}
},
method: 'post',
path: '/billing/webhooks/:provider'
},
// Health check endpoint
{
handler: async () => {
const providers = providerRegistry.getAll().map(p => ({
name: p.name,
status: 'active'
}))
return Response.json({
providers,
status: 'ok',
version: '0.1.0'
})
},
method: 'get',
path: '/billing/health'
}
)
// Initialize providers and onInit hook
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
// Execute any existing onInit functions first
if (incomingOnInit) {
await incomingOnInit(payload)
}
// Initialize payment providers
initializeProviders(pluginConfig)
// Log initialization
console.log('[BILLING] Plugin initialized with providers:',
providerRegistry.getAll().map(p => p.name).join(', ')
)
}
return config
}
function initializeProviders(config: BillingPluginConfig) {
// Initialize test provider if enabled
if (config.providers?.test?.enabled) {
const testProvider = new TestPaymentProvider(config.providers.test)
providerRegistry.register(testProvider)
}
// TODO: Initialize Stripe provider
// TODO: Initialize Mollie provider
}
// Utility function to get payment provider
export function getPaymentProvider(name: string) {
const provider = providerRegistry.get(name)
if (!provider) {
throw new Error(`Payment provider '${name}' not found`)
}
return provider
}
// Utility function to list available providers
export function getAvailableProviders() {
return providerRegistry.getAll().map(p => ({
name: p.name,
// Add provider-specific info here
}))
}
export default billingPlugin
// Export all providers
export { testProvider } from './providers/test.js'
export type {
StripeProviderConfig,
MollieProviderConfig,
TestProviderConfig,
TestProviderConfigResponse,
PaymentOutcome,
PaymentMethod,
PaymentScenario
} from './providers/index.js'

60
src/plugin/config.ts Normal file
View File

@@ -0,0 +1,60 @@
import { CollectionConfig } from 'payload'
import { FieldsOverride } from './utils'
import { PaymentProvider } from './types/index'
export const defaults = {
paymentsCollection: 'payments',
invoicesCollection: 'invoices',
refundsCollection: 'refunds',
customerRelationSlug: 'customer'
}
// Provider configurations
export interface TestProviderConfig {
autoComplete?: boolean
defaultDelay?: number
enabled: boolean
failureRate?: number
simulateFailures?: boolean
}
// Re-export the actual test provider config instead of duplicating
export type { TestProviderConfig as AdvancedTestProviderConfig } from '../providers/test'
// Customer info extractor callback type
export interface CustomerInfoExtractor {
(customer: any): {
name: string
email: string
phone?: string
company?: string
taxId?: string
billingAddress?: {
line1: string
line2?: string
city: string
state?: string
postalCode: string
country: string
}
}
}
// Plugin configuration
export interface BillingPluginConfig {
admin?: {
customComponents?: boolean
dashboard?: boolean
}
collections?: {
invoices?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
payments?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
refunds?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
}
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
customerRelationSlug?: string // Customer collection slug for relationship
disabled?: boolean
providers?: (PaymentProvider | undefined | null)[]
}

56
src/plugin/index.ts Normal file
View File

@@ -0,0 +1,56 @@
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index'
import type { BillingPluginConfig } from './config'
import type { Config, Payload } from 'payload'
import { createSingleton } from './singleton'
import type { PaymentProvider } from '../providers/index'
const singleton = createSingleton(Symbol('billingPlugin'))
type BillingPlugin = {
config: BillingPluginConfig
providerConfig: {
[key: string]: PaymentProvider
}
}
export const useBillingPlugin = (payload: Payload) => singleton.get(payload) as BillingPlugin
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) {
return config
}
config.collections = [
...(config.collections || []),
createPaymentsCollection(pluginConfig),
createInvoicesCollection(pluginConfig),
createRefundsCollection(pluginConfig),
];
(pluginConfig.providers || [])
.filter(provider => provider?.onConfig)
.forEach(provider => provider?.onConfig!(config, pluginConfig))
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
if (incomingOnInit) {
await incomingOnInit(payload)
}
singleton.set(payload, {
config: pluginConfig,
providerConfig: (pluginConfig.providers || []).filter(Boolean).reduce(
(record, provider) => {
record[provider!.key] = provider as PaymentProvider
return record
},
{} as Record<string, PaymentProvider>
)
} satisfies BillingPlugin)
await Promise.all((pluginConfig.providers || [])
.filter(provider => provider?.onInit)
.map(provider => provider?.onInit!(payload)))
}
return config
}
export default billingPlugin

11
src/plugin/singleton.ts Normal file
View File

@@ -0,0 +1,11 @@
export const createSingleton = <T>(s?: symbol | string) => {
const symbol = !s ? Symbol() : s
return {
get(container: any) {
return container[symbol] as T
},
set(container: any, value: T) {
container[symbol] = value
},
}
}

1
src/plugin/types/id.ts Normal file
View File

@@ -0,0 +1 @@
export type Id = string | number

View File

@@ -0,0 +1,5 @@
export * from './id.js'
export * from './invoices.js'
export * from './payments.js'
export * from './refunds.js'
export * from '../../providers/types.js'

View File

@@ -0,0 +1,116 @@
import { Payment } from './payments'
import { Id } from './id'
export interface Invoice<TCustomer = unknown> {
id: Id;
/**
* Invoice number (e.g., INV-001)
*/
number: string;
/**
* Link to customer record (optional)
*/
customer?: (Id | null) | TCustomer;
/**
* Customer billing information (auto-populated from customer relationship)
*/
customerInfo?: {
/**
* Customer name
*/
name?: string | null;
/**
* Customer email address
*/
email?: string | null;
/**
* Customer phone number
*/
phone?: string | null;
/**
* Company name (optional)
*/
company?: string | null;
/**
* Tax ID or VAT number
*/
taxId?: string | null;
};
/**
* Billing address (auto-populated from customer relationship)
*/
billingAddress?: {
/**
* Address line 1
*/
line1?: string | null;
/**
* Address line 2
*/
line2?: string | null;
city?: string | null;
/**
* State or province
*/
state?: string | null;
/**
* Postal or ZIP code
*/
postalCode?: string | null;
/**
* Country code (e.g., US, GB)
*/
country?: string | null;
};
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
/**
* ISO 4217 currency code (e.g., USD, EUR)
*/
currency: string;
items: {
description: string;
quantity: number;
/**
* Amount in cents
*/
unitAmount: number;
/**
* Calculated: quantity × unitAmount
*/
totalAmount?: number | null;
id?: Id | null;
}[];
/**
* Sum of all line items
*/
subtotal?: number | null;
/**
* Tax amount in cents
*/
taxAmount?: number | null;
/**
* Total amount (subtotal + tax)
*/
amount?: number | null;
dueDate?: string | null;
paidAt?: string | null;
payment?: (number | null) | Payment;
/**
* Internal notes
*/
notes?: string | null;
/**
* Additional invoice metadata
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}

View File

@@ -0,0 +1,57 @@
import { Refund } from './refunds'
import { Invoice } from './invoices'
import { Id } from './id'
export interface Payment {
id: Id;
provider: 'stripe' | 'mollie' | 'test';
/**
* The payment ID from the payment provider
*/
providerId: Id;
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
/**
* Amount in cents (e.g., 2000 = $20.00)
*/
amount: number;
/**
* ISO 4217 currency code (e.g., USD, EUR)
*/
currency: string;
/**
* Payment description
*/
description?: string | null;
invoice?: (Id | null) | Invoice;
/**
* Additional metadata for the payment
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Raw data from the payment provider
*/
providerData?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
refunds?: (number | Refund)[] | null;
/**
* Version number for optimistic locking (auto-incremented on updates)
*/
version?: number;
updatedAt: string;
createdAt: string;
}

View File

@@ -0,0 +1,53 @@
import { Payment } from './payments'
export interface Refund {
id: number;
/**
* The refund ID from the payment provider
*/
providerId: string;
payment: number | Payment;
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled';
/**
* Refund amount in cents
*/
amount: number;
/**
* ISO 4217 currency code (e.g., USD, EUR)
*/
currency: string;
/**
* Reason for the refund
*/
reason?: ('duplicate' | 'fraudulent' | 'requested_by_customer' | 'other') | null;
/**
* Additional details about the refund
*/
description?: string | null;
/**
* Additional refund metadata
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Raw data from the payment provider
*/
providerData?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}

15
src/plugin/utils.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
import type { Id } from './types/index'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export const extractSlug =
(arg: string | Partial<CollectionConfig>) => (typeof arg === 'string' ? arg : arg.slug!) as CollectionSlug
/**
* Safely cast ID types for PayloadCMS operations
* This utility provides a typed way to handle the mismatch between our Id type and PayloadCMS expectations
*/
export function toPayloadId(id: Id): any {
return id as any
}

View File

@@ -1,63 +0,0 @@
import type { CreatePaymentOptions, Payment, PaymentProvider, Refund, WebhookEvent } from '../../types'
export abstract class BasePaymentProvider implements PaymentProvider {
abstract name: string
protected formatAmount(amount: number, currency: string): number {
this.validateAmount(amount)
this.validateCurrency(currency)
return amount
}
protected log(level: 'error' | 'info' | 'warn', message: string, data?: Record<string, unknown>): void {
const logData = {
message,
provider: this.name,
...data,
}
console[level](`[${this.name.toUpperCase()}]`, logData)
}
protected validateAmount(amount: number): void {
if (amount <= 0 || !Number.isInteger(amount)) {
throw new Error('Amount must be a positive integer in cents')
}
}
protected validateCurrency(currency: string): void {
if (!currency || currency.length !== 3) {
throw new Error('Currency must be a valid 3-letter ISO currency code')
}
}
abstract cancelPayment(id: string): Promise<Payment>
abstract createPayment(options: CreatePaymentOptions): Promise<Payment>
abstract handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
abstract refundPayment(id: string, amount?: number): Promise<Refund>
abstract retrievePayment(id: string): Promise<Payment>
}
export function createProviderRegistry() {
const providers = new Map<string, PaymentProvider>()
return {
register(provider: PaymentProvider): void {
providers.set(provider.name, provider)
},
get(name: string): PaymentProvider | undefined {
return providers.get(name)
},
getAll(): PaymentProvider[] {
return Array.from(providers.values())
},
has(name: string): boolean {
return providers.has(name)
}
}
}
export const providerRegistry = createProviderRegistry()

94
src/providers/currency.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* Currency utilities for payment processing
*/
// Currencies that don't use centesimal units (no decimal places)
const NON_CENTESIMAL_CURRENCIES = new Set([
'BIF', // Burundian Franc
'CLP', // Chilean Peso
'DJF', // Djiboutian Franc
'GNF', // Guinean Franc
'JPY', // Japanese Yen
'KMF', // Comorian Franc
'KRW', // South Korean Won
'MGA', // Malagasy Ariary
'PYG', // Paraguayan Guaraní
'RWF', // Rwandan Franc
'UGX', // Ugandan Shilling
'VND', // Vietnamese Đồng
'VUV', // Vanuatu Vatu
'XAF', // Central African CFA Franc
'XOF', // West African CFA Franc
'XPF', // CFP Franc
])
// Currencies that use 3 decimal places
const THREE_DECIMAL_CURRENCIES = new Set([
'BHD', // Bahraini Dinar
'IQD', // Iraqi Dinar
'JOD', // Jordanian Dinar
'KWD', // Kuwaiti Dinar
'LYD', // Libyan Dinar
'OMR', // Omani Rial
'TND', // Tunisian Dinar
])
/**
* Convert amount from smallest unit to decimal for display
* @param amount - Amount in smallest unit (e.g., cents for USD)
* @param currency - ISO 4217 currency code
* @returns Formatted amount string for the payment provider
*/
export function formatAmountForProvider(amount: number, currency: string): string {
const upperCurrency = currency.toUpperCase()
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
// No decimal places
return amount.toString()
}
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
// 3 decimal places
return (amount / 1000).toFixed(3)
}
// Default: 2 decimal places (most currencies)
return (amount / 100).toFixed(2)
}
/**
* Get the number of decimal places for a currency
* @param currency - ISO 4217 currency code
* @returns Number of decimal places
*/
export function getCurrencyDecimals(currency: string): number {
const upperCurrency = currency.toUpperCase()
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
return 0
}
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
return 3
}
return 2
}
/**
* Validate currency code format
* @param currency - Currency code to validate
* @returns True if valid ISO 4217 format
*/
export function isValidCurrencyCode(currency: string): boolean {
return /^[A-Z]{3}$/.test(currency.toUpperCase())
}
/**
* Validate amount is positive and within reasonable limits
* @param amount - Amount to validate
* @returns True if valid
*/
export function isValidAmount(amount: number): boolean {
return Number.isInteger(amount) && amount > 0 && amount <= 99999999999 // Max ~999 million in major units
}

10
src/providers/index.ts Normal file
View File

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

161
src/providers/mollie.ts Normal file
View File

@@ -0,0 +1,161 @@
import type { Payment } from '../plugin/types/payments'
import type { PaymentProvider } from '../plugin/types/index'
import type { Payload } from 'payload'
import { createSingleton } from '../plugin/singleton'
import type { createMollieClient, MollieClient } from '@mollie/api-client'
import {
webhookResponses,
findPaymentByProviderId,
updatePaymentStatus,
updateInvoiceOnPaymentSuccess,
handleWebhookError,
validateProductionUrl
} from './utils'
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
/**
* Type-safe mapping of Mollie payment status to internal status
*/
function mapMollieStatusToPaymentStatus(mollieStatus: string): Payment['status'] {
// Define known Mollie statuses for type safety
const mollieStatusMap: Record<string, Payment['status']> = {
'paid': 'succeeded',
'failed': 'failed',
'canceled': 'canceled',
'expired': 'canceled',
'pending': 'pending',
'open': 'pending',
'authorized': 'pending',
}
return mollieStatusMap[mollieStatus] || 'processing'
}
export const mollieProvider = (mollieConfig: MollieProviderConfig & {
webhookUrl?: string
redirectUrl?: string
}) => {
// Validate required configuration at initialization
if (!mollieConfig.apiKey) {
throw new Error('Mollie API key is required')
}
const singleton = createSingleton<MollieClient>(symbol)
return {
key: 'mollie',
onConfig: (config, pluginConfig) => {
// Always register Mollie webhook since it doesn't require a separate webhook secret
// Mollie validates webhooks through payment ID verification
config.endpoints = [
...(config.endpoints || []),
{
path: '/payload-billing/mollie/webhook',
method: 'post',
handler: async (req) => {
try {
const payload = req.payload
const mollieClient = singleton.get(payload)
// Parse the webhook body to get the Mollie payment ID
if (!req.text) {
return webhookResponses.missingBody()
}
const body = await req.text()
if (!body || !body.startsWith('id=')) {
return webhookResponses.invalidPayload()
}
const molliePaymentId = body.slice(3) // Remove 'id=' prefix
// Fetch the payment details from Mollie
const molliePayment = await mollieClient.payments.get(molliePaymentId)
// Find the corresponding payment in our database
const payment = await findPaymentByProviderId(payload, molliePaymentId, pluginConfig)
if (!payment) {
return webhookResponses.paymentNotFound()
}
// Map Mollie status to our status using proper type-safe mapping
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
// Update the payment status and provider data
const updateSuccess = await updatePaymentStatus(
payload,
payment.id,
status,
molliePayment.toPlainObject(),
pluginConfig
)
// If payment is successful and update succeeded, update the invoice
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
const logger = createContextLogger(payload, 'Mollie Webhook')
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Mollie', error, undefined, req.payload)
}
}
}
]
},
onInit: async (payload: Payload) => {
const createMollieClient = (await import('@mollie/api-client')).default
const mollieClient = createMollieClient(mollieConfig)
singleton.set(payload, mollieClient)
},
initPayment: async (payload, payment) => {
// Validate required fields
if (!payment.amount) {
throw new Error('Amount is required')
}
if (!payment.currency) {
throw new Error('Currency is required')
}
// Validate amount
if (!isValidAmount(payment.amount)) {
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
}
// Validate currency code
if (!isValidCurrencyCode(payment.currency)) {
throw new Error('Invalid currency: must be a 3-letter ISO code')
}
// Setup URLs with development defaults
const isProduction = process.env.NODE_ENV === 'production'
const redirectUrl = mollieConfig.redirectUrl ||
(!isProduction ? 'https://localhost:3000/payment/success' : undefined)
const webhookUrl = mollieConfig.webhookUrl ||
`${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook`
// Validate URLs for production
validateProductionUrl(redirectUrl, 'Redirect')
validateProductionUrl(webhookUrl, 'Webhook')
const molliePayment = await singleton.get(payload).payments.create({
amount: {
value: formatAmountForProvider(payment.amount, payment.currency),
currency: payment.currency.toUpperCase()
},
description: payment.description || '',
redirectUrl,
webhookUrl,
});
payment.providerId = molliePayment.id
payment.providerData = molliePayment.toPlainObject()
return payment
},
} satisfies PaymentProvider
}

266
src/providers/stripe.ts Normal file
View File

@@ -0,0 +1,266 @@
import type { Payment } from '../plugin/types/payments'
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
import type { Payload } from 'payload'
import { createSingleton } from '../plugin/singleton'
import type Stripe from 'stripe'
import {
webhookResponses,
findPaymentByProviderId,
updatePaymentStatus,
updateInvoiceOnPaymentSuccess,
handleWebhookError,
logWebhookEvent
} from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('stripe')
export interface StripeProviderConfig {
secretKey: string
webhookSecret?: string
apiVersion?: Stripe.StripeConfig['apiVersion']
returnUrl?: string
webhookUrl?: string
}
// Default API version for consistency
const DEFAULT_API_VERSION: Stripe.StripeConfig['apiVersion'] = '2025-08-27.basil'
export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
// Validate required configuration at initialization
if (!stripeConfig.secretKey) {
throw new Error('Stripe secret key is required')
}
const singleton = createSingleton<Stripe>(symbol)
return {
key: 'stripe',
onConfig: (config, pluginConfig) => {
// Only register webhook endpoint if webhook secret is configured
if (stripeConfig.webhookSecret) {
config.endpoints = [
...(config.endpoints || []),
{
path: '/payload-billing/stripe/webhook',
method: 'post',
handler: async (req) => {
try {
const payload = req.payload
const stripe = singleton.get(payload)
// Get the raw body for signature verification
let body: string
try {
if (!req.text) {
return webhookResponses.missingBody()
}
body = await req.text()
if (!body) {
return webhookResponses.missingBody()
}
} catch (error) {
return handleWebhookError('Stripe', error, 'Failed to read request body', req.payload)
}
const signature = req.headers.get('stripe-signature')
if (!signature) {
return webhookResponses.error('Missing webhook signature', 400, req.payload)
}
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
// Verify webhook signature and construct event
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
} catch (err) {
return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload)
}
// Handle different event types
switch (event.type) {
case 'payment_intent.succeeded':
case 'payment_intent.payment_failed':
case 'payment_intent.canceled': {
const paymentIntent = event.data.object
// Find the corresponding payment in our database
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
if (!payment) {
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`, undefined, req.payload)
return webhookResponses.success() // Still return 200 to acknowledge receipt
}
// Map Stripe status to our status
let status: Payment['status'] = 'pending'
if (paymentIntent.status === 'succeeded') {
status = 'succeeded'
} else if (paymentIntent.status === 'canceled') {
status = 'canceled'
} else if (paymentIntent.status === 'requires_payment_method' ||
paymentIntent.status === 'requires_confirmation' ||
paymentIntent.status === 'requires_action') {
status = 'pending'
} else if (paymentIntent.status === 'processing') {
status = 'processing'
} else {
status = 'failed'
}
// Update the payment status and provider data
const providerData: ProviderData<Stripe.PaymentIntent> = {
raw: paymentIntent,
timestamp: new Date().toISOString(),
provider: 'stripe'
}
const updateSuccess = await updatePaymentStatus(
payload,
payment.id,
status,
providerData,
pluginConfig
)
// If payment is successful and update succeeded, update the invoice
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
const logger = createContextLogger(payload, 'Stripe Webhook')
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
}
break
}
case 'charge.refunded': {
const charge = event.data.object
// Find the payment by charge ID or payment intent
let payment: Payment | null = null
// First try to find by payment intent ID
if (charge.payment_intent) {
payment = await findPaymentByProviderId(
payload,
charge.payment_intent as string,
pluginConfig
)
}
// If not found, try charge ID
if (!payment) {
payment = await findPaymentByProviderId(payload, charge.id, pluginConfig)
}
if (payment) {
// Determine if fully or partially refunded
const isFullyRefunded = charge.amount_refunded === charge.amount
const providerData: ProviderData<Stripe.Charge> = {
raw: charge,
timestamp: new Date().toISOString(),
provider: 'stripe'
}
const updateSuccess = await updatePaymentStatus(
payload,
payment.id,
isFullyRefunded ? 'refunded' : 'partially_refunded',
providerData,
pluginConfig
)
if (!updateSuccess) {
const logger = createContextLogger(payload, 'Stripe Webhook')
logger.warn(`Failed to update refund status for payment ${payment.id}`)
}
}
break
}
default:
// Unhandled event type
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Stripe', error, undefined, req.payload)
}
}
}
]
}
},
onInit: async (payload: Payload) => {
const { default: Stripe } = await import('stripe')
const stripe = new Stripe(stripeConfig.secretKey, {
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
})
singleton.set(payload, stripe)
// Log webhook registration status
if (!stripeConfig.webhookSecret) {
const logger = createContextLogger(payload, 'Stripe Provider')
logger.warn('Webhook endpoint not registered - webhookSecret not configured')
}
},
initPayment: async (payload, payment) => {
// Validate required fields
if (!payment.amount) {
throw new Error('Amount is required')
}
if (!payment.currency) {
throw new Error('Currency is required')
}
// Validate amount
if (!isValidAmount(payment.amount)) {
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
}
// Validate currency code
if (!isValidCurrencyCode(payment.currency)) {
throw new Error('Invalid currency: must be a 3-letter ISO code')
}
// Validate description length if provided
if (payment.description && payment.description.length > 1000) {
throw new Error('Description must be 1000 characters or less')
}
const stripe = singleton.get(payload)
// Create a payment intent
const paymentIntent = await stripe.paymentIntents.create({
amount: payment.amount, // Stripe handles currency conversion internally
currency: payment.currency.toLowerCase(),
description: payment.description || undefined,
metadata: {
payloadPaymentId: payment.id?.toString() || '',
...(typeof payment.metadata === 'object' &&
payment.metadata !== null &&
!Array.isArray(payment.metadata)
? payment.metadata
: {})
} as Stripe.MetadataParam,
automatic_payment_methods: {
enabled: true,
},
})
payment.providerId = paymentIntent.id
const providerData: ProviderData<Stripe.PaymentIntent> = {
raw: { ...paymentIntent, client_secret: paymentIntent.client_secret },
timestamp: new Date().toISOString(),
provider: 'stripe'
}
payment.providerData = providerData
return payment
},
} satisfies PaymentProvider
}

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: async (req) => {
const response: TestProviderConfigResponse = {
enabled: testConfig.enabled,
scenarios: 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,225 +0,0 @@
import type {
CreatePaymentOptions,
Payment,
PaymentStatus,
Refund,
TestProviderConfig,
WebhookEvent
} from '../../types';
import {
RefundStatus
} from '../../types'
import { BasePaymentProvider } from '../base/provider'
interface TestPaymentData {
delayMs?: number
failAfterMs?: number
simulateFailure?: boolean
}
export class TestPaymentProvider extends BasePaymentProvider {
private config: TestProviderConfig
private payments = new Map<string, Payment>()
private refunds = new Map<string, Refund>()
name = 'test'
constructor(config: TestProviderConfig) {
super()
this.config = config
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async cancelPayment(id: string): Promise<Payment> {
const payment = this.payments.get(id)
if (!payment) {
throw new Error(`Payment ${id} not found`)
}
if (payment.status === 'succeeded') {
throw new Error('Cannot cancel a succeeded payment')
}
const canceledPayment = {
...payment,
status: 'canceled' as PaymentStatus,
updatedAt: new Date().toISOString()
}
this.payments.set(id, canceledPayment)
this.log('info', 'Payment canceled', { paymentId: id })
return canceledPayment
}
clearStoredData(): void {
this.payments.clear()
this.refunds.clear()
this.log('info', 'Test data cleared')
}
async createPayment(options: CreatePaymentOptions): Promise<Payment> {
const testData = options.metadata?.test as TestPaymentData || {}
const delay = testData.delayMs ?? this.config.defaultDelay ?? 0
if (delay > 0) {
await this.sleep(delay)
}
const shouldFail = testData.simulateFailure ??
(this.config.simulateFailures && Math.random() < (this.config.failureRate ?? 0.1))
const paymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substring(7)}`
const payment: Payment = {
id: paymentId,
amount: options.amount,
createdAt: new Date().toISOString(),
currency: options.currency,
customer: options.customer,
description: options.description,
metadata: options.metadata,
provider: this.name,
providerData: {
autoCompleted: this.config.autoComplete,
delayApplied: delay,
simulatedFailure: shouldFail,
testMode: true
},
status: shouldFail ? 'failed' : (this.config.autoComplete ? 'succeeded' : 'pending'),
updatedAt: new Date().toISOString()
}
this.payments.set(paymentId, payment)
this.log('info', 'Payment created', {
amount: options.amount,
currency: options.currency,
paymentId,
status: payment.status
})
// Simulate async status updates if configured
if (testData.failAfterMs && !shouldFail) {
setTimeout(() => {
const updatedPayment = { ...payment, status: 'failed' as PaymentStatus, updatedAt: new Date().toISOString() }
this.payments.set(paymentId, updatedPayment)
this.log('info', 'Payment failed after delay', { paymentId })
}, testData.failAfterMs)
}
return payment
}
getAllPayments(): Payment[] {
return Array.from(this.payments.values())
}
getAllRefunds(): Refund[] {
return Array.from(this.refunds.values())
}
// Test-specific methods
getStoredPayment(id: string): Payment | undefined {
return this.payments.get(id)
}
getStoredRefund(id: string): Refund | undefined {
return this.refunds.get(id)
}
async handleWebhook(request: Request, signature?: string): Promise<WebhookEvent> {
if (!this.config.enabled) {
throw new Error('Test provider is not enabled')
}
// For test provider, we'll simulate webhook events
const body = await request.text()
let eventData: Record<string, unknown>
try {
eventData = JSON.parse(body)
} catch (error) {
throw new Error('Invalid JSON in webhook body')
}
const event: WebhookEvent = {
id: `test_evt_${Date.now()}_${Math.random().toString(36).substring(7)}`,
type: (eventData.type as string) || 'payment.status_changed',
data: eventData,
provider: this.name,
verified: true // Test provider always considers webhooks verified
}
this.log('info', 'Webhook received', {
type: event.type,
dataKeys: Object.keys(event.data),
eventId: event.id
})
return event
}
async refundPayment(id: string, amount?: number): Promise<Refund> {
const payment = this.payments.get(id)
if (!payment) {
throw new Error(`Payment ${id} not found`)
}
if (payment.status !== 'succeeded') {
throw new Error('Can only refund succeeded payments')
}
const refundAmount = amount ?? payment.amount
if (refundAmount > payment.amount) {
throw new Error('Refund amount cannot exceed payment amount')
}
const refundId = `test_ref_${Date.now()}_${Math.random().toString(36).substring(7)}`
const refund: Refund = {
id: refundId,
amount: refundAmount,
createdAt: new Date().toISOString(),
currency: payment.currency,
paymentId: id,
providerData: {
autoCompleted: this.config.autoComplete,
testMode: true
},
status: this.config.autoComplete ? 'succeeded' : 'pending'
}
this.refunds.set(refundId, refund)
// Update payment status
const newPaymentStatus: PaymentStatus = refundAmount === payment.amount ? 'refunded' : 'partially_refunded'
const updatedPayment = {
...payment,
status: newPaymentStatus,
updatedAt: new Date().toISOString()
}
this.payments.set(id, updatedPayment)
this.log('info', 'Refund created', {
amount: refundAmount,
paymentId: id,
refundId,
status: refund.status
})
return refund
}
async retrievePayment(id: string): Promise<Payment> {
const payment = this.payments.get(id)
if (!payment) {
throw new Error(`Payment ${id} not found`)
}
return payment
}
}

21
src/providers/types.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Payment } from '../plugin/types/payments'
import type { Config, Payload } from 'payload'
import type { BillingPluginConfig } from '../plugin/config'
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
export type PaymentProvider = {
key: string
onConfig?: (config: Config, pluginConfig: BillingPluginConfig) => void
onInit?: (payload: Payload) => Promise<void> | void
initPayment: InitPayment
}
/**
* Type-safe provider data wrapper
*/
export type ProviderData<T = unknown> = {
raw: T
timestamp: string
provider: string
}

229
src/providers/utils.ts Normal file
View File

@@ -0,0 +1,229 @@
import type { Payload } from 'payload'
import type { Payment } from '../plugin/types/payments'
import type { BillingPluginConfig } from '../plugin/config'
import type { ProviderData } from './types'
import { defaults } from '../plugin/config'
import { extractSlug, toPayloadId } from '../plugin/utils'
import { createContextLogger } from '../utils/logger'
/**
* Common webhook response utilities
* Note: Always return 200 for webhook acknowledgment to prevent information disclosure
*/
export const webhookResponses = {
success: () => Response.json({ received: true }, { status: 200 }),
error: (message: string, status = 400, payload?: Payload) => {
// Log error internally but don't expose details
if (payload) {
const logger = createContextLogger(payload, 'Webhook')
logger.error('Error:', message)
} else {
console.error('[Webhook] Error:', message)
}
return Response.json({ error: 'Invalid request' }, { status })
},
missingBody: () => Response.json({ received: true }, { status: 200 }),
paymentNotFound: () => Response.json({ received: true }, { status: 200 }),
invalidPayload: () => Response.json({ received: true }, { status: 200 }),
}
/**
* Find a payment by provider ID
*/
export async function findPaymentByProviderId(
payload: Payload,
providerId: string,
pluginConfig: BillingPluginConfig
): Promise<Payment | null> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
const payments = await payload.find({
collection: paymentsCollection,
where: {
providerId: {
equals: providerId
}
}
})
return payments.docs.length > 0 ? payments.docs[0] as Payment : null
}
/**
* Update payment status and provider data with optimistic locking
*/
export async function updatePaymentStatus(
payload: Payload,
paymentId: string | number,
status: Payment['status'],
providerData: ProviderData<any>,
pluginConfig: BillingPluginConfig
): Promise<boolean> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
try {
// First, fetch the current payment to get the current version
const currentPayment = await payload.findByID({
collection: paymentsCollection,
id: toPayloadId(paymentId),
}) as Payment
if (!currentPayment) {
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Payment ${paymentId} not found`)
return false
}
const currentVersion = currentPayment.version || 1
// Attempt to update with optimistic locking
// We'll use a transaction to ensure atomicity
const transactionID = await payload.db.beginTransaction()
if (!transactionID) {
const logger = createContextLogger(payload, 'Payment Update')
logger.error('Failed to begin transaction')
return false
}
try {
// Re-fetch within transaction to ensure consistency
const paymentInTransaction = await payload.findByID({
collection: paymentsCollection,
id: toPayloadId(paymentId),
req: { transactionID: transactionID }
}) as Payment
// Check if version still matches
if ((paymentInTransaction.version || 1) !== currentVersion) {
// Version conflict detected - payment was modified by another process
const logger = createContextLogger(payload, 'Payment Update')
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
await payload.db.rollbackTransaction(transactionID)
return false
}
// Update with new version
await payload.update({
collection: paymentsCollection,
id: toPayloadId(paymentId),
data: {
status,
providerData: {
...providerData,
webhookProcessedAt: new Date().toISOString()
},
version: currentVersion + 1
},
req: { transactionID: transactionID }
})
await payload.db.commitTransaction(transactionID)
return true
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
throw error
}
} catch (error) {
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Failed to update payment ${paymentId}:`, error)
return false
}
}
/**
* Update invoice status when payment succeeds
*/
export async function updateInvoiceOnPaymentSuccess(
payload: Payload,
payment: Payment,
pluginConfig: BillingPluginConfig
): Promise<void> {
if (!payment.invoice) return
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
const invoiceId = typeof payment.invoice === 'object'
? payment.invoice.id
: payment.invoice
await payload.update({
collection: invoicesCollection,
id: toPayloadId(invoiceId),
data: {
status: 'paid',
payment: toPayloadId(payment.id)
}
})
}
/**
* Handle webhook errors with consistent logging
*/
export function handleWebhookError(
provider: string,
error: unknown,
context?: string,
payload?: Payload
): Response {
const message = error instanceof Error ? error.message : 'Unknown error'
const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook`
// Log detailed error internally for debugging
if (payload) {
const logger = createContextLogger(payload, fullContext)
logger.error('Error:', error)
} else {
console.error(`[${fullContext}] Error:`, error)
}
// Return generic response to avoid information disclosure
return Response.json({
received: false,
error: 'Processing error'
}, { status: 200 })
}
/**
* Log webhook events
*/
export function logWebhookEvent(
provider: string,
event: string,
details?: any,
payload?: Payload
): void {
if (payload) {
const logger = createContextLogger(payload, `${provider} Webhook`)
logger.info(event, details ? JSON.stringify(details) : '')
} else {
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
}
}
/**
* Validate URL for production use
*/
export function validateProductionUrl(url: string | undefined, urlType: string): void {
const isProduction = process.env.NODE_ENV === 'production'
if (!isProduction) return
if (!url) {
throw new Error(`${urlType} URL is required for production`)
}
if (url.includes('localhost') || url.includes('127.0.0.1')) {
throw new Error(`${urlType} URL cannot use localhost in production`)
}
if (!url.startsWith('https://')) {
throw new Error(`${urlType} URL must use HTTPS in production`)
}
// Basic URL validation
try {
new URL(url)
} catch {
throw new Error(`${urlType} URL is not a valid URL`)
}
}

View File

@@ -1,224 +0,0 @@
import type { Config } from 'payload'
// Base payment provider interface
export interface PaymentProvider {
cancelPayment(id: string): Promise<Payment>
createPayment(options: CreatePaymentOptions): Promise<Payment>
handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
name: string
refundPayment(id: string, amount?: number): Promise<Refund>
retrievePayment(id: string): Promise<Payment>
}
// Payment types
export interface CreatePaymentOptions {
amount: number
cancelUrl?: string
currency: string
customer?: string
description?: string
metadata?: Record<string, unknown>
returnUrl?: string
}
export interface Payment {
amount: number
createdAt: string
currency: string
customer?: string
description?: string
id: string
metadata?: Record<string, unknown>
provider: string
providerData?: Record<string, unknown>
status: PaymentStatus
updatedAt: string
}
export interface Refund {
amount: number
createdAt: string
currency: string
id: string
paymentId: string
providerData?: Record<string, unknown>
reason?: string
status: RefundStatus
}
export interface WebhookEvent {
data: Record<string, unknown>
id: string
provider: string
type: string
verified: boolean
}
// Status enums
export type PaymentStatus =
| 'canceled'
| 'failed'
| 'partially_refunded'
| 'pending'
| 'processing'
| 'refunded'
| 'succeeded'
export type RefundStatus =
| 'canceled'
| 'failed'
| 'pending'
| 'processing'
| 'succeeded'
// Provider configurations
export interface StripeConfig {
apiVersion?: string
publishableKey: string
secretKey: string
webhookEndpointSecret: string
}
export interface MollieConfig {
apiKey: string
testMode?: boolean
webhookUrl: string
}
export interface TestProviderConfig {
autoComplete?: boolean
defaultDelay?: number
enabled: boolean
failureRate?: number
simulateFailures?: boolean
}
// Plugin configuration
export interface BillingPluginConfig {
admin?: {
customComponents?: boolean
dashboard?: boolean
}
collections?: {
customers?: string
invoices?: string
payments?: string
refunds?: string
}
disabled?: boolean
providers?: {
mollie?: MollieConfig
stripe?: StripeConfig
test?: TestProviderConfig
}
webhooks?: {
basePath?: string
cors?: boolean
}
}
// Collection types
export interface PaymentRecord {
amount: number
createdAt: string
currency: string
customer?: string
description?: string
id: string
metadata?: Record<string, unknown>
provider: string
providerData?: Record<string, unknown>
providerId: string
status: PaymentStatus
updatedAt: string
}
export interface CustomerRecord {
address?: {
city?: string
country?: string
line1?: string
line2?: string
postal_code?: string
state?: string
}
createdAt: string
email?: string
id: string
metadata?: Record<string, unknown>
name?: string
phone?: string
providerIds?: Record<string, string>
updatedAt: string
}
export interface InvoiceRecord {
amount: number
createdAt: string
currency: string
customer?: string
dueDate?: string
id: string
items: InvoiceItem[]
metadata?: Record<string, unknown>
number: string
paidAt?: string
status: InvoiceStatus
updatedAt: string
}
export interface InvoiceItem {
description: string
quantity: number
totalAmount: number
unitAmount: number
}
export type InvoiceStatus =
| 'draft'
| 'open'
| 'paid'
| 'uncollectible'
| 'void'
// Plugin type
export interface BillingPluginOptions extends BillingPluginConfig {
disabled?: boolean
}
// Error types
export class BillingError extends Error {
constructor(
message: string,
public code: string,
public provider?: string,
public details?: Record<string, unknown>
) {
super(message)
this.name = 'BillingError'
}
}
export class PaymentProviderError extends BillingError {
constructor(
message: string,
provider: string,
code?: string,
details?: Record<string, unknown>
) {
super(message, code || 'PROVIDER_ERROR', provider, details)
this.name = 'PaymentProviderError'
}
}
export class WebhookError extends BillingError {
constructor(
message: string,
provider: string,
code?: string,
details?: Record<string, unknown>
) {
super(message, code || 'WEBHOOK_ERROR', provider, details)
this.name = 'WebhookError'
}
}

View File

@@ -1,148 +0,0 @@
/**
* PayloadCMS type definitions for hooks and handlers
*/
import type { PayloadRequest, User } from 'payload'
// Collection hook types
export interface CollectionBeforeChangeHook<T = Record<string, unknown>> {
data: T
operation: 'create' | 'delete' | 'update'
originalDoc?: T
req: PayloadRequest
}
export interface CollectionAfterChangeHook<T = Record<string, unknown>> {
doc: T
operation: 'create' | 'delete' | 'update'
previousDoc?: T
req: PayloadRequest
}
export interface CollectionBeforeValidateHook<T = Record<string, unknown>> {
data?: T
operation: 'create' | 'update'
originalDoc?: T
req: PayloadRequest
}
// Access control types
export interface AccessArgs<T = unknown> {
data?: T
id?: number | string
req: {
payload: unknown
user: null | User
}
}
// Invoice item type for hooks
export interface InvoiceItemData {
description: string
quantity: number
totalAmount?: number
unitAmount: number
}
// Invoice data type for hooks
export interface InvoiceData {
amount?: number
currency?: string
customer?: string
dueDate?: string
items?: InvoiceItemData[]
metadata?: Record<string, unknown>
notes?: string
number?: string
paidAt?: string
payment?: string
status?: string
subtotal?: number
taxAmount?: number
}
// Payment data type for hooks
export interface PaymentData {
amount?: number
currency?: string
customer?: string
description?: string
invoice?: string
metadata?: Record<string, unknown>
provider?: string
providerData?: Record<string, unknown>
providerId?: string
status?: string
}
// Customer data type for hooks
export interface CustomerData {
address?: {
city?: string
country?: string
line1?: string
line2?: string
postal_code?: string
state?: string
}
email?: string
metadata?: Record<string, unknown>
name?: string
phone?: string
providerIds?: Record<string, string>
}
// Refund data type for hooks
export interface RefundData {
amount?: number
currency?: string
description?: string
metadata?: Record<string, unknown>
payment?: { id: string } | string
providerData?: Record<string, unknown>
providerId?: string
reason?: string
status?: string
}
// Document types with required fields after creation
export interface PaymentDocument extends PaymentData {
amount: number
createdAt: string
currency: string
id: string
provider: string
providerId: string
status: string
updatedAt: string
}
export interface CustomerDocument extends CustomerData {
createdAt: string
id: string
updatedAt: string
}
export interface InvoiceDocument extends InvoiceData {
amount: number
createdAt: string
currency: string
customer: string
id: string
items: InvoiceItemData[]
number: string
status: string
updatedAt: string
}
export interface RefundDocument extends RefundData {
amount: number
createdAt: string
currency: string
id: string
payment: { id: string } | string
providerId: string
refunds?: string[]
status: string
updatedAt: string
}

View File

@@ -1,130 +0,0 @@
/**
* Currency utility functions for payment processing
*/
// Common currency configurations
export const CURRENCY_CONFIG = {
AUD: { name: 'Australian Dollar', decimals: 2, symbol: 'A$' },
CAD: { name: 'Canadian Dollar', decimals: 2, symbol: 'C$' },
CHF: { name: 'Swiss Franc', decimals: 2, symbol: 'Fr' },
DKK: { name: 'Danish Krone', decimals: 2, symbol: 'kr' },
EUR: { name: 'Euro', decimals: 2, symbol: '€' },
GBP: { name: 'British Pound', decimals: 2, symbol: '£' },
JPY: { name: 'Japanese Yen', decimals: 0, symbol: '¥' },
NOK: { name: 'Norwegian Krone', decimals: 2, symbol: 'kr' },
SEK: { name: 'Swedish Krona', decimals: 2, symbol: 'kr' },
USD: { name: 'US Dollar', decimals: 2, symbol: '$' },
} as const
export type SupportedCurrency = keyof typeof CURRENCY_CONFIG
/**
* Validates if a currency code is supported
*/
export function isSupportedCurrency(currency: string): currency is SupportedCurrency {
return currency in CURRENCY_CONFIG
}
/**
* Validates currency format (3-letter ISO code)
*/
export function isValidCurrencyCode(currency: string): boolean {
return /^[A-Z]{3}$/.test(currency)
}
/**
* Converts amount from cents to major currency unit
*/
export function fromCents(amount: number, currency: string): number {
if (!isValidCurrencyCode(currency)) {
throw new Error(`Invalid currency code: ${currency}`)
}
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
if (!config) {
// Default to 2 decimals for unknown currencies
return amount / 100
}
return config.decimals === 0 ? amount : amount / Math.pow(10, config.decimals)
}
/**
* Converts amount from major currency unit to cents
*/
export function toCents(amount: number, currency: string): number {
if (!isValidCurrencyCode(currency)) {
throw new Error(`Invalid currency code: ${currency}`)
}
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
if (!config) {
// Default to 2 decimals for unknown currencies
return Math.round(amount * 100)
}
return config.decimals === 0
? Math.round(amount)
: Math.round(amount * Math.pow(10, config.decimals))
}
/**
* Formats amount for display with currency symbol
*/
export function formatAmount(amount: number, currency: string, options?: {
showCode?: boolean
showSymbol?: boolean
}): string {
const { showCode = false, showSymbol = true } = options || {}
if (!isValidCurrencyCode(currency)) {
throw new Error(`Invalid currency code: ${currency}`)
}
const majorAmount = fromCents(amount, currency)
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
let formatted = majorAmount.toFixed(config?.decimals ?? 2)
if (showSymbol && config?.symbol) {
formatted = `${config.symbol}${formatted}`
}
if (showCode) {
formatted += ` ${currency}`
}
return formatted
}
/**
* Gets currency information
*/
export function getCurrencyInfo(currency: string) {
if (!isValidCurrencyCode(currency)) {
throw new Error(`Invalid currency code: ${currency}`)
}
return CURRENCY_CONFIG[currency as SupportedCurrency] || {
name: currency,
decimals: 2,
symbol: currency
}
}
/**
* Validates amount is positive and properly formatted
*/
export function validateAmount(amount: number): void {
if (!Number.isFinite(amount)) {
throw new Error('Amount must be a finite number')
}
if (amount <= 0) {
throw new Error('Amount must be positive')
}
if (!Number.isInteger(amount)) {
throw new Error('Amount must be an integer (in cents)')
}
}

View File

@@ -1,3 +0,0 @@
export * from './currency'
export * from './logger'
export * from './validation'

View File

@@ -1,113 +1,48 @@
/**
* Structured logging utilities for the billing plugin
*/
import type { Payload } from 'payload'
export type LogLevel = 'debug' | 'error' | 'info' | 'warn'
export interface LogContext {
[key: string]: unknown
amount?: number
currency?: string
customerId?: string
invoiceId?: string
paymentId?: string
provider?: string
refundId?: string
webhookId?: string
}
export interface Logger {
debug(message: string, context?: LogContext): void
error(message: string, context?: LogContext): void
info(message: string, context?: LogContext): void
warn(message: string, context?: LogContext): void
}
let pluginLogger: any = null
/**
* Creates a structured logger with consistent formatting
* Get or create the plugin logger instance
* Uses PAYLOAD_BILLING_LOG_LEVEL environment variable to configure log level
* Defaults to 'info' if not set
*/
export function createLogger(namespace: string = 'BILLING'): Logger {
const log = (level: LogLevel, message: string, context: LogContext = {}) => {
const timestamp = new Date().toISOString()
const logData = {
level: level.toUpperCase(),
message,
namespace,
timestamp,
...context,
}
export function getPluginLogger(payload: Payload) {
if (!pluginLogger && payload.logger) {
const logLevel = process.env.PAYLOAD_BILLING_LOG_LEVEL || 'info'
// Use console methods based on log level
const consoleMethod = console[level] || console.log
consoleMethod(`[${namespace}] ${message}`, logData)
pluginLogger = payload.logger.child({
level: logLevel,
plugin: '@xtr-dev/payload-billing'
})
// Log the configured log level on first initialization
pluginLogger.info(`Logger initialized with level: ${logLevel}`)
}
// Fallback to console if logger not available (shouldn't happen in normal operation)
if (!pluginLogger) {
return {
debug: (...args: any[]) => console.log('[BILLING DEBUG]', ...args),
info: (...args: any[]) => console.log('[BILLING INFO]', ...args),
warn: (...args: any[]) => console.warn('[BILLING WARN]', ...args),
error: (...args: any[]) => console.error('[BILLING ERROR]', ...args),
}
}
return pluginLogger
}
/**
* Create a context-specific logger for a particular operation
*/
export function createContextLogger(payload: Payload, context: string) {
const logger = getPluginLogger(payload)
return {
debug: (message: string, context?: LogContext) => log('debug', message, context),
error: (message: string, context?: LogContext) => log('error', message, context),
info: (message: string, context?: LogContext) => log('info', message, context),
warn: (message: string, context?: LogContext) => log('warn', message, context),
debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args),
info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args),
warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args),
error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args),
}
}
/**
* Default logger instance for the plugin
*/
export const logger = createLogger('BILLING')
/**
* Creates a provider-specific logger
*/
export function createProviderLogger(providerName: string): Logger {
return createLogger(`BILLING:${providerName.toUpperCase()}`)
}
/**
* Log payment operations with consistent structure
*/
export function logPaymentOperation(
operation: string,
paymentId: string,
provider: string,
context?: LogContext
) {
logger.info(`Payment ${operation}`, {
operation,
paymentId,
provider,
...context,
})
}
/**
* Log webhook events with consistent structure
*/
export function logWebhookEvent(
provider: string,
eventType: string,
webhookId: string,
context?: LogContext
) {
logger.info(`Webhook received`, {
eventType,
provider,
webhookId,
...context,
})
}
/**
* Log errors with consistent structure
*/
export function logError(
error: Error,
operation: string,
context?: LogContext
) {
logger.error(`Operation failed: ${operation}`, {
error: error.message,
operation,
stack: error.stack,
...context,
})
}

View File

@@ -1,181 +0,0 @@
/**
* Validation utilities for billing data
*/
import { z } from 'zod'
import { isValidCurrencyCode } from './currency'
/**
* Zod schema for payment creation options
*/
export const createPaymentSchema = z.object({
amount: z.number().int().positive('Amount must be positive').min(1, 'Amount must be at least 1 cent'),
cancelUrl: z.string().url('Invalid cancel URL').optional(),
currency: z.string().length(3, 'Currency must be 3 characters').regex(/^[A-Z]{3}$/, 'Currency must be uppercase'),
customer: z.string().optional(),
description: z.string().max(500, 'Description too long').optional(),
metadata: z.record(z.unknown()).optional(),
returnUrl: z.string().url('Invalid return URL').optional(),
})
/**
* Zod schema for customer data
*/
export const customerSchema = z.object({
name: z.string().max(100, 'Name too long').optional(),
address: z.object({
city: z.string().max(50).optional(),
country: z.string().length(2, 'Country must be 2 characters').regex(/^[A-Z]{2}$/, 'Country must be uppercase').optional(),
line1: z.string().max(100).optional(),
line2: z.string().max(100).optional(),
postal_code: z.string().max(20).optional(),
state: z.string().max(50).optional(),
}).optional(),
email: z.string().email('Invalid email address').optional(),
metadata: z.record(z.unknown()).optional(),
phone: z.string().max(20, 'Phone number too long').optional(),
})
/**
* Zod schema for invoice items
*/
export const invoiceItemSchema = z.object({
description: z.string().min(1, 'Description is required').max(200, 'Description too long'),
quantity: z.number().int().positive('Quantity must be positive'),
unitAmount: z.number().int().min(0, 'Unit amount must be non-negative'),
})
/**
* Zod schema for invoice creation
*/
export const invoiceSchema = z.object({
currency: z.string().length(3).regex(/^[A-Z]{3}$/),
customer: z.string().min(1, 'Customer is required'),
dueDate: z.string().datetime().optional(),
items: z.array(invoiceItemSchema).min(1, 'At least one item is required'),
metadata: z.record(z.unknown()).optional(),
notes: z.string().max(1000).optional(),
taxAmount: z.number().int().min(0).default(0),
})
/**
* Validates payment creation data
*/
export function validateCreatePayment(data: unknown) {
const result = createPaymentSchema.safeParse(data)
if (!result.success) {
throw new Error(`Invalid payment data: ${result.error.issues.map(i => i.message).join(', ')}`)
}
// Additional currency validation
if (!isValidCurrencyCode(result.data.currency)) {
throw new Error(`Unsupported currency: ${result.data.currency}`)
}
return result.data
}
/**
* Validates customer data
*/
export function validateCustomer(data: unknown) {
const result = customerSchema.safeParse(data)
if (!result.success) {
throw new Error(`Invalid customer data: ${result.error.issues.map(i => i.message).join(', ')}`)
}
return result.data
}
/**
* Validates invoice data
*/
export function validateInvoice(data: unknown) {
const result = invoiceSchema.safeParse(data)
if (!result.success) {
throw new Error(`Invalid invoice data: ${result.error.issues.map(i => i.message).join(', ')}`)
}
// Additional currency validation
if (!isValidCurrencyCode(result.data.currency)) {
throw new Error(`Unsupported currency: ${result.data.currency}`)
}
return result.data
}
/**
* Validates webhook signature format
*/
export function validateWebhookSignature(signature: string, provider: string): void {
if (!signature) {
throw new Error(`Missing webhook signature for ${provider}`)
}
switch (provider) {
case 'mollie':
if (signature.length < 32) {
throw new Error('Invalid Mollie webhook signature length')
}
break
case 'stripe':
if (!signature.startsWith('t=')) {
throw new Error('Invalid Stripe webhook signature format')
}
break
case 'test':
// Test provider accepts any signature
break
default:
throw new Error(`Unknown provider: ${provider}`)
}
}
/**
* Validates payment provider name
*/
export function validateProviderName(provider: string): void {
const validProviders = ['stripe', 'mollie', 'test']
if (!validProviders.includes(provider)) {
throw new Error(`Invalid provider: ${provider}. Must be one of: ${validProviders.join(', ')}`)
}
}
/**
* Validates payment amount and currency combination
*/
export function validateAmountAndCurrency(amount: number, currency: string): void {
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error('Amount must be a positive integer')
}
if (!isValidCurrencyCode(currency)) {
throw new Error('Invalid currency code')
}
// Validate minimum amounts for different currencies
const minimums: Record<string, number> = {
EUR: 50, // €0.50
GBP: 30, // £0.30
JPY: 50, // ¥50
USD: 50, // $0.50
}
const minimum = minimums[currency] || 50
if (amount < minimum) {
throw new Error(`Amount too small for ${currency}. Minimum: ${minimum} cents`)
}
}
/**
* Validates refund amount against original payment
*/
export function validateRefundAmount(refundAmount: number, paymentAmount: number): void {
if (!Number.isInteger(refundAmount) || refundAmount <= 0) {
throw new Error('Refund amount must be a positive integer')
}
if (refundAmount > paymentAmount) {
throw new Error('Refund amount cannot exceed original payment amount')
}
}