30 Commits

Author SHA1 Message Date
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
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
33 changed files with 1857 additions and 1541 deletions

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

View File

@@ -24,30 +24,46 @@ pnpm add @xtr-dev/payload-billing
yarn add @xtr-dev/payload-billing
```
### 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
```typescript
import { buildConfig } from 'payload'
import { billingPlugin } from '@xtr-dev/payload-billing'
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: {
enabled: process.env.NODE_ENV === 'development',
autoComplete: true,
}
webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
}),
],
collections: {
payments: 'payments',
invoices: 'invoices',
refunds: 'refunds',
}
})
]
@@ -60,11 +76,11 @@ export default buildConfig({
// Main plugin
import { billingPlugin } from '@xtr-dev/payload-billing'
// Provider utilities
import { getPaymentProvider } from '@xtr-dev/payload-billing'
// Payment providers
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
// Types
import type { PaymentProvider, CreatePaymentOptions, Payment } from '@xtr-dev/payload-billing'
import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing'
```
## Provider Types
@@ -83,16 +99,14 @@ Local development testing with configurable scenarios, automatic completion, deb
The plugin adds these collections:
- **payments** - Payment transactions with status and provider data
- **customers** - Customer profiles with billing information
- **invoices** - Invoice generation with line items and PDF support
- **refunds** - Refund tracking and management
## Webhook Endpoints
Automatic webhook endpoints are created:
- `/api/billing/webhooks/stripe`
- `/api/billing/webhooks/mollie`
- `/api/billing/webhooks/test`
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

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>;
@@ -174,7 +172,6 @@ export interface Payment {
* Payment description
*/
description?: string | null;
customer?: (number | null) | Customer;
invoice?: (number | null) | Invoice;
/**
* Additional metadata for the payment
@@ -204,70 +201,6 @@ export interface Payment {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers".
*/
export interface Customer {
id: number;
/**
* 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?: (number | Payment)[] | null;
/**
* Customer invoices
*/
invoices?: (number | Invoice)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "invoices".
@@ -278,7 +211,57 @@ export interface Invoice {
* Invoice number (e.g., INV-001)
*/
number: string;
customer: number | 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)
@@ -422,10 +405,6 @@ export interface PayloadLockedDocument {
relationTo: 'payments';
value: number | Payment;
} | null)
| ({
relationTo: 'customers';
value: number | Customer;
} | null)
| ({
relationTo: 'invoices';
value: number | Invoice;
@@ -516,7 +495,6 @@ export interface PaymentsSelect<T extends boolean = true> {
amount?: T;
currency?: T;
description?: T;
customer?: T;
invoice?: T;
metadata?: T;
providerData?: T;
@@ -526,36 +504,29 @@ export interface PaymentsSelect<T extends boolean = true> {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers_select".
* via the `definition` "invoices_select".
*/
export interface CustomersSelect<T extends boolean = true> {
email?: T;
name?: T;
phone?: T;
address?:
export interface InvoicesSelect<T extends boolean = true> {
number?: T;
customerInfo?:
| T
| {
name?: T;
email?: T;
phone?: T;
company?: T;
taxId?: T;
};
billingAddress?:
| T
| {
line1?: T;
line2?: T;
city?: T;
state?: T;
postal_code?: T;
postalCode?: T;
country?: T;
};
providerIds?: T;
metadata?: T;
payments?: T;
invoices?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "invoices_select".
*/
export interface InvoicesSelect<T extends boolean = true> {
number?: T;
customer?: T;
status?: T;
currency?: T;
items?:

View File

@@ -2,12 +2,13 @@ import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { billingPlugin, defaultCustomerInfoExtractor } from '../dist/index.js'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter'
import { seed } from './seed'
import billingPlugin from '../src/plugin'
import { mollieProvider } from '../src/providers'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -48,36 +49,16 @@ const buildConfigWithSQLite = () => {
},
plugins: [
billingPlugin({
providers: {
test: {
enabled: true,
autoComplete: true,
}
},
providers: [
mollieProvider({
apiKey: process.env.MOLLIE_KEY!
})
],
collections: {
payments: 'payments',
customers: 'customers',
invoices: 'invoices',
refunds: 'refunds',
// customerRelation: false, // Set to false to disable customer relationship in invoices
// customerRelation: 'clients', // Or set to a custom collection slug
},
// Use the default extractor for the built-in customer collection
customerInfoExtractor: defaultCustomerInfoExtractor,
// Or provide a custom extractor for your own customer collection structure:
// customerInfoExtractor: (customer) => ({
// name: customer.fullName,
// email: customer.contactEmail,
// phone: customer.phoneNumber,
// company: customer.companyName,
// taxId: customer.vatNumber,
// billingAddress: {
// line1: customer.billing.street,
// city: customer.billing.city,
// postalCode: customer.billing.zip,
// country: customer.billing.countryCode,
// }
// })
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

View File

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

@@ -44,6 +44,7 @@ export default [
'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.2",
"version": "0.1.5",
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT",
"type": "module",
@@ -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": {

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,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: 'postalCode',
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,
}
}

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

@@ -0,0 +1,11 @@
import type { Payment } from '../plugin/types/index.js'
import type { Payload } from 'payload'
import { useBillingPlugin } from '../plugin/index.js'
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => {
const billing = useBillingPlugin(payload)
if (!payment.provider || !billing.providerConfig[payment.provider]) {
throw new Error(`Provider ${payment.provider} not found.`)
}
return billing.providerConfig[payment.provider].initPayment(payload, payment)
}

View File

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

View File

@@ -1,23 +1,303 @@
import type { CollectionConfig } from 'payload'
import type {
import {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
InvoiceData,
InvoiceDocument,
InvoiceItemData
} from '../types/payload'
import type { CustomerInfoExtractor } from '../types'
CollectionConfig, Field,
} from 'payload'
import type { BillingPluginConfig} from '../plugin/config.js';
import { defaults } from '../plugin/config.js'
import { extractSlug } from '../plugin/utils.js'
import type { Invoice } from '../plugin/types/invoices.js'
export function createInvoicesCollection(
slug: string = 'invoices',
customerCollectionSlug?: string,
customerInfoExtractor?: CustomerInfoExtractor
): 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,
@@ -29,298 +309,19 @@ export function createInvoicesCollection(
group: 'Billing',
useAsTitle: 'number',
},
fields: [
{
name: 'number',
type: 'text',
admin: {
description: 'Invoice number (e.g., INV-001)',
},
index: true,
required: true,
unique: true,
},
// Optional customer relationship
...(customerCollectionSlug ? [{
name: 'customer',
type: 'relationship' as const,
admin: {
position: 'sidebar' as const,
description: 'Link to customer record (optional)',
},
relationTo: customerCollectionSlug as any,
required: false,
}] : []),
// Basic customer info fields (embedded)
{
name: 'customerInfo',
type: 'group',
admin: {
description: customerCollectionSlug && customerInfoExtractor
? 'Customer billing information (auto-populated from customer relationship)'
: 'Customer billing information',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
fields: [
{
name: 'name',
type: 'text',
admin: {
description: 'Customer name',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug || !customerInfoExtractor,
},
{
name: 'email',
type: 'email',
admin: {
description: 'Customer email address',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug || !customerInfoExtractor,
},
{
name: 'phone',
type: 'text',
admin: {
description: 'Customer phone number',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
name: 'company',
type: 'text',
admin: {
description: 'Company name (optional)',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
name: 'taxId',
type: 'text',
admin: {
description: 'Tax ID or VAT number',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
],
},
{
name: 'billingAddress',
type: 'group',
admin: {
description: customerCollectionSlug && customerInfoExtractor
? 'Billing address (auto-populated from customer relationship)'
: 'Billing address',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
fields: [
{
name: 'line1',
type: 'text',
admin: {
description: 'Address line 1',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug || !customerInfoExtractor,
},
{
name: 'line2',
type: 'text',
admin: {
description: 'Address line 2',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
name: 'city',
type: 'text',
admin: {
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug || !customerInfoExtractor,
},
{
name: 'state',
type: 'text',
admin: {
description: 'State or province',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
},
{
name: 'postalCode',
type: 'text',
admin: {
description: 'Postal or ZIP code',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
required: !customerCollectionSlug || !customerInfoExtractor,
},
{
name: 'country',
type: 'text',
admin: {
description: 'Country code (e.g., US, GB)',
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
},
maxLength: 2,
required: !customerCollectionSlug || !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: 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}`)
}
},
],
] satisfies CollectionAfterChangeHook<Invoice>[],
beforeChange: [
async ({ data, operation, req, originalDoc }: CollectionBeforeChangeHook<InvoiceData>) => {
async ({ data, operation, req, originalDoc }) => {
// Sync customer info from relationship if extractor is provided
if (customerCollectionSlug && customerInfoExtractor && data.customer) {
if (customerRelationSlug && customerInfoExtractor && data.customer) {
// Check if customer changed or this is a new invoice
const customerChanged = operation === 'create' ||
(originalDoc && originalDoc.customer !== data.customer)
@@ -329,8 +330,8 @@ export function createInvoicesCollection(
try {
// Fetch the customer data
const customer = await req.payload.findByID({
collection: customerCollectionSlug,
id: data.customer,
collection: customerRelationSlug as never,
id: data.customer as never,
})
// Extract customer info using the provided callback
@@ -383,37 +384,37 @@ export function createInvoicesCollection(
data.paidAt = new Date().toISOString()
}
},
],
] satisfies CollectionBeforeChangeHook<Invoice>[],
beforeValidate: [
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
({ data }) => {
if (!data) return
// If using extractor, customer relationship is required
if (customerCollectionSlug && customerInfoExtractor && !data.customer) {
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 (customerCollectionSlug && !customerInfoExtractor &&
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 (!customerCollectionSlug && (!data.customerInfo?.name || !data.customerInfo?.email)) {
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
)
@@ -421,8 +422,8 @@ export function createInvoicesCollection(
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.js';
import { defaults } from '../plugin/config.js'
import { extractSlug } from '../plugin/utils.js'
import type { Payment } from '../plugin/types/payments.js'
import { initProviderPayment } from './hooks.js'
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,12 @@
import type { CollectionConfig } from 'payload'
import type { AccessArgs, CollectionConfig } from 'payload'
import { BillingPluginConfig, defaults } from '../plugin/config.js'
import { extractSlug } from '../plugin/utils.js'
import { Payment } from '../plugin/types/index.js'
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 +35,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
admin: {
position: 'sidebar',
},
relationTo: 'payments',
relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
required: true,
},
{
@@ -113,7 +109,7 @@ 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}`)
@@ -121,15 +117,15 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
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 as any],
refunds: [...refundIds, doc.id],
},
})
} catch (error) {
@@ -139,7 +135,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<RefundData>) => {
({ data, operation }) => {
if (operation === 'create') {
// Validate amount format
if (data.amount && !Number.isInteger(data.amount)) {

View File

@@ -1,98 +1,8 @@
import type { Config } from 'payload'
import type { BillingPluginConfig, CustomerInfoExtractor } from './types'
import { createCustomersCollection } from './collections/customers'
import { createInvoicesCollection } from './collections/invoices'
import { createPaymentsCollection } from './collections/payments'
import { createRefundsCollection } from './collections/refunds'
export * from './types'
// Default customer info extractor for the built-in customer collection
export const defaultCustomerInfoExtractor: CustomerInfoExtractor = (customer) => {
return {
name: customer.name || '',
email: customer.email || '',
phone: customer.phone,
company: customer.company,
taxId: customer.taxId,
billingAddress: customer.address ? {
line1: customer.address.line1 || '',
line2: customer.address.line2,
city: customer.address.city || '',
state: customer.address.state,
postalCode: customer.address.postalCode || '',
country: customer.address.country || '',
} : undefined,
}
}
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) {
return config
}
// Initialize collections
if (!config.collections) {
config.collections = []
}
const customerSlug = pluginConfig.collections?.customers || 'customers'
config.collections.push(
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
createCustomersCollection(customerSlug),
createInvoicesCollection(
pluginConfig.collections?.invoices || 'invoices',
pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined,
pluginConfig.customerInfoExtractor
),
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
)
// Initialize endpoints
if (!config.endpoints) {
config.endpoints = []
}
config.endpoints?.push(
// Webhook endpoints
{
handler: (_req) => {
try {
const provider = null
if (!provider) {
return Response.json({ error: 'Provider not found' }, { status: 404 })
}
// TODO: Process webhook event and update database
return Response.json({ received: true })
} catch (error) {
// TODO: Use proper logger instead of console
// eslint-disable-next-line no-console
console.error('[BILLING] Webhook error:', error)
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
}
},
method: 'post',
path: '/billing/webhooks/:provider'
},
)
// Initialize providers and onInit hook
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
// Execute any existing onInit functions first
if (incomingOnInit) {
await incomingOnInit(payload)
}
}
return config
}
export default billingPlugin
export { billingPlugin } from './plugin/index.js'
export { mollieProvider, stripeProvider } from './providers/index.js'
export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config.js'
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
export type { PaymentProvider, ProviderData } from './providers/types.js'
export type { MollieProviderConfig } from './providers/mollie.js'
export type { StripeProviderConfig } from './providers/stripe.js'

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

@@ -0,0 +1,57 @@
import { CollectionConfig } from 'payload'
import { FieldsOverride } from './utils.js'
import { PaymentProvider } from './types/index.js'
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
}
// 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[]
}

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

@@ -0,0 +1,56 @@
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index.js'
import type { BillingPluginConfig } from './config.js'
import type { Config, Payload } from 'payload'
import { createSingleton } from './singleton.js'
import type { PaymentProvider } from '../providers/index.js'
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 || []).reduce(
(record, provider) => {
record[provider.key] = provider
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.js'
import { Id } from './id.js'
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.js'
import { Invoice } from './invoices.js'
import { Id } from './id.js'
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.js'
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.js'
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
}

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
}

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

@@ -0,0 +1,4 @@
export * from './mollie.js'
export * from './stripe.js'
export * from './types.js'
export * from './currency.js'

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

@@ -0,0 +1,159 @@
import type { Payment } from '../plugin/types/payments.js'
import type { PaymentProvider } from '../plugin/types/index.js'
import type { Payload } from 'payload'
import { createSingleton } from '../plugin/singleton.js'
import type { createMollieClient, MollieClient } from '@mollie/api-client'
import {
webhookResponses,
findPaymentByProviderId,
updatePaymentStatus,
updateInvoiceOnPaymentSuccess,
handleWebhookError,
validateProductionUrl
} from './utils.js'
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency.js'
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) {
console.warn(`[Mollie Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Mollie', error)
}
}
}
]
},
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
}

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

@@ -0,0 +1,260 @@
import type { Payment } from '../plugin/types/payments.js'
import type { PaymentProvider, ProviderData } from '../plugin/types/index.js'
import type { Payload } from 'payload'
import { createSingleton } from '../plugin/singleton.js'
import type Stripe from 'stripe'
import {
webhookResponses,
findPaymentByProviderId,
updatePaymentStatus,
updateInvoiceOnPaymentSuccess,
handleWebhookError,
logWebhookEvent
} from './utils.js'
import { isValidAmount, isValidCurrencyCode } from './currency.js'
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')
}
const signature = req.headers.get('stripe-signature')
if (!signature) {
return webhookResponses.error('Missing webhook signature', 400)
}
// 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')
}
// 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}`)
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) {
console.warn(`[Stripe Webhook] 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) {
console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`)
}
}
break
}
default:
// Unhandled event type
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Stripe', error)
}
}
}
]
} else {
// Log that webhook endpoint is not registered
console.warn('[Stripe Provider] Webhook endpoint not registered - webhookSecret not configured')
}
},
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)
},
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
}

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

@@ -0,0 +1,21 @@
import type { Payment } from '../plugin/types/payments.js'
import type { Config, Payload } from 'payload'
import type { BillingPluginConfig } from '../plugin/config.js'
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<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
}

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

@@ -0,0 +1,207 @@
import type { Payload } from 'payload'
import type { Payment } from '../plugin/types/payments.js'
import type { BillingPluginConfig } from '../plugin/config.js'
import type { ProviderData } from './types.js'
import { defaults } from '../plugin/config.js'
import { extractSlug, toPayloadId } from '../plugin/utils.js'
/**
* 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) => {
// Log error internally but don't expose details
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) {
console.error(`[Payment Update] 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) {
console.error(`[Payment Update] 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
console.warn(`[Payment Update] 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) {
console.error(`[Payment Update] 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
): Response {
const message = error instanceof Error ? error.message : 'Unknown error'
const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]`
// Log detailed error internally for debugging
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
): void {
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,260 +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
}
// 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?: {
customerRelation?: boolean | string // false to disable, string for custom collection slug
customers?: string
invoices?: string
payments?: string
refunds?: string
}
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
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
postalCode?: 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
billingAddress?: {
city: string
country: string
line1: string
line2?: string
postalCode: string
state?: string
}
createdAt: string
currency: string
customer?: string // Optional relationship to customer collection
customerInfo?: {
company?: string
email: string
name: string
phone?: string
taxId?: 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,178 +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
billingAddress?: {
city?: string
country?: string
line1?: string
line2?: string
postalCode?: string
state?: string
}
currency?: string
customer?: string // Optional relationship
customerInfo?: {
company?: string
email?: string
name?: string
phone?: string
taxId?: 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 | number
status?: string
}
// Customer data type for hooks
export interface CustomerData {
address?: {
city?: string
country?: string
line1?: string
line2?: string
postalCode?: string
state?: string
}
email?: string
metadata?: Record<string, unknown>
name?: string
phone?: string
providerIds?: Record<string, string | number>
}
// Refund data type for hooks
export interface RefundData {
amount?: number
currency?: string
description?: string
metadata?: Record<string, unknown>
payment?: { id: string | number } | string
providerData?: Record<string, unknown>
providerId?: string | number
reason?: string
status?: string
}
// Document types with required fields after creation
export interface PaymentDocument extends PaymentData {
amount: number
createdAt: string
currency: string
id: string | number
provider: string
providerId: string | number
status: string
updatedAt: string
}
export interface CustomerDocument extends CustomerData {
createdAt: string
id: string | number
updatedAt: string
}
export interface InvoiceDocument extends InvoiceData {
amount: number
createdAt: string
currency: string
customer?: string // Optional relationship
customerInfo?: { // Optional when customer relationship exists
company?: string
email: string
name: string
phone?: string
taxId?: string
}
billingAddress?: { // Optional when customer relationship exists
city: string
country: string
line1: string
line2?: string
postalCode: string
state?: string
}
id: string | number
items: InvoiceItemData[]
number: string
status: string
updatedAt: string
}
export interface RefundDocument extends RefundData {
amount: number
createdAt: string
currency: string
id: string | number
payment: { id: string } | string
providerId: string
refunds?: string[]
status: string
updatedAt: string
}