mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-08 00:33:23 +00:00
Add PayloadCMS type definitions, Prettier config, and PNPM lockfile
This commit is contained in:
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2
|
||||
}
|
||||
30
.swcrc
Normal file
30
.swcrc
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": false
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
},
|
||||
"externalHelpers": false,
|
||||
"keepClassNames": false,
|
||||
"loose": false
|
||||
},
|
||||
"module": {
|
||||
"type": "es6",
|
||||
"strict": false,
|
||||
"strictMode": true,
|
||||
"lazy": false,
|
||||
"noInterop": false
|
||||
}
|
||||
}
|
||||
44
CHANGELOG.md
Normal file
44
CHANGELOG.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @xtr-dev/payload-billing
|
||||
|
||||
## 0.1.0 (Initial Release)
|
||||
|
||||
### Features
|
||||
|
||||
- **Payment Providers**: Initial support for Stripe, Mollie, and Test providers
|
||||
- **PayloadCMS Integration**: Pre-configured collections for payments, customers, invoices, and refunds
|
||||
- **Test Provider**: Full-featured test payment provider for local development
|
||||
- **TypeScript Support**: Complete TypeScript definitions and type safety
|
||||
- **Webhook Handling**: Robust webhook processing for all supported providers
|
||||
- **Currency Support**: Multi-currency support with validation and formatting utilities
|
||||
- **Logging**: Structured logging system for debugging and monitoring
|
||||
- **Validation**: Comprehensive data validation using Zod schemas
|
||||
|
||||
### Collections
|
||||
|
||||
- **Payments**: Track payment status, amounts, and provider-specific data
|
||||
- **Customers**: Customer management with billing information and relationships
|
||||
- **Invoices**: Invoice generation with line items and status tracking
|
||||
- **Refunds**: Refund tracking with relationship to original payments
|
||||
|
||||
### Provider Features
|
||||
|
||||
#### Test Provider
|
||||
- Configurable auto-completion of payments
|
||||
- Failure simulation for testing error scenarios
|
||||
- Delay simulation for testing async operations
|
||||
- In-memory storage for development
|
||||
- Full webhook event simulation
|
||||
|
||||
#### Extensible Architecture
|
||||
- Common provider interface for easy extension
|
||||
- Provider registry system
|
||||
- Standardized error handling
|
||||
- Consistent logging across providers
|
||||
|
||||
### Developer Experience
|
||||
|
||||
- **Testing**: Comprehensive test suite with Jest
|
||||
- **Build System**: Modern build setup with tsup
|
||||
- **Linting**: ESLint configuration with TypeScript support
|
||||
- **Documentation**: Complete API documentation and usage examples
|
||||
- **Development**: Hot reloading and watch mode support
|
||||
162
CLAUDE.md
Normal file
162
CLAUDE.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# PayloadCMS Billing Plugin Development Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a PayloadCMS plugin that provides billing and payment functionality with multiple payment provider integrations (Stripe, Mollie) and a test payment provider for local development.
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Core Design
|
||||
- **Provider Abstraction**: All payment providers implement a common interface for consistency
|
||||
- **TypeScript First**: Full TypeScript support with strict typing throughout
|
||||
- **PayloadCMS Integration**: Deep integration with Payload collections, hooks, and admin UI
|
||||
- **Extensible**: Easy to add new payment providers through the common interface
|
||||
- **Developer Experience**: Comprehensive testing tools and local development support
|
||||
|
||||
### Payment Provider Interface
|
||||
All payment providers must implement the `PaymentProvider` interface:
|
||||
```typescript
|
||||
interface PaymentProvider {
|
||||
createPayment(options: CreatePaymentOptions): Promise<Payment>
|
||||
retrievePayment(id: string): Promise<Payment>
|
||||
cancelPayment(id: string): Promise<Payment>
|
||||
refundPayment(id: string, amount?: number): Promise<Refund>
|
||||
handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
|
||||
}
|
||||
```
|
||||
|
||||
### Collections Structure
|
||||
- **Payments**: Core payment tracking with provider-specific data
|
||||
- **Customers**: Customer management with billing information
|
||||
- **Invoices**: Invoice generation and management
|
||||
- **Refunds**: Refund tracking and management
|
||||
|
||||
## Code Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── providers/ # Payment provider implementations
|
||||
│ ├── stripe/ # Stripe integration
|
||||
│ ├── mollie/ # Mollie integration
|
||||
│ ├── test/ # Test provider for development
|
||||
│ └── base/ # Base provider interface and utilities
|
||||
├── collections/ # PayloadCMS collection configurations
|
||||
├── endpoints/ # API endpoints (webhooks, etc.)
|
||||
├── hooks/ # PayloadCMS lifecycle hooks
|
||||
├── admin/ # Admin UI components and extensions
|
||||
├── types/ # TypeScript type definitions
|
||||
└── utils/ # Shared utilities and helpers
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Payment Provider Development
|
||||
1. **Implement Base Interface**: All providers must implement `PaymentProvider`
|
||||
2. **Error Handling**: Use consistent error types and proper error propagation
|
||||
3. **Webhook Security**: Always verify webhook signatures and implement replay protection
|
||||
4. **Idempotency**: Support idempotent operations where possible
|
||||
5. **Logging**: Use structured logging for debugging and monitoring
|
||||
|
||||
### Testing Strategy
|
||||
- **Unit Tests**: Test individual provider methods and utilities
|
||||
- **Integration Tests**: Test provider integrations with mock APIs
|
||||
- **E2E Tests**: Test complete payment flows using test provider
|
||||
- **Webhook Tests**: Test webhook handling with various scenarios
|
||||
|
||||
### TypeScript Guidelines
|
||||
- Use strict TypeScript configuration
|
||||
- Define proper interfaces for all external API responses
|
||||
- Use discriminated unions for provider-specific data
|
||||
- Implement proper generic types for extensibility
|
||||
|
||||
### PayloadCMS Integration
|
||||
- Follow PayloadCMS plugin patterns and conventions
|
||||
- Use proper collection configurations with access control
|
||||
- Implement admin UI components using PayloadCMS patterns
|
||||
- Utilize PayloadCMS hooks for business logic
|
||||
|
||||
### Security Considerations
|
||||
- **Webhook Verification**: Always verify webhook signatures
|
||||
- **API Key Storage**: Use environment variables for sensitive data
|
||||
- **Access Control**: Implement proper PayloadCMS access control
|
||||
- **Input Validation**: Validate all inputs and sanitize data
|
||||
- **Audit Logging**: Log all payment operations for audit trails
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
```bash
|
||||
# Stripe Configuration
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Mollie Configuration
|
||||
MOLLIE_API_KEY=test_...
|
||||
MOLLIE_WEBHOOK_URL=https://yourapp.com/api/billing/webhooks/mollie
|
||||
|
||||
# Test Provider Configuration
|
||||
NODE_ENV=development # Enables test provider
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
1. Use test provider for local development
|
||||
2. Configure webhook forwarding tools (ngrok, etc.) for local webhook testing
|
||||
3. Use provider sandbox/test modes during development
|
||||
4. Implement comprehensive logging for debugging
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
### Basic Configuration
|
||||
```typescript
|
||||
billingPlugin({
|
||||
providers: {
|
||||
// Provider configurations
|
||||
},
|
||||
collections: {
|
||||
// Collection name overrides
|
||||
},
|
||||
webhooks: {
|
||||
// Webhook configuration
|
||||
},
|
||||
admin: {
|
||||
// Admin UI configuration
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
- Custom collection schemas
|
||||
- Provider-specific options
|
||||
- Webhook endpoint customization
|
||||
- Admin UI customization
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Provider Errors
|
||||
- Map provider-specific errors to common error types
|
||||
- Preserve original error information for debugging
|
||||
- Implement proper retry logic for transient failures
|
||||
|
||||
### Webhook Errors
|
||||
- Handle duplicate webhooks gracefully
|
||||
- Implement proper error responses for webhook failures
|
||||
- Log webhook processing errors with context
|
||||
|
||||
## Performance Considerations
|
||||
- Implement proper caching where appropriate
|
||||
- Use database indexes for payment queries
|
||||
- Optimize webhook processing for high throughput
|
||||
- Consider rate limiting for API endpoints
|
||||
|
||||
## Monitoring and Observability
|
||||
- Log all payment operations with structured data
|
||||
- Track payment success/failure rates
|
||||
- Monitor webhook processing times
|
||||
- Implement health check endpoints
|
||||
|
||||
## Documentation Requirements
|
||||
- Document all public APIs with examples
|
||||
- Provide integration guides for each payment provider
|
||||
- Include troubleshooting guides for common issues
|
||||
- Maintain up-to-date TypeScript documentation
|
||||
195
README.md
Normal file
195
README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 💳 @xtr-dev/payload-billing
|
||||
|
||||
PayloadCMS plugin for billing and payment provider integrations with comprehensive tracking and local testing support.
|
||||
|
||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### Payment Providers
|
||||
- **🔶 Stripe Integration** - Full Stripe payment processing support
|
||||
- **🟠 Mollie Integration** - Complete Mollie payment gateway integration
|
||||
- **🧪 Test Provider** - Local development and testing payment provider
|
||||
- **🔧 Extensible Architecture** - Easy to add new payment providers
|
||||
|
||||
### Payment Tracking & Management
|
||||
- **📊 Transaction History** - Complete payment tracking and history
|
||||
- **🔄 Payment Status Management** - Real-time payment status updates
|
||||
- **💰 Amount & Currency Handling** - Multi-currency support
|
||||
- **📋 Invoice Generation** - Automatic invoice creation and management
|
||||
- **🏷️ Metadata Support** - Custom metadata for payments and customers
|
||||
|
||||
### Developer Experience
|
||||
- **🛠️ Local Development** - Test provider for local development
|
||||
- **🪝 Webhook Handling** - Robust webhook processing for all providers
|
||||
- **📝 TypeScript Support** - Full TypeScript definitions and type safety
|
||||
- **🔍 Debugging Tools** - Built-in logging and debugging capabilities
|
||||
- **📚 Documentation** - Comprehensive API documentation
|
||||
|
||||
### PayloadCMS Integration
|
||||
- **⚡ Admin UI Extensions** - Payment management directly in Payload admin
|
||||
- **🗃️ Collections** - Pre-configured payment, customer, and invoice collections
|
||||
- **🔐 Access Control** - Secure payment data with proper permissions
|
||||
- **🎯 Hooks & Events** - PayloadCMS lifecycle hooks for payment events
|
||||
|
||||
## 🏗️ Installation
|
||||
|
||||
```bash
|
||||
npm install @xtr-dev/payload-billing
|
||||
# or
|
||||
yarn add @xtr-dev/payload-billing
|
||||
# or
|
||||
pnpm add @xtr-dev/payload-billing
|
||||
```
|
||||
|
||||
## ⚙️ Quick Setup
|
||||
|
||||
```typescript
|
||||
import { billingPlugin } from '@xtr-dev/payload-billing'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
billingPlugin({
|
||||
providers: {
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY!,
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
|
||||
webhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
},
|
||||
mollie: {
|
||||
apiKey: process.env.MOLLIE_API_KEY!,
|
||||
webhookUrl: process.env.MOLLIE_WEBHOOK_URL!,
|
||||
},
|
||||
// Test provider for local development
|
||||
test: {
|
||||
enabled: process.env.NODE_ENV === 'development',
|
||||
autoComplete: true, // Automatically complete payments
|
||||
}
|
||||
},
|
||||
collections: {
|
||||
payments: 'payments',
|
||||
customers: 'customers',
|
||||
invoices: 'invoices',
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 📋 Collections Added
|
||||
|
||||
### Payments Collection
|
||||
- Payment tracking with status, amount, currency
|
||||
- Provider-specific payment data
|
||||
- Customer relationships
|
||||
- Transaction metadata
|
||||
|
||||
### Customers Collection
|
||||
- Customer information and billing details
|
||||
- Payment method storage
|
||||
- Transaction history
|
||||
- Subscription management
|
||||
|
||||
### Invoices Collection
|
||||
- Invoice generation and management
|
||||
- PDF generation support
|
||||
- Payment status tracking
|
||||
- Line item details
|
||||
|
||||
## 🔌 Provider APIs
|
||||
|
||||
### Stripe Integration
|
||||
```typescript
|
||||
import { getPaymentProvider } from '@xtr-dev/payload-billing'
|
||||
|
||||
const stripe = getPaymentProvider('stripe')
|
||||
|
||||
// Create a payment
|
||||
const payment = await stripe.createPayment({
|
||||
amount: 2000, // $20.00
|
||||
currency: 'usd',
|
||||
customer: 'customer_id',
|
||||
metadata: { orderId: '12345' }
|
||||
})
|
||||
|
||||
// Handle webhooks
|
||||
await stripe.handleWebhook(request, signature)
|
||||
```
|
||||
|
||||
### Mollie Integration
|
||||
```typescript
|
||||
const mollie = getPaymentProvider('mollie')
|
||||
|
||||
// Create a payment
|
||||
const payment = await mollie.createPayment({
|
||||
amount: { value: '20.00', currency: 'EUR' },
|
||||
description: 'Order #12345',
|
||||
redirectUrl: 'https://example.com/return',
|
||||
webhookUrl: 'https://example.com/webhook'
|
||||
})
|
||||
```
|
||||
|
||||
### Test Provider
|
||||
```typescript
|
||||
const testProvider = getPaymentProvider('test')
|
||||
|
||||
// Simulate payment scenarios
|
||||
const payment = await testProvider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'usd',
|
||||
// Test-specific options
|
||||
simulateFailure: false,
|
||||
delayMs: 1000
|
||||
})
|
||||
```
|
||||
|
||||
## 🪝 Webhook Handling
|
||||
|
||||
The plugin automatically sets up webhook endpoints for all configured providers:
|
||||
|
||||
- `/api/billing/webhooks/stripe` - Stripe webhook endpoint
|
||||
- `/api/billing/webhooks/mollie` - Mollie webhook endpoint
|
||||
- `/api/billing/webhooks/test` - Test provider webhook endpoint
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/xtr-dev/payload-billing.git
|
||||
cd payload-billing
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the plugin
|
||||
pnpm build
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [API Reference](./docs/api.md)
|
||||
- [Provider Integration Guide](./docs/providers.md)
|
||||
- [Webhook Setup](./docs/webhooks.md)
|
||||
- [Testing Guide](./docs/testing.md)
|
||||
- [TypeScript Types](./docs/types.md)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [PayloadCMS](https://payloadcms.com)
|
||||
- [Stripe Documentation](https://stripe.com/docs)
|
||||
- [Mollie Documentation](https://docs.mollie.com)
|
||||
- [GitHub Repository](https://github.com/xtr-dev/payload-billing)
|
||||
16
dev/.env.example
Normal file
16
dev/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# PayloadCMS Configuration
|
||||
PAYLOAD_SECRET=your-secret-key-here
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URI=mongodb://localhost:27017/payload-billing-dev
|
||||
|
||||
# Payment Provider Configuration (Optional - for testing integrations)
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||
|
||||
MOLLIE_API_KEY=test_your_mollie_api_key
|
||||
MOLLIE_WEBHOOK_URL=http://localhost:3000/api/billing/webhooks/mollie
|
||||
|
||||
# Development Settings
|
||||
NODE_ENV=development
|
||||
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
|
||||
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, importMap, params, searchParams })
|
||||
|
||||
export default NotFound
|
||||
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
|
||||
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, importMap, params, searchParams })
|
||||
|
||||
export default Page
|
||||
5
dev/app/(payload)/admin/importMap.js
Normal file
5
dev/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
export const importMap = {
|
||||
|
||||
}
|
||||
19
dev/app/(payload)/api/[...slug]/route.ts
Normal file
19
dev/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
export const PUT = REST_PUT(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal file
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
8
dev/app/(payload)/api/graphql/route.ts
Normal file
8
dev/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
0
dev/app/(payload)/custom.scss
Normal file
0
dev/app/(payload)/custom.scss
Normal file
32
dev/app/(payload)/layout.tsx
Normal file
32
dev/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ServerFunctionClient } from 'payload'
|
||||
|
||||
import '@payloadcms/next/css'
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||
import React from 'react'
|
||||
|
||||
import { importMap } from './admin/importMap.js'
|
||||
import './custom.scss'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server'
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
12
dev/app/my-route/route.ts
Normal file
12
dev/app/my-route/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
message: 'This is an example of a custom route.',
|
||||
})
|
||||
}
|
||||
15
dev/e2e.spec.ts
Normal file
15
dev/e2e.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
// this is an example Playwright e2e test
|
||||
test('should render admin panel logo', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
|
||||
// login
|
||||
await page.fill('#field-email', 'dev@payloadcms.com')
|
||||
await page.fill('#field-password', 'test')
|
||||
await page.click('.form-submit button')
|
||||
|
||||
// should show dashboard
|
||||
await expect(page).toHaveTitle(/Dashboard/)
|
||||
await expect(page.locator('.graphic-icon')).toBeVisible()
|
||||
})
|
||||
4
dev/helpers/credentials.ts
Normal file
4
dev/helpers/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const devUser = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
}
|
||||
38
dev/helpers/testEmailAdapter.ts
Normal file
38
dev/helpers/testEmailAdapter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { EmailAdapter, SendEmailOptions } from 'payload'
|
||||
|
||||
/**
|
||||
* Logs all emails to stdout
|
||||
*/
|
||||
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
|
||||
name: 'test-email-adapter',
|
||||
defaultFromAddress: 'dev@payloadcms.com',
|
||||
defaultFromName: 'Payload Test',
|
||||
sendEmail: async (message) => {
|
||||
const stringifiedTo = getStringifiedToAddress(message)
|
||||
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
|
||||
payload.logger.info({ content: message, msg: res })
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
|
||||
let stringifiedTo: string | undefined
|
||||
|
||||
if (typeof message.to === 'string') {
|
||||
stringifiedTo = message.to
|
||||
} else if (Array.isArray(message.to)) {
|
||||
stringifiedTo = message.to
|
||||
.map((to: { address: string } | string) => {
|
||||
if (typeof to === 'string') {
|
||||
return to
|
||||
} else if (to.address) {
|
||||
return to.address
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.join(', ')
|
||||
} else if (message.to?.address) {
|
||||
stringifiedTo = message.to.address
|
||||
}
|
||||
return stringifiedTo
|
||||
}
|
||||
52
dev/int.spec.ts
Normal file
52
dev/int.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { createPayloadRequest, getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
|
||||
|
||||
let payload: Payload
|
||||
|
||||
afterAll(async () => {
|
||||
await payload.destroy()
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config })
|
||||
})
|
||||
|
||||
describe('Plugin integration tests', () => {
|
||||
test('should query custom endpoint added by plugin', async () => {
|
||||
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const payloadRequest = await createPayloadRequest({ config, request })
|
||||
const response = await customEndpointHandler(payloadRequest)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toMatchObject({
|
||||
message: 'Hello from custom endpoint',
|
||||
})
|
||||
})
|
||||
|
||||
test('can create post with custom text field added by plugin', async () => {
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
addedByPlugin: 'added by plugin',
|
||||
},
|
||||
})
|
||||
expect(post.addedByPlugin).toBe('added by plugin')
|
||||
})
|
||||
|
||||
test('plugin creates and seeds plugin-collection', async () => {
|
||||
expect(payload.collections['plugin-collection']).toBeDefined()
|
||||
|
||||
const { docs } = await payload.find({ collection: 'plugin-collection' })
|
||||
|
||||
expect(docs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
5
dev/next-env.d.ts
vendored
Normal file
5
dev/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
21
dev/next.config.mjs
Normal file
21
dev/next.config.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
'.cjs': ['.cts', '.cjs'],
|
||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
}
|
||||
|
||||
return webpackConfig
|
||||
},
|
||||
serverExternalPackages: ['mongodb-memory-server'],
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||
656
dev/payload-types.ts
Normal file
656
dev/payload-types.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
posts: Post;
|
||||
media: Media;
|
||||
payments: Payment;
|
||||
customers: Customer;
|
||||
invoices: Invoice;
|
||||
refunds: Refund;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
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>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payments".
|
||||
*/
|
||||
export interface Payment {
|
||||
id: string;
|
||||
provider: 'stripe' | 'mollie' | 'test';
|
||||
/**
|
||||
* The payment ID from the payment provider
|
||||
*/
|
||||
providerId: string;
|
||||
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;
|
||||
customer?: (string | null) | Customer;
|
||||
invoice?: (string | 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?: (string | Refund)[] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "customers".
|
||||
*/
|
||||
export interface Customer {
|
||||
id: string;
|
||||
/**
|
||||
* Customer email address
|
||||
*/
|
||||
email?: string | null;
|
||||
/**
|
||||
* Customer full name
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Customer phone number
|
||||
*/
|
||||
phone?: string | null;
|
||||
address?: {
|
||||
line1?: string | null;
|
||||
line2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postal_code?: string | null;
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 country code
|
||||
*/
|
||||
country?: string | null;
|
||||
};
|
||||
/**
|
||||
* Customer IDs from payment providers
|
||||
*/
|
||||
providerIds?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Additional customer metadata
|
||||
*/
|
||||
metadata?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Customer payments
|
||||
*/
|
||||
payments?: (string | Payment)[] | null;
|
||||
/**
|
||||
* Customer invoices
|
||||
*/
|
||||
invoices?: (string | Invoice)[] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "invoices".
|
||||
*/
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
/**
|
||||
* Invoice number (e.g., INV-001)
|
||||
*/
|
||||
number: string;
|
||||
customer: string | Customer;
|
||||
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?: string | 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?: (string | null) | Payment;
|
||||
/**
|
||||
* Internal notes
|
||||
*/
|
||||
notes?: string | null;
|
||||
/**
|
||||
* Additional invoice metadata
|
||||
*/
|
||||
metadata?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "refunds".
|
||||
*/
|
||||
export interface Refund {
|
||||
id: string;
|
||||
/**
|
||||
* The refund ID from the payment provider
|
||||
*/
|
||||
providerId: string;
|
||||
payment: string | 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;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: string | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'payments';
|
||||
value: string | Payment;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'customers';
|
||||
value: string | Customer;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'invoices';
|
||||
value: string | Invoice;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'refunds';
|
||||
value: string | Refund;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: string;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payments_select".
|
||||
*/
|
||||
export interface PaymentsSelect<T extends boolean = true> {
|
||||
provider?: T;
|
||||
providerId?: T;
|
||||
status?: T;
|
||||
amount?: T;
|
||||
currency?: T;
|
||||
description?: T;
|
||||
customer?: T;
|
||||
invoice?: T;
|
||||
metadata?: T;
|
||||
providerData?: T;
|
||||
refunds?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "customers_select".
|
||||
*/
|
||||
export interface CustomersSelect<T extends boolean = true> {
|
||||
email?: T;
|
||||
name?: T;
|
||||
phone?: T;
|
||||
address?:
|
||||
| T
|
||||
| {
|
||||
line1?: T;
|
||||
line2?: T;
|
||||
city?: T;
|
||||
state?: T;
|
||||
postal_code?: T;
|
||||
country?: T;
|
||||
};
|
||||
providerIds?: T;
|
||||
metadata?: T;
|
||||
payments?: T;
|
||||
invoices?: T;
|
||||
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?:
|
||||
| T
|
||||
| {
|
||||
description?: T;
|
||||
quantity?: T;
|
||||
unitAmount?: T;
|
||||
totalAmount?: T;
|
||||
id?: T;
|
||||
};
|
||||
subtotal?: T;
|
||||
taxAmount?: T;
|
||||
amount?: T;
|
||||
dueDate?: T;
|
||||
paidAt?: T;
|
||||
payment?: T;
|
||||
notes?: T;
|
||||
metadata?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "refunds_select".
|
||||
*/
|
||||
export interface RefundsSelect<T extends boolean = true> {
|
||||
providerId?: T;
|
||||
payment?: T;
|
||||
status?: T;
|
||||
amount?: T;
|
||||
currency?: T;
|
||||
reason?: T;
|
||||
description?: T;
|
||||
metadata?: T;
|
||||
providerData?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
84
dev/payload.config.ts
Normal file
84
dev/payload.config.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { MongoMemoryReplSet } from 'mongodb-memory-server'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { billingPlugin } from '../src/index.js'
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
||||
import { seed } from './seed.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
if (!process.env.ROOT_DIR) {
|
||||
process.env.ROOT_DIR = dirname
|
||||
}
|
||||
|
||||
const buildConfigWithMemoryDB = async () => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
const memoryDB = await MongoMemoryReplSet.create({
|
||||
replSet: {
|
||||
count: 3,
|
||||
dbName: 'payloadmemory',
|
||||
},
|
||||
})
|
||||
|
||||
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
|
||||
}
|
||||
|
||||
return buildConfig({
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
slug: 'posts',
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
slug: 'media',
|
||||
fields: [],
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, 'media'),
|
||||
},
|
||||
},
|
||||
],
|
||||
db: mongooseAdapter({
|
||||
ensureIndexes: true,
|
||||
url: process.env.DATABASE_URI || '',
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
email: testEmailAdapter,
|
||||
onInit: async (payload) => {
|
||||
await seed(payload)
|
||||
},
|
||||
plugins: [
|
||||
billingPlugin({
|
||||
providers: {
|
||||
test: {
|
||||
enabled: true,
|
||||
autoComplete: true,
|
||||
}
|
||||
},
|
||||
collections: {
|
||||
payments: 'payments',
|
||||
customers: 'customers',
|
||||
invoices: 'invoices',
|
||||
refunds: 'refunds',
|
||||
}
|
||||
}),
|
||||
],
|
||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||
sharp,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default buildConfigWithMemoryDB()
|
||||
149
dev/seed.ts
Normal file
149
dev/seed.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { devUser } from './helpers/credentials.js'
|
||||
|
||||
export const seed = async (payload: Payload) => {
|
||||
// Seed default user first
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!totalDocs) {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: devUser,
|
||||
})
|
||||
}
|
||||
|
||||
// Seed billing sample data
|
||||
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)
|
||||
}
|
||||
}
|
||||
35
dev/tsconfig.json
Normal file
35
dev/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"exclude": [],
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.mjs",
|
||||
"**/*.cjs",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"../src/**/*.ts",
|
||||
"../src/**/*.tsx",
|
||||
"next.config.mjs",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"./payload.config.ts"
|
||||
],
|
||||
"temp-plugin": [
|
||||
"../src/index.ts"
|
||||
],
|
||||
"temp-plugin/client": [
|
||||
"../src/exports/client.ts"
|
||||
],
|
||||
"temp-plugin/rsc": [
|
||||
"../src/exports/rsc.ts"
|
||||
]
|
||||
},
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false,
|
||||
}
|
||||
}
|
||||
46
eslint.config.js
Normal file
46
eslint.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// @ts-check
|
||||
|
||||
import payloadEsLintConfig from '@payloadcms/eslint-config'
|
||||
|
||||
export const defaultESLintIgnores = [
|
||||
'**/.temp',
|
||||
'**/.*', // ignore all dotfiles
|
||||
'**/.git',
|
||||
'**/.hg',
|
||||
'**/.pnp.*',
|
||||
'**/.svn',
|
||||
'**/playwright.config.ts',
|
||||
'**/vitest.config.js',
|
||||
'**/tsconfig.tsbuildinfo',
|
||||
'**/README.md',
|
||||
'**/eslint.config.js',
|
||||
'**/payload-types.ts',
|
||||
'**/dist/',
|
||||
'**/.yarn/',
|
||||
'**/build/',
|
||||
'**/node_modules/',
|
||||
'**/temp/',
|
||||
]
|
||||
|
||||
export default [
|
||||
...payloadEsLintConfig,
|
||||
{
|
||||
rules: {
|
||||
'no-restricted-exports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
projectService: {
|
||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
||||
allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'],
|
||||
},
|
||||
// projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
147
package.json
Normal file
147
package.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-billing",
|
||||
"version": "0.1.0",
|
||||
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./src/exports/client.tsx",
|
||||
"types": "./src/exports/client.tsx",
|
||||
"default": "./src/exports/client.tsx"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./src/exports/rsc.tsx",
|
||||
"types": "./src/exports/rsc.tsx",
|
||||
"default": "./src/exports/rsc.tsx"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --project tsconfig.build.json",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"dev": "next dev dev --turbo",
|
||||
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
|
||||
"dev:generate-types": "pnpm dev:payload generate:types",
|
||||
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
|
||||
"generate:importmap": "pnpm dev:generate-importmap",
|
||||
"generate:types": "pnpm dev:generate-types",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm build",
|
||||
"test": "pnpm test:int && pnpm test:e2e",
|
||||
"test:e2e": "playwright test",
|
||||
"test:int": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"release": "changeset publish"
|
||||
},
|
||||
"keywords": [
|
||||
"payloadcms",
|
||||
"plugin",
|
||||
"billing",
|
||||
"payments",
|
||||
"stripe",
|
||||
"mollie",
|
||||
"ecommerce",
|
||||
"subscription",
|
||||
"invoice"
|
||||
],
|
||||
"author": "XTR Development <dev@xtr.dev>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/xtr-dev/payload-billing.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/xtr-dev/payload-billing/issues"
|
||||
},
|
||||
"homepage": "https://github.com/xtr-dev/payload-billing#readme",
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.1",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@payloadcms/db-mongodb": "3.37.0",
|
||||
"@payloadcms/db-postgres": "3.37.0",
|
||||
"@payloadcms/db-sqlite": "3.37.0",
|
||||
"@payloadcms/eslint-config": "3.9.0",
|
||||
"@payloadcms/next": "3.37.0",
|
||||
"@payloadcms/richtext-lexical": "3.37.0",
|
||||
"@payloadcms/ui": "3.37.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@swc-node/register": "1.10.9",
|
||||
"@swc/cli": "0.6.0",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-next": "15.4.4",
|
||||
"graphql": "^16.8.1",
|
||||
"mongodb-memory-server": "10.1.4",
|
||||
"next": "15.4.4",
|
||||
"open": "^10.1.0",
|
||||
"payload": "3.37.0",
|
||||
"prettier": "^3.4.2",
|
||||
"qs-esm": "7.0.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"rimraf": "3.0.2",
|
||||
"sharp": "0.34.2",
|
||||
"sort-package-json": "^2.10.0",
|
||||
"typescript": "5.7.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.37.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"stripe": "^14.15.0",
|
||||
"@mollie/api-client": "^3.7.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
"pnpm": "^9 || ^10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/exports/client.js",
|
||||
"types": "./dist/exports/client.d.ts",
|
||||
"default": "./dist/exports/client.js"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"access": "public"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
|
||||
}
|
||||
46
playwright.config.js
Normal file
46
playwright.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './dev',
|
||||
testMatch: '**/e2e.spec.{ts,js}',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
reuseExistingServer: true,
|
||||
url: 'http://localhost:3000/admin',
|
||||
},
|
||||
})
|
||||
11927
pnpm-lock.yaml
generated
Normal file
11927
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
283
src/__tests__/test-provider.test.ts
Normal file
283
src/__tests__/test-provider.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { TestProviderConfig} from '../types';
|
||||
|
||||
import { TestPaymentProvider } from '../providers/test/provider'
|
||||
import { PaymentStatus } from '../types'
|
||||
|
||||
describe('TestPaymentProvider', () => {
|
||||
let provider: TestPaymentProvider
|
||||
let config: TestProviderConfig
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
autoComplete: true,
|
||||
defaultDelay: 0,
|
||||
enabled: true,
|
||||
}
|
||||
provider = new TestPaymentProvider(config)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
provider.clearStoredData()
|
||||
})
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should create a payment with succeeded status when autoComplete is true', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
description: 'Test payment',
|
||||
})
|
||||
|
||||
expect(payment).toMatchObject({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
description: 'Test payment',
|
||||
provider: 'test',
|
||||
status: 'succeeded',
|
||||
})
|
||||
expect(payment.id).toBeDefined()
|
||||
expect(payment.createdAt).toBeDefined()
|
||||
expect(payment.updatedAt).toBeDefined()
|
||||
expect(payment.providerData?.testMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should create a payment with pending status when autoComplete is false', async () => {
|
||||
config.autoComplete = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const payment = await provider.createPayment({
|
||||
amount: 1500,
|
||||
currency: 'EUR',
|
||||
})
|
||||
|
||||
expect(payment).toMatchObject({
|
||||
amount: 1500,
|
||||
currency: 'EUR',
|
||||
status: 'pending',
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a failed payment when simulateFailure is true', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 1000,
|
||||
currency: 'USD',
|
||||
metadata: {
|
||||
test: { simulateFailure: true },
|
||||
},
|
||||
})
|
||||
|
||||
expect(payment.status).toBe('failed')
|
||||
expect(payment.providerData?.simulatedFailure).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply delay when specified', async () => {
|
||||
const startTime = Date.now()
|
||||
|
||||
await provider.createPayment({
|
||||
amount: 1000,
|
||||
currency: 'USD',
|
||||
metadata: {
|
||||
test: { delayMs: 100 },
|
||||
},
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should store payment data', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const stored = provider.getStoredPayment(payment.id)
|
||||
expect(stored).toEqual(payment)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retrievePayment', () => {
|
||||
it('should retrieve an existing payment', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const retrieved = await provider.retrievePayment(payment.id)
|
||||
expect(retrieved).toEqual(payment)
|
||||
})
|
||||
|
||||
it('should throw error for non-existent payment', async () => {
|
||||
await expect(provider.retrievePayment('non-existent')).rejects.toThrow(
|
||||
'Payment non-existent not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelPayment', () => {
|
||||
it('should cancel a pending payment', async () => {
|
||||
config.autoComplete = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const canceled = await provider.cancelPayment(payment.id)
|
||||
expect(canceled.status).toBe('canceled')
|
||||
expect(canceled.updatedAt).not.toBe(payment.updatedAt)
|
||||
})
|
||||
|
||||
it('should not cancel a succeeded payment', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
await expect(provider.cancelPayment(payment.id)).rejects.toThrow(
|
||||
'Cannot cancel a succeeded payment'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error for non-existent payment', async () => {
|
||||
await expect(provider.cancelPayment('non-existent')).rejects.toThrow(
|
||||
'Payment non-existent not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refundPayment', () => {
|
||||
it('should create a full refund for succeeded payment', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const refund = await provider.refundPayment(payment.id)
|
||||
|
||||
expect(refund).toMatchObject({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
paymentId: payment.id,
|
||||
status: 'succeeded',
|
||||
})
|
||||
expect(refund.id).toBeDefined()
|
||||
expect(refund.createdAt).toBeDefined()
|
||||
|
||||
// Check payment status is updated
|
||||
const updatedPayment = await provider.retrievePayment(payment.id)
|
||||
expect(updatedPayment.status).toBe('refunded')
|
||||
})
|
||||
|
||||
it('should create a partial refund', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const refund = await provider.refundPayment(payment.id, 1000)
|
||||
|
||||
expect(refund.amount).toBe(1000)
|
||||
|
||||
// Check payment status is updated to partially_refunded
|
||||
const updatedPayment = await provider.retrievePayment(payment.id)
|
||||
expect(updatedPayment.status).toBe('partially_refunded')
|
||||
})
|
||||
|
||||
it('should not refund a non-succeeded payment', async () => {
|
||||
config.autoComplete = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
await expect(provider.refundPayment(payment.id)).rejects.toThrow(
|
||||
'Can only refund succeeded payments'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not refund more than payment amount', async () => {
|
||||
const payment = await provider.createPayment({
|
||||
amount: 2000,
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
await expect(provider.refundPayment(payment.id, 3000)).rejects.toThrow(
|
||||
'Refund amount cannot exceed payment amount'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleWebhook', () => {
|
||||
it('should handle webhook event', async () => {
|
||||
const mockRequest = {
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
type: 'payment.succeeded',
|
||||
data: { paymentId: 'test_pay_123' }
|
||||
}))
|
||||
} as Request
|
||||
|
||||
const event = await provider.handleWebhook(mockRequest)
|
||||
|
||||
expect(event).toMatchObject({
|
||||
type: 'payment.succeeded',
|
||||
data: { paymentId: 'test_pay_123' },
|
||||
provider: 'test',
|
||||
verified: true,
|
||||
})
|
||||
expect(event.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('should throw error for invalid JSON', async () => {
|
||||
const mockRequest = {
|
||||
text: () => Promise.resolve('invalid json')
|
||||
} as Request
|
||||
|
||||
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
|
||||
'Invalid JSON in webhook body'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when provider is disabled', async () => {
|
||||
config.enabled = false
|
||||
provider = new TestPaymentProvider(config)
|
||||
|
||||
const mockRequest = {
|
||||
text: () => Promise.resolve('{}')
|
||||
} as Request
|
||||
|
||||
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
|
||||
'Test provider is not enabled'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data management', () => {
|
||||
it('should clear all stored data', async () => {
|
||||
await provider.createPayment({ amount: 1000, currency: 'USD' })
|
||||
|
||||
expect(provider.getAllPayments()).toHaveLength(1)
|
||||
|
||||
provider.clearStoredData()
|
||||
|
||||
expect(provider.getAllPayments()).toHaveLength(0)
|
||||
expect(provider.getAllRefunds()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should return all payments and refunds', async () => {
|
||||
const payment1 = await provider.createPayment({ amount: 1000, currency: 'USD' })
|
||||
const payment2 = await provider.createPayment({ amount: 2000, currency: 'EUR' })
|
||||
const refund = await provider.refundPayment(payment1.id)
|
||||
|
||||
const payments = provider.getAllPayments()
|
||||
const refunds = provider.getAllRefunds()
|
||||
|
||||
expect(payments).toHaveLength(2)
|
||||
expect(refunds).toHaveLength(1)
|
||||
expect(refunds[0]).toEqual(refund)
|
||||
})
|
||||
})
|
||||
})
|
||||
149
src/collections/customers.ts
Normal file
149
src/collections/customers.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
CustomerData,
|
||||
CustomerDocument
|
||||
} from '../types/payload'
|
||||
|
||||
export function createCustomersCollection(slug: string = 'customers'): CollectionConfig {
|
||||
return {
|
||||
slug,
|
||||
access: {
|
||||
create: ({ req: { user } }: AccessArgs) => !!user,
|
||||
delete: ({ req: { user } }: AccessArgs) => !!user,
|
||||
read: ({ req: { user } }: AccessArgs) => !!user,
|
||||
update: ({ req: { user } }: AccessArgs) => !!user,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['email', 'name', 'createdAt'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
admin: {
|
||||
description: 'Customer email address',
|
||||
},
|
||||
index: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer full name',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Customer phone number',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'line1',
|
||||
type: 'text',
|
||||
label: 'Address Line 1',
|
||||
},
|
||||
{
|
||||
name: 'line2',
|
||||
type: 'text',
|
||||
label: 'Address Line 2',
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
label: 'City',
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
label: 'State/Province',
|
||||
},
|
||||
{
|
||||
name: 'postal_code',
|
||||
type: 'text',
|
||||
label: 'Postal Code',
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 3166-1 alpha-2 country code',
|
||||
},
|
||||
label: 'Country',
|
||||
maxLength: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'providerIds',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Customer IDs from payment providers',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional customer metadata',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'payments',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
description: 'Customer payments',
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'payments',
|
||||
},
|
||||
{
|
||||
name: 'invoices',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
description: 'Customer invoices',
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'invoices',
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [
|
||||
({ doc, operation, req }: CollectionAfterChangeHook<CustomerDocument>) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Customer created: ${doc.id} (${doc.email})`)
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeChange: [
|
||||
({ data, operation }: CollectionBeforeChangeHook<CustomerData>) => {
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
// Normalize country code
|
||||
if (data.address?.country) {
|
||||
data.address.country = data.address.country.toUpperCase()
|
||||
if (!/^[A-Z]{2}$/.test(data.address.country)) {
|
||||
throw new Error('Country must be a 2-letter ISO code')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
}
|
||||
4
src/collections/index.ts
Normal file
4
src/collections/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { createCustomersCollection } from './customers'
|
||||
export { createInvoicesCollection } from './invoices'
|
||||
export { createPaymentsCollection } from './payments'
|
||||
export { createRefundsCollection } from './refunds'
|
||||
248
src/collections/invoices.ts
Normal file
248
src/collections/invoices.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionBeforeValidateHook,
|
||||
InvoiceData,
|
||||
InvoiceDocument,
|
||||
InvoiceItemData
|
||||
} from '../types/payload'
|
||||
|
||||
export function createInvoicesCollection(slug: string = 'invoices'): 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: ['number', 'customer', 'status', 'amount', 'currency', 'dueDate'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'number',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'number',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Invoice number (e.g., INV-001)',
|
||||
},
|
||||
index: true,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'customers',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Open', value: 'open' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
{ label: 'Void', value: 'void' },
|
||||
{ label: 'Uncollectible', value: 'uncollectible' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
||||
},
|
||||
defaultValue: 'USD',
|
||||
maxLength: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
admin: {
|
||||
// Custom row labeling can be added here when needed
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
admin: {
|
||||
width: '40%',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'number',
|
||||
admin: {
|
||||
width: '15%',
|
||||
},
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'unitAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Amount in cents',
|
||||
width: '20%',
|
||||
},
|
||||
min: 0,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'totalAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Calculated: quantity × unitAmount',
|
||||
readOnly: true,
|
||||
width: '20%',
|
||||
},
|
||||
},
|
||||
],
|
||||
minRows: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'subtotal',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Sum of all line items',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'taxAmount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Tax amount in cents',
|
||||
},
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Total amount (subtotal + tax)',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dueDate',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paidAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
condition: (data: InvoiceData) => data.status === 'paid',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'payment',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (data: InvoiceData) => data.status === 'paid',
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'payments',
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Internal notes',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional invoice metadata',
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [
|
||||
({ doc, operation, req }: CollectionAfterChangeHook<InvoiceDocument>) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Invoice created: ${doc.number}`)
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeChange: [
|
||||
({ data, operation }: CollectionBeforeChangeHook<InvoiceData>) => {
|
||||
if (operation === 'create') {
|
||||
// Generate invoice number if not provided
|
||||
if (!data.number) {
|
||||
const timestamp = Date.now()
|
||||
data.number = `INV-${timestamp}`
|
||||
}
|
||||
|
||||
// Validate currency format
|
||||
if (data.currency) {
|
||||
data.currency = data.currency.toUpperCase()
|
||||
if (!/^[A-Z]{3}$/.test(data.currency)) {
|
||||
throw new Error('Currency must be a 3-letter ISO code')
|
||||
}
|
||||
}
|
||||
|
||||
// Set due date if not provided (30 days from now)
|
||||
if (!data.dueDate) {
|
||||
const dueDate = new Date()
|
||||
dueDate.setDate(dueDate.getDate() + 30)
|
||||
data.dueDate = dueDate.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Set paid date when status changes to paid
|
||||
if (data.status === 'paid' && !data.paidAt) {
|
||||
data.paidAt = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeValidate: [
|
||||
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
|
||||
if (data && data.items && Array.isArray(data.items)) {
|
||||
// Calculate totals for each line item
|
||||
data.items = data.items.map((item: InvoiceItemData) => ({
|
||||
...item,
|
||||
totalAmount: (item.quantity || 0) * (item.unitAmount || 0),
|
||||
}))
|
||||
|
||||
// Calculate subtotal
|
||||
data.subtotal = data.items.reduce(
|
||||
(sum: number, item: InvoiceItemData) => sum + (item.totalAmount || 0),
|
||||
0
|
||||
)
|
||||
|
||||
// Calculate total amount
|
||||
data.amount = (data.subtotal || 0) + (data.taxAmount || 0)
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
}
|
||||
162
src/collections/payments.ts
Normal file
162
src/collections/payments.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
PaymentData,
|
||||
PaymentDocument
|
||||
} from '../types/payload'
|
||||
|
||||
export function createPaymentsCollection(slug: string = 'payments'): 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: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'id',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Stripe', value: 'stripe' },
|
||||
{ label: 'Mollie', value: 'mollie' },
|
||||
{ label: 'Test', value: 'test' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'providerId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The payment ID from the payment provider',
|
||||
},
|
||||
label: 'Provider Payment ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Processing', value: 'processing' },
|
||||
{ label: 'Succeeded', value: 'succeeded' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
{ label: 'Canceled', value: 'canceled' },
|
||||
{ label: 'Refunded', value: 'refunded' },
|
||||
{ label: 'Partially Refunded', value: 'partially_refunded' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Amount in cents (e.g., 2000 = $20.00)',
|
||||
},
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
||||
},
|
||||
maxLength: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Payment description',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'customers',
|
||||
},
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'invoices',
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional metadata for the payment',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'providerData',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Raw data from the payment provider',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refunds',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'refunds',
|
||||
},
|
||||
],
|
||||
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>) => {
|
||||
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()
|
||||
if (!/^[A-Z]{3}$/.test(data.currency)) {
|
||||
throw new Error('Currency must be a 3-letter ISO code')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
}
|
||||
163
src/collections/refunds.ts
Normal file
163
src/collections/refunds.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type {
|
||||
AccessArgs,
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
RefundData,
|
||||
RefundDocument
|
||||
} from '../types/payload'
|
||||
|
||||
export function createRefundsCollection(slug: string = 'refunds'): 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: ['id', 'payment', 'amount', 'currency', 'status', 'createdAt'],
|
||||
group: 'Billing',
|
||||
useAsTitle: 'id',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'providerId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'The refund ID from the payment provider',
|
||||
},
|
||||
label: 'Provider Refund ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'payment',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'payments',
|
||||
required: 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' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Refund amount in cents',
|
||||
},
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
||||
},
|
||||
maxLength: 3,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'reason',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Reason for the refund',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Duplicate', value: 'duplicate' },
|
||||
{ label: 'Fraudulent', value: 'fraudulent' },
|
||||
{ label: 'Requested by Customer', value: 'requested_by_customer' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Additional details about the refund',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional refund metadata',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'providerData',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Raw data from the payment provider',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, operation, req }: CollectionAfterChangeHook<RefundDocument>) => {
|
||||
if (operation === 'create') {
|
||||
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
|
||||
|
||||
// Update the related payment's refund relationship
|
||||
try {
|
||||
const payment = await req.payload.findByID({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: 'payments',
|
||||
})
|
||||
|
||||
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
||||
|
||||
await req.payload.update({
|
||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||
collection: 'payments',
|
||||
data: {
|
||||
refunds: [...refundIds, doc.id],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
req.payload.logger.error(`Failed to update payment refunds: ${error}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeChange: [
|
||||
({ data, operation }: CollectionBeforeChangeHook<RefundData>) => {
|
||||
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()
|
||||
if (!/^[A-Z]{3}$/.test(data.currency)) {
|
||||
throw new Error('Currency must be a 3-letter ISO code')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
}
|
||||
68
src/exports/client.tsx
Normal file
68
src/exports/client.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Client-side components and utilities for the billing plugin
|
||||
* These components run in the browser and have access to React hooks and client-side APIs
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
// Example client component that could be used in the admin dashboard
|
||||
export const BillingDashboardWidget: React.FC = () => {
|
||||
return (
|
||||
<div className="billing-dashboard-widget">
|
||||
<h3>Billing Overview</h3>
|
||||
<p>Payment statistics and recent transactions will be displayed here.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Client-side utilities
|
||||
export const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
currency: currency.toUpperCase(),
|
||||
style: 'currency',
|
||||
}).format(amount / 100)
|
||||
}
|
||||
|
||||
export const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'canceled':
|
||||
return 'gray'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
case 'pending':
|
||||
return 'yellow'
|
||||
case 'succeeded':
|
||||
return 'green'
|
||||
default:
|
||||
return 'blue'
|
||||
}
|
||||
}
|
||||
|
||||
// Example of a client component for payment status display
|
||||
export const PaymentStatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||
const color = getPaymentStatusColor(status)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`payment-status-badge payment-status-${status}`}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderRadius: '4px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
padding: '2px 8px'
|
||||
}}
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
BillingDashboardWidget,
|
||||
formatCurrency,
|
||||
getPaymentStatusColor,
|
||||
PaymentStatusBadge,
|
||||
}
|
||||
146
src/exports/rsc.tsx
Normal file
146
src/exports/rsc.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* React Server Components (RSC) and server-side utilities for the billing plugin
|
||||
* These components run on the server and can access server-side APIs and databases
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
// Server component that can fetch data during server-side rendering
|
||||
interface BillingServerStatsProps {
|
||||
payloadInstance?: unknown
|
||||
}
|
||||
|
||||
export const BillingServerStats: React.FC<BillingServerStatsProps> = async ({
|
||||
payloadInstance
|
||||
}) => {
|
||||
// In a real implementation, this would fetch data from the database
|
||||
// const stats = await payloadInstance?.find({
|
||||
// collection: 'payments',
|
||||
// limit: 0,
|
||||
// depth: 0
|
||||
// })
|
||||
|
||||
return (
|
||||
<div className="billing-server-stats">
|
||||
<h3>Payment Statistics</h3>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Total Payments</span>
|
||||
<span className="stat-value">-</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Successful</span>
|
||||
<span className="stat-value">-</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Pending</span>
|
||||
<span className="stat-value">-</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Failed</span>
|
||||
<span className="stat-value">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Server-side utility functions
|
||||
export const generateInvoiceNumber = () => {
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||
return `INV-${timestamp}-${random}`
|
||||
}
|
||||
|
||||
export const calculateInvoiceTotal = (items: Array<{
|
||||
quantity: number
|
||||
unitAmount: number
|
||||
}>, taxRate: number = 0) => {
|
||||
const subtotal = items.reduce((sum, item) => sum + (item.quantity * item.unitAmount), 0)
|
||||
const taxAmount = Math.round(subtotal * taxRate)
|
||||
return {
|
||||
subtotal,
|
||||
taxAmount,
|
||||
total: subtotal + taxAmount
|
||||
}
|
||||
}
|
||||
|
||||
// Server component for displaying invoice details
|
||||
interface InvoiceDetailsProps {
|
||||
invoice?: {
|
||||
amount: number
|
||||
currency: string
|
||||
customer?: {
|
||||
email?: string
|
||||
name?: string
|
||||
}
|
||||
dueDate?: string
|
||||
items?: Array<{
|
||||
description: string
|
||||
quantity: number
|
||||
totalAmount: number
|
||||
unitAmount: number
|
||||
}>
|
||||
number: string
|
||||
status: string
|
||||
}
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export const InvoiceDetails: React.FC<InvoiceDetailsProps> = ({ invoice, readonly = false }) => {
|
||||
if (!invoice) {
|
||||
return <div>No invoice data available</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invoice-details">
|
||||
<div className="invoice-header">
|
||||
<h3>Invoice {invoice.number}</h3>
|
||||
<span className={`status-badge status-${invoice.status}`}>
|
||||
{invoice.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="invoice-content">
|
||||
<div className="invoice-meta">
|
||||
<p><strong>Customer:</strong> {invoice.customer?.name || invoice.customer?.email}</p>
|
||||
<p><strong>Due Date:</strong> {invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : 'N/A'}</p>
|
||||
<p><strong>Amount:</strong> {invoice.currency} {(invoice.amount / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
{invoice.items && invoice.items.length > 0 && (
|
||||
<div className="invoice-items">
|
||||
<h4>Items</h4>
|
||||
<table className="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Quantity</th>
|
||||
<th>Unit Amount</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.items.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>{item.description}</td>
|
||||
<td>{item.quantity}</td>
|
||||
<td>{invoice.currency} {(item.unitAmount / 100).toFixed(2)}</td>
|
||||
<td>{invoice.currency} {(item.totalAmount / 100).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
BillingServerStats,
|
||||
calculateInvoiceTotal,
|
||||
generateInvoiceNumber,
|
||||
InvoiceDetails,
|
||||
}
|
||||
132
src/index.ts
Normal file
132
src/index.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Config } from 'payload'
|
||||
|
||||
import type { BillingPluginConfig } from './types'
|
||||
|
||||
import { createCustomersCollection } from './collections/customers'
|
||||
import { createInvoicesCollection } from './collections/invoices'
|
||||
import { createPaymentsCollection } from './collections/payments'
|
||||
import { createRefundsCollection } from './collections/refunds'
|
||||
import { providerRegistry } from './providers/base/provider'
|
||||
import { TestPaymentProvider } from './providers/test/provider'
|
||||
|
||||
export * from './providers/base/provider'
|
||||
export * from './providers/test/provider'
|
||||
export * from './types'
|
||||
|
||||
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
||||
if (pluginConfig.disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
// Initialize collections
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
config.collections.push(
|
||||
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
|
||||
createCustomersCollection(pluginConfig.collections?.customers || 'customers'),
|
||||
createInvoicesCollection(pluginConfig.collections?.invoices || 'invoices'),
|
||||
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
|
||||
)
|
||||
|
||||
// Initialize endpoints
|
||||
if (!config.endpoints) {
|
||||
config.endpoints = []
|
||||
}
|
||||
|
||||
config.endpoints?.push(
|
||||
// Webhook endpoints
|
||||
{
|
||||
handler: async (req) => {
|
||||
try {
|
||||
const provider = providerRegistry.get(req.routeParams?.provider as string)
|
||||
if (!provider) {
|
||||
return Response.json({ error: 'Provider not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const signature = req.headers.get('stripe-signature') ||
|
||||
req.headers.get('x-mollie-signature')
|
||||
|
||||
const event = await provider.handleWebhook(req as unknown as Request, signature || '')
|
||||
|
||||
// TODO: Process webhook event and update database
|
||||
|
||||
return Response.json({ eventId: event.id, received: true })
|
||||
} catch (error) {
|
||||
console.error('[BILLING] Webhook error:', error)
|
||||
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
|
||||
}
|
||||
},
|
||||
method: 'post',
|
||||
path: '/billing/webhooks/:provider'
|
||||
},
|
||||
// Health check endpoint
|
||||
{
|
||||
handler: async () => {
|
||||
const providers = providerRegistry.getAll().map(p => ({
|
||||
name: p.name,
|
||||
status: 'active'
|
||||
}))
|
||||
|
||||
return Response.json({
|
||||
providers,
|
||||
status: 'ok',
|
||||
version: '0.1.0'
|
||||
})
|
||||
},
|
||||
method: 'get',
|
||||
path: '/billing/health'
|
||||
}
|
||||
)
|
||||
|
||||
// Initialize providers and onInit hook
|
||||
const incomingOnInit = config.onInit
|
||||
|
||||
config.onInit = async (payload) => {
|
||||
// Execute any existing onInit functions first
|
||||
if (incomingOnInit) {
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
|
||||
// Initialize payment providers
|
||||
initializeProviders(pluginConfig)
|
||||
|
||||
// Log initialization
|
||||
console.log('[BILLING] Plugin initialized with providers:',
|
||||
providerRegistry.getAll().map(p => p.name).join(', ')
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function initializeProviders(config: BillingPluginConfig) {
|
||||
// Initialize test provider if enabled
|
||||
if (config.providers?.test?.enabled) {
|
||||
const testProvider = new TestPaymentProvider(config.providers.test)
|
||||
providerRegistry.register(testProvider)
|
||||
}
|
||||
|
||||
// TODO: Initialize Stripe provider
|
||||
// TODO: Initialize Mollie provider
|
||||
}
|
||||
|
||||
// Utility function to get payment provider
|
||||
export function getPaymentProvider(name: string) {
|
||||
const provider = providerRegistry.get(name)
|
||||
if (!provider) {
|
||||
throw new Error(`Payment provider '${name}' not found`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// Utility function to list available providers
|
||||
export function getAvailableProviders() {
|
||||
return providerRegistry.getAll().map(p => ({
|
||||
name: p.name,
|
||||
// Add provider-specific info here
|
||||
}))
|
||||
}
|
||||
|
||||
export default billingPlugin
|
||||
63
src/providers/base/provider.ts
Normal file
63
src/providers/base/provider.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { CreatePaymentOptions, Payment, PaymentProvider, Refund, WebhookEvent } from '../../types'
|
||||
|
||||
export abstract class BasePaymentProvider implements PaymentProvider {
|
||||
abstract name: string
|
||||
|
||||
protected formatAmount(amount: number, currency: string): number {
|
||||
this.validateAmount(amount)
|
||||
this.validateCurrency(currency)
|
||||
return amount
|
||||
}
|
||||
protected log(level: 'error' | 'info' | 'warn', message: string, data?: Record<string, unknown>): void {
|
||||
const logData = {
|
||||
message,
|
||||
provider: this.name,
|
||||
...data,
|
||||
}
|
||||
|
||||
console[level](`[${this.name.toUpperCase()}]`, logData)
|
||||
}
|
||||
protected validateAmount(amount: number): void {
|
||||
if (amount <= 0 || !Number.isInteger(amount)) {
|
||||
throw new Error('Amount must be a positive integer in cents')
|
||||
}
|
||||
}
|
||||
protected validateCurrency(currency: string): void {
|
||||
if (!currency || currency.length !== 3) {
|
||||
throw new Error('Currency must be a valid 3-letter ISO currency code')
|
||||
}
|
||||
}
|
||||
abstract cancelPayment(id: string): Promise<Payment>
|
||||
|
||||
abstract createPayment(options: CreatePaymentOptions): Promise<Payment>
|
||||
|
||||
abstract handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
|
||||
|
||||
abstract refundPayment(id: string, amount?: number): Promise<Refund>
|
||||
|
||||
abstract retrievePayment(id: string): Promise<Payment>
|
||||
}
|
||||
|
||||
export function createProviderRegistry() {
|
||||
const providers = new Map<string, PaymentProvider>()
|
||||
|
||||
return {
|
||||
register(provider: PaymentProvider): void {
|
||||
providers.set(provider.name, provider)
|
||||
},
|
||||
|
||||
get(name: string): PaymentProvider | undefined {
|
||||
return providers.get(name)
|
||||
},
|
||||
|
||||
getAll(): PaymentProvider[] {
|
||||
return Array.from(providers.values())
|
||||
},
|
||||
|
||||
has(name: string): boolean {
|
||||
return providers.has(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const providerRegistry = createProviderRegistry()
|
||||
225
src/providers/test/provider.ts
Normal file
225
src/providers/test/provider.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type {
|
||||
CreatePaymentOptions,
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
Refund,
|
||||
TestProviderConfig,
|
||||
WebhookEvent
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
RefundStatus
|
||||
} from '../../types'
|
||||
import { BasePaymentProvider } from '../base/provider'
|
||||
|
||||
interface TestPaymentData {
|
||||
delayMs?: number
|
||||
failAfterMs?: number
|
||||
simulateFailure?: boolean
|
||||
}
|
||||
|
||||
export class TestPaymentProvider extends BasePaymentProvider {
|
||||
private config: TestProviderConfig
|
||||
private payments = new Map<string, Payment>()
|
||||
private refunds = new Map<string, Refund>()
|
||||
name = 'test'
|
||||
|
||||
constructor(config: TestProviderConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async cancelPayment(id: string): Promise<Payment> {
|
||||
const payment = this.payments.get(id)
|
||||
if (!payment) {
|
||||
throw new Error(`Payment ${id} not found`)
|
||||
}
|
||||
|
||||
if (payment.status === 'succeeded') {
|
||||
throw new Error('Cannot cancel a succeeded payment')
|
||||
}
|
||||
|
||||
const canceledPayment = {
|
||||
...payment,
|
||||
status: 'canceled' as PaymentStatus,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
this.payments.set(id, canceledPayment)
|
||||
|
||||
this.log('info', 'Payment canceled', { paymentId: id })
|
||||
|
||||
return canceledPayment
|
||||
}
|
||||
|
||||
clearStoredData(): void {
|
||||
this.payments.clear()
|
||||
this.refunds.clear()
|
||||
this.log('info', 'Test data cleared')
|
||||
}
|
||||
|
||||
async createPayment(options: CreatePaymentOptions): Promise<Payment> {
|
||||
const testData = options.metadata?.test as TestPaymentData || {}
|
||||
const delay = testData.delayMs ?? this.config.defaultDelay ?? 0
|
||||
|
||||
if (delay > 0) {
|
||||
await this.sleep(delay)
|
||||
}
|
||||
|
||||
const shouldFail = testData.simulateFailure ??
|
||||
(this.config.simulateFailures && Math.random() < (this.config.failureRate ?? 0.1))
|
||||
|
||||
const paymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const payment: Payment = {
|
||||
id: paymentId,
|
||||
amount: options.amount,
|
||||
createdAt: new Date().toISOString(),
|
||||
currency: options.currency,
|
||||
customer: options.customer,
|
||||
description: options.description,
|
||||
metadata: options.metadata,
|
||||
provider: this.name,
|
||||
providerData: {
|
||||
autoCompleted: this.config.autoComplete,
|
||||
delayApplied: delay,
|
||||
simulatedFailure: shouldFail,
|
||||
testMode: true
|
||||
},
|
||||
status: shouldFail ? 'failed' : (this.config.autoComplete ? 'succeeded' : 'pending'),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
this.payments.set(paymentId, payment)
|
||||
|
||||
this.log('info', 'Payment created', {
|
||||
amount: options.amount,
|
||||
currency: options.currency,
|
||||
paymentId,
|
||||
status: payment.status
|
||||
})
|
||||
|
||||
// Simulate async status updates if configured
|
||||
if (testData.failAfterMs && !shouldFail) {
|
||||
setTimeout(() => {
|
||||
const updatedPayment = { ...payment, status: 'failed' as PaymentStatus, updatedAt: new Date().toISOString() }
|
||||
this.payments.set(paymentId, updatedPayment)
|
||||
this.log('info', 'Payment failed after delay', { paymentId })
|
||||
}, testData.failAfterMs)
|
||||
}
|
||||
|
||||
return payment
|
||||
}
|
||||
|
||||
getAllPayments(): Payment[] {
|
||||
return Array.from(this.payments.values())
|
||||
}
|
||||
|
||||
getAllRefunds(): Refund[] {
|
||||
return Array.from(this.refunds.values())
|
||||
}
|
||||
|
||||
// Test-specific methods
|
||||
getStoredPayment(id: string): Payment | undefined {
|
||||
return this.payments.get(id)
|
||||
}
|
||||
|
||||
getStoredRefund(id: string): Refund | undefined {
|
||||
return this.refunds.get(id)
|
||||
}
|
||||
|
||||
async handleWebhook(request: Request, signature?: string): Promise<WebhookEvent> {
|
||||
if (!this.config.enabled) {
|
||||
throw new Error('Test provider is not enabled')
|
||||
}
|
||||
|
||||
// For test provider, we'll simulate webhook events
|
||||
const body = await request.text()
|
||||
let eventData: Record<string, unknown>
|
||||
|
||||
try {
|
||||
eventData = JSON.parse(body)
|
||||
} catch (error) {
|
||||
throw new Error('Invalid JSON in webhook body')
|
||||
}
|
||||
|
||||
const event: WebhookEvent = {
|
||||
id: `test_evt_${Date.now()}_${Math.random().toString(36).substring(7)}`,
|
||||
type: (eventData.type as string) || 'payment.status_changed',
|
||||
data: eventData,
|
||||
provider: this.name,
|
||||
verified: true // Test provider always considers webhooks verified
|
||||
}
|
||||
|
||||
this.log('info', 'Webhook received', {
|
||||
type: event.type,
|
||||
dataKeys: Object.keys(event.data),
|
||||
eventId: event.id
|
||||
})
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
async refundPayment(id: string, amount?: number): Promise<Refund> {
|
||||
const payment = this.payments.get(id)
|
||||
if (!payment) {
|
||||
throw new Error(`Payment ${id} not found`)
|
||||
}
|
||||
|
||||
if (payment.status !== 'succeeded') {
|
||||
throw new Error('Can only refund succeeded payments')
|
||||
}
|
||||
|
||||
const refundAmount = amount ?? payment.amount
|
||||
if (refundAmount > payment.amount) {
|
||||
throw new Error('Refund amount cannot exceed payment amount')
|
||||
}
|
||||
|
||||
const refundId = `test_ref_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const refund: Refund = {
|
||||
id: refundId,
|
||||
amount: refundAmount,
|
||||
createdAt: new Date().toISOString(),
|
||||
currency: payment.currency,
|
||||
paymentId: id,
|
||||
providerData: {
|
||||
autoCompleted: this.config.autoComplete,
|
||||
testMode: true
|
||||
},
|
||||
status: this.config.autoComplete ? 'succeeded' : 'pending'
|
||||
}
|
||||
|
||||
this.refunds.set(refundId, refund)
|
||||
|
||||
// Update payment status
|
||||
const newPaymentStatus: PaymentStatus = refundAmount === payment.amount ? 'refunded' : 'partially_refunded'
|
||||
const updatedPayment = {
|
||||
...payment,
|
||||
status: newPaymentStatus,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
this.payments.set(id, updatedPayment)
|
||||
|
||||
this.log('info', 'Refund created', {
|
||||
amount: refundAmount,
|
||||
paymentId: id,
|
||||
refundId,
|
||||
status: refund.status
|
||||
})
|
||||
|
||||
return refund
|
||||
}
|
||||
|
||||
async retrievePayment(id: string): Promise<Payment> {
|
||||
const payment = this.payments.get(id)
|
||||
if (!payment) {
|
||||
throw new Error(`Payment ${id} not found`)
|
||||
}
|
||||
return payment
|
||||
}
|
||||
}
|
||||
224
src/types/index.ts
Normal file
224
src/types/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { Config } from 'payload'
|
||||
|
||||
// Base payment provider interface
|
||||
export interface PaymentProvider {
|
||||
cancelPayment(id: string): Promise<Payment>
|
||||
createPayment(options: CreatePaymentOptions): Promise<Payment>
|
||||
handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
|
||||
name: string
|
||||
refundPayment(id: string, amount?: number): Promise<Refund>
|
||||
retrievePayment(id: string): Promise<Payment>
|
||||
}
|
||||
|
||||
// Payment types
|
||||
export interface CreatePaymentOptions {
|
||||
amount: number
|
||||
cancelUrl?: string
|
||||
currency: string
|
||||
customer?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
returnUrl?: string
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer?: string
|
||||
description?: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
provider: string
|
||||
providerData?: Record<string, unknown>
|
||||
status: PaymentStatus
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Refund {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
paymentId: string
|
||||
providerData?: Record<string, unknown>
|
||||
reason?: string
|
||||
status: RefundStatus
|
||||
}
|
||||
|
||||
export interface WebhookEvent {
|
||||
data: Record<string, unknown>
|
||||
id: string
|
||||
provider: string
|
||||
type: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
// Status enums
|
||||
export type PaymentStatus =
|
||||
| 'canceled'
|
||||
| 'failed'
|
||||
| 'partially_refunded'
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'refunded'
|
||||
| 'succeeded'
|
||||
|
||||
export type RefundStatus =
|
||||
| 'canceled'
|
||||
| 'failed'
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'succeeded'
|
||||
|
||||
// Provider configurations
|
||||
export interface StripeConfig {
|
||||
apiVersion?: string
|
||||
publishableKey: string
|
||||
secretKey: string
|
||||
webhookEndpointSecret: string
|
||||
}
|
||||
|
||||
export interface MollieConfig {
|
||||
apiKey: string
|
||||
testMode?: boolean
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
export interface TestProviderConfig {
|
||||
autoComplete?: boolean
|
||||
defaultDelay?: number
|
||||
enabled: boolean
|
||||
failureRate?: number
|
||||
simulateFailures?: boolean
|
||||
}
|
||||
|
||||
// Plugin configuration
|
||||
export interface BillingPluginConfig {
|
||||
admin?: {
|
||||
customComponents?: boolean
|
||||
dashboard?: boolean
|
||||
}
|
||||
collections?: {
|
||||
customers?: string
|
||||
invoices?: string
|
||||
payments?: string
|
||||
refunds?: string
|
||||
}
|
||||
disabled?: boolean
|
||||
providers?: {
|
||||
mollie?: MollieConfig
|
||||
stripe?: StripeConfig
|
||||
test?: TestProviderConfig
|
||||
}
|
||||
webhooks?: {
|
||||
basePath?: string
|
||||
cors?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Collection types
|
||||
export interface PaymentRecord {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer?: string
|
||||
description?: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
provider: string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId: string
|
||||
status: PaymentStatus
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CustomerRecord {
|
||||
address?: {
|
||||
city?: string
|
||||
country?: string
|
||||
line1?: string
|
||||
line2?: string
|
||||
postal_code?: string
|
||||
state?: string
|
||||
}
|
||||
createdAt: string
|
||||
email?: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
name?: string
|
||||
phone?: string
|
||||
providerIds?: Record<string, string>
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InvoiceRecord {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer?: string
|
||||
dueDate?: string
|
||||
id: string
|
||||
items: InvoiceItem[]
|
||||
metadata?: Record<string, unknown>
|
||||
number: string
|
||||
paidAt?: string
|
||||
status: InvoiceStatus
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
description: string
|
||||
quantity: number
|
||||
totalAmount: number
|
||||
unitAmount: number
|
||||
}
|
||||
|
||||
export type InvoiceStatus =
|
||||
| 'draft'
|
||||
| 'open'
|
||||
| 'paid'
|
||||
| 'uncollectible'
|
||||
| 'void'
|
||||
|
||||
// Plugin type
|
||||
export interface BillingPluginOptions extends BillingPluginConfig {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Error types
|
||||
export class BillingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public provider?: string,
|
||||
public details?: Record<string, unknown>
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'BillingError'
|
||||
}
|
||||
}
|
||||
|
||||
export class PaymentProviderError extends BillingError {
|
||||
constructor(
|
||||
message: string,
|
||||
provider: string,
|
||||
code?: string,
|
||||
details?: Record<string, unknown>
|
||||
) {
|
||||
super(message, code || 'PROVIDER_ERROR', provider, details)
|
||||
this.name = 'PaymentProviderError'
|
||||
}
|
||||
}
|
||||
|
||||
export class WebhookError extends BillingError {
|
||||
constructor(
|
||||
message: string,
|
||||
provider: string,
|
||||
code?: string,
|
||||
details?: Record<string, unknown>
|
||||
) {
|
||||
super(message, code || 'WEBHOOK_ERROR', provider, details)
|
||||
this.name = 'WebhookError'
|
||||
}
|
||||
}
|
||||
148
src/types/payload.ts
Normal file
148
src/types/payload.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* PayloadCMS type definitions for hooks and handlers
|
||||
*/
|
||||
|
||||
import type { PayloadRequest, User } from 'payload'
|
||||
|
||||
// Collection hook types
|
||||
export interface CollectionBeforeChangeHook<T = Record<string, unknown>> {
|
||||
data: T
|
||||
operation: 'create' | 'delete' | 'update'
|
||||
originalDoc?: T
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
export interface CollectionAfterChangeHook<T = Record<string, unknown>> {
|
||||
doc: T
|
||||
operation: 'create' | 'delete' | 'update'
|
||||
previousDoc?: T
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
export interface CollectionBeforeValidateHook<T = Record<string, unknown>> {
|
||||
data?: T
|
||||
operation: 'create' | 'update'
|
||||
originalDoc?: T
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
// Access control types
|
||||
export interface AccessArgs<T = unknown> {
|
||||
data?: T
|
||||
id?: number | string
|
||||
req: {
|
||||
payload: unknown
|
||||
user: null | User
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice item type for hooks
|
||||
export interface InvoiceItemData {
|
||||
description: string
|
||||
quantity: number
|
||||
totalAmount?: number
|
||||
unitAmount: number
|
||||
}
|
||||
|
||||
// Invoice data type for hooks
|
||||
export interface InvoiceData {
|
||||
amount?: number
|
||||
currency?: string
|
||||
customer?: string
|
||||
dueDate?: string
|
||||
items?: InvoiceItemData[]
|
||||
metadata?: Record<string, unknown>
|
||||
notes?: string
|
||||
number?: string
|
||||
paidAt?: string
|
||||
payment?: string
|
||||
status?: string
|
||||
subtotal?: number
|
||||
taxAmount?: number
|
||||
}
|
||||
|
||||
// Payment data type for hooks
|
||||
export interface PaymentData {
|
||||
amount?: number
|
||||
currency?: string
|
||||
customer?: string
|
||||
description?: string
|
||||
invoice?: string
|
||||
metadata?: Record<string, unknown>
|
||||
provider?: string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
// Customer data type for hooks
|
||||
export interface CustomerData {
|
||||
address?: {
|
||||
city?: string
|
||||
country?: string
|
||||
line1?: string
|
||||
line2?: string
|
||||
postal_code?: string
|
||||
state?: string
|
||||
}
|
||||
email?: string
|
||||
metadata?: Record<string, unknown>
|
||||
name?: string
|
||||
phone?: string
|
||||
providerIds?: Record<string, string>
|
||||
}
|
||||
|
||||
// Refund data type for hooks
|
||||
export interface RefundData {
|
||||
amount?: number
|
||||
currency?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
payment?: { id: string } | string
|
||||
providerData?: Record<string, unknown>
|
||||
providerId?: string
|
||||
reason?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
// Document types with required fields after creation
|
||||
export interface PaymentDocument extends PaymentData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
provider: string
|
||||
providerId: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CustomerDocument extends CustomerData {
|
||||
createdAt: string
|
||||
id: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InvoiceDocument extends InvoiceData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
customer: string
|
||||
id: string
|
||||
items: InvoiceItemData[]
|
||||
number: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface RefundDocument extends RefundData {
|
||||
amount: number
|
||||
createdAt: string
|
||||
currency: string
|
||||
id: string
|
||||
payment: { id: string } | string
|
||||
providerId: string
|
||||
refunds?: string[]
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
130
src/utils/currency.ts
Normal file
130
src/utils/currency.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Currency utility functions for payment processing
|
||||
*/
|
||||
|
||||
// Common currency configurations
|
||||
export const CURRENCY_CONFIG = {
|
||||
AUD: { name: 'Australian Dollar', decimals: 2, symbol: 'A$' },
|
||||
CAD: { name: 'Canadian Dollar', decimals: 2, symbol: 'C$' },
|
||||
CHF: { name: 'Swiss Franc', decimals: 2, symbol: 'Fr' },
|
||||
DKK: { name: 'Danish Krone', decimals: 2, symbol: 'kr' },
|
||||
EUR: { name: 'Euro', decimals: 2, symbol: '€' },
|
||||
GBP: { name: 'British Pound', decimals: 2, symbol: '£' },
|
||||
JPY: { name: 'Japanese Yen', decimals: 0, symbol: '¥' },
|
||||
NOK: { name: 'Norwegian Krone', decimals: 2, symbol: 'kr' },
|
||||
SEK: { name: 'Swedish Krona', decimals: 2, symbol: 'kr' },
|
||||
USD: { name: 'US Dollar', decimals: 2, symbol: '$' },
|
||||
} as const
|
||||
|
||||
export type SupportedCurrency = keyof typeof CURRENCY_CONFIG
|
||||
|
||||
/**
|
||||
* Validates if a currency code is supported
|
||||
*/
|
||||
export function isSupportedCurrency(currency: string): currency is SupportedCurrency {
|
||||
return currency in CURRENCY_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates currency format (3-letter ISO code)
|
||||
*/
|
||||
export function isValidCurrencyCode(currency: string): boolean {
|
||||
return /^[A-Z]{3}$/.test(currency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts amount from cents to major currency unit
|
||||
*/
|
||||
export function fromCents(amount: number, currency: string): number {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
if (!config) {
|
||||
// Default to 2 decimals for unknown currencies
|
||||
return amount / 100
|
||||
}
|
||||
|
||||
return config.decimals === 0 ? amount : amount / Math.pow(10, config.decimals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts amount from major currency unit to cents
|
||||
*/
|
||||
export function toCents(amount: number, currency: string): number {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
if (!config) {
|
||||
// Default to 2 decimals for unknown currencies
|
||||
return Math.round(amount * 100)
|
||||
}
|
||||
|
||||
return config.decimals === 0
|
||||
? Math.round(amount)
|
||||
: Math.round(amount * Math.pow(10, config.decimals))
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats amount for display with currency symbol
|
||||
*/
|
||||
export function formatAmount(amount: number, currency: string, options?: {
|
||||
showCode?: boolean
|
||||
showSymbol?: boolean
|
||||
}): string {
|
||||
const { showCode = false, showSymbol = true } = options || {}
|
||||
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const majorAmount = fromCents(amount, currency)
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
|
||||
let formatted = majorAmount.toFixed(config?.decimals ?? 2)
|
||||
|
||||
if (showSymbol && config?.symbol) {
|
||||
formatted = `${config.symbol}${formatted}`
|
||||
}
|
||||
|
||||
if (showCode) {
|
||||
formatted += ` ${currency}`
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets currency information
|
||||
*/
|
||||
export function getCurrencyInfo(currency: string) {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
return CURRENCY_CONFIG[currency as SupportedCurrency] || {
|
||||
name: currency,
|
||||
decimals: 2,
|
||||
symbol: currency
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates amount is positive and properly formatted
|
||||
*/
|
||||
export function validateAmount(amount: number): void {
|
||||
if (!Number.isFinite(amount)) {
|
||||
throw new Error('Amount must be a finite number')
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new Error('Amount must be positive')
|
||||
}
|
||||
|
||||
if (!Number.isInteger(amount)) {
|
||||
throw new Error('Amount must be an integer (in cents)')
|
||||
}
|
||||
}
|
||||
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './currency'
|
||||
export * from './logger'
|
||||
export * from './validation'
|
||||
113
src/utils/logger.ts
Normal file
113
src/utils/logger.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Structured logging utilities for the billing plugin
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'error' | 'info' | 'warn'
|
||||
|
||||
export interface LogContext {
|
||||
[key: string]: unknown
|
||||
amount?: number
|
||||
currency?: string
|
||||
customerId?: string
|
||||
invoiceId?: string
|
||||
paymentId?: string
|
||||
provider?: string
|
||||
refundId?: string
|
||||
webhookId?: string
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, context?: LogContext): void
|
||||
error(message: string, context?: LogContext): void
|
||||
info(message: string, context?: LogContext): void
|
||||
warn(message: string, context?: LogContext): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a structured logger with consistent formatting
|
||||
*/
|
||||
export function createLogger(namespace: string = 'BILLING'): Logger {
|
||||
const log = (level: LogLevel, message: string, context: LogContext = {}) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const logData = {
|
||||
level: level.toUpperCase(),
|
||||
message,
|
||||
namespace,
|
||||
timestamp,
|
||||
...context,
|
||||
}
|
||||
|
||||
// Use console methods based on log level
|
||||
const consoleMethod = console[level] || console.log
|
||||
consoleMethod(`[${namespace}] ${message}`, logData)
|
||||
}
|
||||
|
||||
return {
|
||||
debug: (message: string, context?: LogContext) => log('debug', message, context),
|
||||
error: (message: string, context?: LogContext) => log('error', message, context),
|
||||
info: (message: string, context?: LogContext) => log('info', message, context),
|
||||
warn: (message: string, context?: LogContext) => log('warn', message, context),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default logger instance for the plugin
|
||||
*/
|
||||
export const logger = createLogger('BILLING')
|
||||
|
||||
/**
|
||||
* Creates a provider-specific logger
|
||||
*/
|
||||
export function createProviderLogger(providerName: string): Logger {
|
||||
return createLogger(`BILLING:${providerName.toUpperCase()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log payment operations with consistent structure
|
||||
*/
|
||||
export function logPaymentOperation(
|
||||
operation: string,
|
||||
paymentId: string,
|
||||
provider: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.info(`Payment ${operation}`, {
|
||||
operation,
|
||||
paymentId,
|
||||
provider,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log webhook events with consistent structure
|
||||
*/
|
||||
export function logWebhookEvent(
|
||||
provider: string,
|
||||
eventType: string,
|
||||
webhookId: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.info(`Webhook received`, {
|
||||
eventType,
|
||||
provider,
|
||||
webhookId,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log errors with consistent structure
|
||||
*/
|
||||
export function logError(
|
||||
error: Error,
|
||||
operation: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.error(`Operation failed: ${operation}`, {
|
||||
error: error.message,
|
||||
operation,
|
||||
stack: error.stack,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
181
src/utils/validation.ts
Normal file
181
src/utils/validation.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Validation utilities for billing data
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { isValidCurrencyCode } from './currency'
|
||||
|
||||
/**
|
||||
* Zod schema for payment creation options
|
||||
*/
|
||||
export const createPaymentSchema = z.object({
|
||||
amount: z.number().int().positive('Amount must be positive').min(1, 'Amount must be at least 1 cent'),
|
||||
cancelUrl: z.string().url('Invalid cancel URL').optional(),
|
||||
currency: z.string().length(3, 'Currency must be 3 characters').regex(/^[A-Z]{3}$/, 'Currency must be uppercase'),
|
||||
customer: z.string().optional(),
|
||||
description: z.string().max(500, 'Description too long').optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
returnUrl: z.string().url('Invalid return URL').optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for customer data
|
||||
*/
|
||||
export const customerSchema = z.object({
|
||||
name: z.string().max(100, 'Name too long').optional(),
|
||||
address: z.object({
|
||||
city: z.string().max(50).optional(),
|
||||
country: z.string().length(2, 'Country must be 2 characters').regex(/^[A-Z]{2}$/, 'Country must be uppercase').optional(),
|
||||
line1: z.string().max(100).optional(),
|
||||
line2: z.string().max(100).optional(),
|
||||
postal_code: z.string().max(20).optional(),
|
||||
state: z.string().max(50).optional(),
|
||||
}).optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
phone: z.string().max(20, 'Phone number too long').optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for invoice items
|
||||
*/
|
||||
export const invoiceItemSchema = z.object({
|
||||
description: z.string().min(1, 'Description is required').max(200, 'Description too long'),
|
||||
quantity: z.number().int().positive('Quantity must be positive'),
|
||||
unitAmount: z.number().int().min(0, 'Unit amount must be non-negative'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for invoice creation
|
||||
*/
|
||||
export const invoiceSchema = z.object({
|
||||
currency: z.string().length(3).regex(/^[A-Z]{3}$/),
|
||||
customer: z.string().min(1, 'Customer is required'),
|
||||
dueDate: z.string().datetime().optional(),
|
||||
items: z.array(invoiceItemSchema).min(1, 'At least one item is required'),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
taxAmount: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validates payment creation data
|
||||
*/
|
||||
export function validateCreatePayment(data: unknown) {
|
||||
const result = createPaymentSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid payment data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
|
||||
// Additional currency validation
|
||||
if (!isValidCurrencyCode(result.data.currency)) {
|
||||
throw new Error(`Unsupported currency: ${result.data.currency}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates customer data
|
||||
*/
|
||||
export function validateCustomer(data: unknown) {
|
||||
const result = customerSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid customer data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates invoice data
|
||||
*/
|
||||
export function validateInvoice(data: unknown) {
|
||||
const result = invoiceSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid invoice data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
|
||||
// Additional currency validation
|
||||
if (!isValidCurrencyCode(result.data.currency)) {
|
||||
throw new Error(`Unsupported currency: ${result.data.currency}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates webhook signature format
|
||||
*/
|
||||
export function validateWebhookSignature(signature: string, provider: string): void {
|
||||
if (!signature) {
|
||||
throw new Error(`Missing webhook signature for ${provider}`)
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'mollie':
|
||||
if (signature.length < 32) {
|
||||
throw new Error('Invalid Mollie webhook signature length')
|
||||
}
|
||||
break
|
||||
case 'stripe':
|
||||
if (!signature.startsWith('t=')) {
|
||||
throw new Error('Invalid Stripe webhook signature format')
|
||||
}
|
||||
break
|
||||
case 'test':
|
||||
// Test provider accepts any signature
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${provider}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates payment provider name
|
||||
*/
|
||||
export function validateProviderName(provider: string): void {
|
||||
const validProviders = ['stripe', 'mollie', 'test']
|
||||
if (!validProviders.includes(provider)) {
|
||||
throw new Error(`Invalid provider: ${provider}. Must be one of: ${validProviders.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates payment amount and currency combination
|
||||
*/
|
||||
export function validateAmountAndCurrency(amount: number, currency: string): void {
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
throw new Error('Amount must be a positive integer')
|
||||
}
|
||||
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error('Invalid currency code')
|
||||
}
|
||||
|
||||
// Validate minimum amounts for different currencies
|
||||
const minimums: Record<string, number> = {
|
||||
EUR: 50, // €0.50
|
||||
GBP: 30, // £0.30
|
||||
JPY: 50, // ¥50
|
||||
USD: 50, // $0.50
|
||||
}
|
||||
|
||||
const minimum = minimums[currency] || 50
|
||||
if (amount < minimum) {
|
||||
throw new Error(`Amount too small for ${currency}. Minimum: ${minimum} cents`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates refund amount against original payment
|
||||
*/
|
||||
export function validateRefundAmount(refundAmount: number, paymentAmount: number): void {
|
||||
if (!Number.isInteger(refundAmount) || refundAmount <= 0) {
|
||||
throw new Error('Refund amount must be a positive integer')
|
||||
}
|
||||
|
||||
if (refundAmount > paymentAmount) {
|
||||
throw new Error('Refund amount cannot exceed original payment amount')
|
||||
}
|
||||
}
|
||||
27
tsconfig.build.json
Normal file
27
tsconfig.build.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"src/__tests__/**/*",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"dev/**/*",
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
39
tsconfig.json
Normal file
39
tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES6"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"declaration": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
25
vitest.config.js
Normal file
25
vitest.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from 'path'
|
||||
import { loadEnv } from 'payload/node'
|
||||
import { fileURLToPath } from 'url'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default defineConfig(() => {
|
||||
loadEnv(path.resolve(dirname, './dev'))
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
environment: 'node',
|
||||
hookTimeout: 30_000,
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user