Merge pull request #6 from xtr-dev/dev

Dev
This commit is contained in:
Bas
2025-09-16 22:15:43 +02:00
committed by GitHub
28 changed files with 1089 additions and 1507 deletions

232
CLAUDE.md
View File

@@ -2,161 +2,165 @@
## Project Overview ## Project Overview
This is a PayloadCMS plugin that provides billing and payment functionality with multiple payment provider integrations (Stripe, Mollie) and a test payment provider for local development. This is a PayloadCMS plugin that provides billing and payment functionality with flexible customer data management and invoice generation capabilities.
## Architecture Principles ## Architecture Principles
### Core Design ### Core Design
- **Provider Abstraction**: All payment providers implement a common interface for consistency
- **TypeScript First**: Full TypeScript support with strict typing throughout - **TypeScript First**: Full TypeScript support with strict typing throughout
- **PayloadCMS Integration**: Deep integration with Payload collections, hooks, and admin UI - **PayloadCMS Integration**: Deep integration with Payload collections, hooks, and admin UI
- **Extensible**: Easy to add new payment providers through the common interface - **Flexible Customer Data**: Support for both relationship-based and embedded customer information
- **Developer Experience**: Comprehensive testing tools and local development support - **Callback-based Syncing**: Use customer info extractors to keep data in sync
### 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 ### Collections Structure
- **Payments**: Core payment tracking with provider-specific data - **Payments**: Core payment tracking with provider-specific data
- **Customers**: Customer management with billing information - **Customers**: Customer management with billing information (optional)
- **Invoices**: Invoice generation and management - **Invoices**: Invoice generation with embedded customer info and optional customer relationship
- **Refunds**: Refund tracking and management - **Refunds**: Refund tracking and management
## Code Organization ## Code Organization
``` ```
src/ 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 ├── collections/ # PayloadCMS collection configurations
├── endpoints/ # API endpoints (webhooks, etc.)
├── hooks/ # PayloadCMS lifecycle hooks
├── admin/ # Admin UI components and extensions
├── types/ # TypeScript type definitions ├── types/ # TypeScript type definitions
└── utils/ # Shared utilities and helpers └── index.ts # Main plugin entry point
``` ```
## Development Guidelines ## Customer Data Management
### Payment Provider Development ### Customer Info Extractor Pattern
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 The plugin uses a callback-based approach to extract customer information from customer relationships:
- **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 ```typescript
- Use strict TypeScript configuration // Define how to extract customer info from your customer collection
- Define proper interfaces for all external API responses const customerInfoExtractor: CustomerInfoExtractor = (customer) => ({
- Use discriminated unions for provider-specific data name: customer.name,
- Implement proper generic types for extensibility email: customer.email,
phone: customer.phone,
### PayloadCMS Integration company: customer.company,
- Follow PayloadCMS plugin patterns and conventions taxId: customer.taxId,
- Use proper collection configurations with access control billingAddress: {
- Implement admin UI components using PayloadCMS patterns line1: customer.address.line1,
- Utilize PayloadCMS hooks for business logic line2: customer.address.line2,
city: customer.address.city,
### Security Considerations state: customer.address.state,
- **Webhook Verification**: Always verify webhook signatures postalCode: customer.address.postalCode,
- **API Key Storage**: Use environment variables for sensitive data country: customer.address.country,
- **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 ### Invoice Customer Data Options
1. Use test provider for local development
2. Configure webhook forwarding tools (ngrok, etc.) for local webhook testing 1. **With Customer Relationship + Extractor**:
3. Use provider sandbox/test modes during development - Customer relationship required
4. Implement comprehensive logging for debugging - Customer info auto-populated and read-only
- Syncs automatically when customer changes
2. **With Customer Relationship (no extractor)**:
- Customer relationship optional
- Customer info manually editable
- Either relationship OR customer info required
3. **No Customer Collection**:
- Customer info fields always required and editable
- No relationship field available
## Plugin Configuration ## Plugin Configuration
### Basic Configuration ### Basic Configuration
```typescript ```typescript
import { billingPlugin, defaultCustomerInfoExtractor } from '@xtr-dev/payload-billing'
billingPlugin({ billingPlugin({
providers: {
// Provider configurations
},
collections: { collections: {
// Collection name overrides customers: 'customers', // Customer collection slug
invoices: 'invoices', // Invoice collection slug
payments: 'payments', // Payment collection slug
refunds: 'refunds', // Refund collection slug
customerRelation: false, // Disable customer relationship
// OR
customerRelation: 'clients', // Use custom collection slug
}, },
webhooks: { customerInfoExtractor: defaultCustomerInfoExtractor, // For built-in customer collection
// Webhook configuration
},
admin: {
// Admin UI configuration
}
}) })
``` ```
### Advanced Configuration ### Custom Customer Info Extractor
- Custom collection schemas ```typescript
- Provider-specific options billingPlugin({
- Webhook endpoint customization customerInfoExtractor: (customer) => ({
- Admin UI customization name: customer.fullName,
email: customer.contactEmail,
phone: customer.phoneNumber,
company: customer.companyName,
taxId: customer.vatNumber,
billingAddress: {
line1: customer.billing.street,
line2: customer.billing.apartment,
city: customer.billing.city,
state: customer.billing.state,
postalCode: customer.billing.zip,
country: customer.billing.countryCode,
}
})
})
```
## Error Handling Strategy ## Development Guidelines
### Provider Errors ### TypeScript Guidelines
- Map provider-specific errors to common error types - Use strict TypeScript configuration
- Preserve original error information for debugging - All customer info extractors must implement `CustomerInfoExtractor` interface
- Implement proper retry logic for transient failures - Ensure consistent camelCase naming for all address fields
### Webhook Errors ### PayloadCMS Integration
- Handle duplicate webhooks gracefully - Follow PayloadCMS plugin patterns and conventions
- Implement proper error responses for webhook failures - Use proper collection configurations with access control
- Log webhook processing errors with context - Utilize PayloadCMS hooks for data syncing and validation
### Field Validation Rules
- When using `customerInfoExtractor`: customer relationship is required, customer info auto-populated
- When not using extractor: either customer relationship OR customer info must be provided
- When no customer collection: customer info is always required
## Collections API
### Invoice Collection Features
- Automatic invoice number generation (INV-{timestamp})
- Currency validation (3-letter ISO codes)
- Automatic due date setting (30 days from creation)
- Line item total calculations
- Customer info syncing via hooks
### Customer Data Syncing
The `beforeChange` hook automatically:
1. Detects when customer relationship changes
2. Fetches customer data from the related collection
3. Extracts customer info using the provided callback
4. Updates invoice with extracted data
5. Maintains data consistency across updates
## Error Handling
### Validation Errors
- Customer relationship required when using extractor
- Customer info required when not using relationship
- Proper error messages for missing required fields
### Data Extraction Errors
- Failed customer fetches are logged and throw user-friendly errors
- Invalid customer data is handled gracefully
## Performance Considerations ## Performance Considerations
- Implement proper caching where appropriate - Customer data is only fetched when relationship changes
- Use database indexes for payment queries - Read-only fields prevent unnecessary manual edits
- Optimize webhook processing for high throughput - Efficient hook execution with proper change detection
- 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 ## Documentation Requirements
- Document all public APIs with examples - Document all public APIs with examples
- Provide integration guides for each payment provider - Provide clear customer info extractor examples
- Include troubleshooting guides for common issues - Include configuration guides for different use cases
- Maintain up-to-date TypeScript documentation - Maintain up-to-date TypeScript documentation

View File

@@ -1,11 +1,13 @@
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { useBillingPlugin } from '../../../src/plugin'
export const GET = async (request: Request) => { export const GET = async (request: Request) => {
const payload = await getPayload({ const payload = await getPayload({
config: configPromise, config: configPromise,
}) })
return Response.json({ return Response.json({
message: 'This is an example of a custom route.', message: 'This is an example of a custom route.',
}) })

View File

@@ -70,7 +70,6 @@ export interface Config {
posts: Post; posts: Post;
media: Media; media: Media;
payments: Payment; payments: Payment;
customers: Customer;
invoices: Invoice; invoices: Invoice;
refunds: Refund; refunds: Refund;
users: User; users: User;
@@ -83,7 +82,6 @@ export interface Config {
posts: PostsSelect<false> | PostsSelect<true>; posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>; media: MediaSelect<false> | MediaSelect<true>;
payments: PaymentsSelect<false> | PaymentsSelect<true>; payments: PaymentsSelect<false> | PaymentsSelect<true>;
customers: CustomersSelect<false> | CustomersSelect<true>;
invoices: InvoicesSelect<false> | InvoicesSelect<true>; invoices: InvoicesSelect<false> | InvoicesSelect<true>;
refunds: RefundsSelect<false> | RefundsSelect<true>; refunds: RefundsSelect<false> | RefundsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
@@ -174,7 +172,6 @@ export interface Payment {
* Payment description * Payment description
*/ */
description?: string | null; description?: string | null;
customer?: (number | null) | Customer;
invoice?: (number | null) | Invoice; invoice?: (number | null) | Invoice;
/** /**
* Additional metadata for the payment * Additional metadata for the payment
@@ -204,70 +201,6 @@ export interface Payment {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers".
*/
export interface Customer {
id: number;
/**
* Customer email address
*/
email?: string | null;
/**
* Customer full name
*/
name?: string | null;
/**
* Customer phone number
*/
phone?: string | null;
address?: {
line1?: string | null;
line2?: string | null;
city?: string | null;
state?: string | null;
postal_code?: string | null;
/**
* ISO 3166-1 alpha-2 country code
*/
country?: string | null;
};
/**
* Customer IDs from payment providers
*/
providerIds?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Additional customer metadata
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Customer payments
*/
payments?: (number | Payment)[] | null;
/**
* Customer invoices
*/
invoices?: (number | Invoice)[] | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "invoices". * via the `definition` "invoices".
@@ -278,7 +211,57 @@ export interface Invoice {
* Invoice number (e.g., INV-001) * Invoice number (e.g., INV-001)
*/ */
number: string; number: string;
customer: number | Customer; /**
* Customer billing information
*/
customerInfo: {
/**
* Customer name
*/
name: string;
/**
* Customer email address
*/
email: string;
/**
* Customer phone number
*/
phone?: string | null;
/**
* Company name (optional)
*/
company?: string | null;
/**
* Tax ID or VAT number
*/
taxId?: string | null;
};
/**
* Billing address
*/
billingAddress: {
/**
* Address line 1
*/
line1: string;
/**
* Address line 2
*/
line2?: string | null;
city: string;
/**
* State or province
*/
state?: string | null;
/**
* Postal or ZIP code
*/
postalCode: string;
/**
* Country code (e.g., US, GB)
*/
country: string;
};
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'; status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
/** /**
* ISO 4217 currency code (e.g., USD, EUR) * ISO 4217 currency code (e.g., USD, EUR)
@@ -422,10 +405,6 @@ export interface PayloadLockedDocument {
relationTo: 'payments'; relationTo: 'payments';
value: number | Payment; value: number | Payment;
} | null) } | null)
| ({
relationTo: 'customers';
value: number | Customer;
} | null)
| ({ | ({
relationTo: 'invoices'; relationTo: 'invoices';
value: number | Invoice; value: number | Invoice;
@@ -516,7 +495,6 @@ export interface PaymentsSelect<T extends boolean = true> {
amount?: T; amount?: T;
currency?: T; currency?: T;
description?: T; description?: T;
customer?: T;
invoice?: T; invoice?: T;
metadata?: T; metadata?: T;
providerData?: T; providerData?: T;
@@ -526,36 +504,29 @@ export interface PaymentsSelect<T extends boolean = true> {
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "customers_select". * via the `definition` "invoices_select".
*/ */
export interface CustomersSelect<T extends boolean = true> { export interface InvoicesSelect<T extends boolean = true> {
email?: T; number?: T;
name?: T; customerInfo?:
phone?: T; | T
address?: | {
name?: T;
email?: T;
phone?: T;
company?: T;
taxId?: T;
};
billingAddress?:
| T | T
| { | {
line1?: T; line1?: T;
line2?: T; line2?: T;
city?: T; city?: T;
state?: T; state?: T;
postal_code?: T; postalCode?: T;
country?: 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; status?: T;
currency?: T; currency?: T;
items?: items?:

View File

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

View File

@@ -21,129 +21,9 @@ export const seed = async (payload: Payload) => {
} }
// Seed billing sample data // Seed billing sample data
await seedBillingData(payload) // await seedBillingData(payload)
} }
async function seedBillingData(payload: Payload): Promise<void> { // async function seedBillingData(payload: Payload): Promise<void> {
payload.logger.info('Seeding billing sample data...') // 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)
}
}

View File

@@ -44,6 +44,7 @@ export default [
'perfectionist/sort-switch-case': 'off', 'perfectionist/sort-switch-case': 'off',
'perfectionist/sort-union-types': 'off', 'perfectionist/sort-union-types': 'off',
'perfectionist/sort-variable-declarations': 'off', 'perfectionist/sort-variable-declarations': 'off',
'perfectionist/sort-intersection-types': 'off',
}, },
}, },
{ {

View File

@@ -70,6 +70,7 @@
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.27.1", "@changesets/cli": "^2.27.1",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@mollie/api-client": "^3.7.0",
"@payloadcms/db-mongodb": "3.37.0", "@payloadcms/db-mongodb": "3.37.0",
"@payloadcms/db-postgres": "3.37.0", "@payloadcms/db-postgres": "3.37.0",
"@payloadcms/db-sqlite": "3.37.0", "@payloadcms/db-sqlite": "3.37.0",
@@ -104,11 +105,11 @@
"vitest": "^3.1.2" "vitest": "^3.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@mollie/api-client": "^3.7.0",
"payload": "^3.37.0" "payload": "^3.37.0"
}, },
"dependencies": { "dependencies": {
"stripe": "^14.15.0", "stripe": "^14.15.0",
"@mollie/api-client": "^3.7.0",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"engines": { "engines": {

12
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
'@mollie/api-client':
specifier: ^3.7.0
version: 3.7.0
stripe: stripe:
specifier: ^14.15.0 specifier: ^14.15.0
version: 14.25.0 version: 14.25.0
@@ -24,6 +21,9 @@ importers:
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.3.1 version: 3.3.1
'@mollie/api-client':
specifier: ^3.7.0
version: 3.7.0
'@payloadcms/db-mongodb': '@payloadcms/db-mongodb':
specifier: 3.37.0 specifier: 3.37.0
version: 3.37.0(payload@3.37.0(graphql@16.11.0)(typescript@5.7.3)) version: 3.37.0(payload@3.37.0(graphql@16.11.0)(typescript@5.7.3))
@@ -8875,7 +8875,7 @@ snapshots:
eslint: 9.35.0 eslint: 9.35.0
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0)
eslint-plugin-react: 7.37.5(eslint@9.35.0) eslint-plugin-react: 7.37.5(eslint@9.35.0)
eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0) eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0)
@@ -8909,7 +8909,7 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0)
eslint-plugin-import-x: 4.4.2(eslint@9.35.0)(typescript@5.7.3) eslint-plugin-import-x: 4.4.2(eslint@9.35.0)(typescript@5.7.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -8960,7 +8960,7 @@ snapshots:
- typescript - typescript
optional: true optional: true
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9

View File

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

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

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

View File

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

View File

@@ -1,17 +1,117 @@
import type { CollectionConfig } from 'payload' import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, CollectionSlug, Field } from 'payload'
import type { BillingPluginConfig} from '@/plugin/config';
import { defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils'
import { Payment } from '@/plugin/types/payments'
import { initProviderPayment } from '@/collections/hooks'
import type { export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
AccessArgs, const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
CollectionAfterChangeHook, let fields: Field[] = [
CollectionBeforeChangeHook, {
PaymentData, name: 'provider',
PaymentDocument type: 'select',
} from '../types/payload' admin: {
position: 'sidebar',
export function createPaymentsCollection(slug: string = 'payments'): CollectionConfig { },
options: [
{ label: 'Stripe', value: 'stripe' },
{ label: 'Mollie', value: 'mollie' },
{ label: 'Test', value: 'test' },
],
required: true,
},
{
name: 'providerId',
type: 'text',
admin: {
description: 'The payment ID from the payment provider',
},
label: 'Provider Payment ID',
unique: true,
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Processing', value: 'processing' },
{ label: 'Succeeded', value: 'succeeded' },
{ label: 'Failed', value: 'failed' },
{ label: 'Canceled', value: 'canceled' },
{ label: 'Refunded', value: 'refunded' },
{ label: 'Partially Refunded', value: 'partially_refunded' },
],
required: true,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Amount in cents (e.g., 2000 = $20.00)',
},
min: 1,
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
maxLength: 3,
required: true,
},
{
name: 'description',
type: 'text',
admin: {
description: 'Payment description',
},
},
{
name: 'invoice',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection) as CollectionSlug,
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional metadata for the payment',
},
},
{
name: 'providerData',
type: 'json',
admin: {
description: 'Raw data from the payment provider',
readOnly: true,
},
},
{
name: 'refunds',
type: 'relationship',
admin: {
position: 'sidebar',
readOnly: true,
},
hasMany: true,
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection) as CollectionSlug,
},
]
if (overrides?.fields) {
fields = overrides?.fields({defaultFields: fields})
}
return { return {
slug, slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
access: { access: overrides?.access || {
create: ({ req: { user } }: AccessArgs) => !!user, create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user, delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user, read: ({ req: { user } }: AccessArgs) => !!user,
@@ -21,131 +121,18 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'], defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
group: 'Billing', group: 'Billing',
useAsTitle: 'id', useAsTitle: 'id',
...overrides?.admin
}, },
fields: [ 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: { hooks: {
afterChange: [
({ doc, operation, req }: CollectionAfterChangeHook<PaymentDocument>) => {
if (operation === 'create') {
req.payload.logger.info(`Payment created: ${doc.id} (${doc.provider})`)
}
},
],
beforeChange: [ beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<PaymentData>) => { async ({ data, operation, req }) => {
if (operation === 'create') { if (operation === 'create') {
// Validate amount format // Validate amount format
if (data.amount && !Number.isInteger(data.amount)) { if (data.amount && !Number.isInteger(data.amount)) {
throw new Error('Amount must be an integer (in cents)') throw new Error('Amount must be an integer (in cents)')
} }
// Validate currency format // Validate currency format
if (data.currency) { if (data.currency) {
data.currency = data.currency.toUpperCase() data.currency = data.currency.toUpperCase()
@@ -153,10 +140,12 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC
throw new Error('Currency must be a 3-letter ISO code') throw new Error('Currency must be a 3-letter ISO code')
} }
} }
await initProviderPayment(req.payload, data)
} }
}, },
], ] satisfies CollectionBeforeChangeHook<Payment>[],
}, },
timestamps: true, timestamps: true,
} }
} }

View File

@@ -1,16 +1,12 @@
import type { CollectionConfig } from 'payload' import type { AccessArgs, CollectionConfig } from 'payload'
import { BillingPluginConfig, defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils'
import { Payment } from '@/plugin/types'
import type { export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
AccessArgs, // TODO: finish collection overrides
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
RefundData,
RefundDocument
} from '../types/payload'
export function createRefundsCollection(slug: string = 'refunds'): CollectionConfig {
return { return {
slug, slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
access: { access: {
create: ({ req: { user } }: AccessArgs) => !!user, create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user, delete: ({ req: { user } }: AccessArgs) => !!user,
@@ -39,7 +35,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
admin: { admin: {
position: 'sidebar', position: 'sidebar',
}, },
relationTo: 'payments', relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
required: true, required: true,
}, },
{ {
@@ -113,7 +109,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
], ],
hooks: { hooks: {
afterChange: [ afterChange: [
async ({ doc, operation, req }: CollectionAfterChangeHook<RefundDocument>) => { async ({ doc, operation, req }) => {
if (operation === 'create') { if (operation === 'create') {
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`) req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
@@ -121,15 +117,15 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
try { try {
const payment = await req.payload.findByID({ const payment = await req.payload.findByID({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id, id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments', collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
}) }) as Payment
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : [] const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
await req.payload.update({ await req.payload.update({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id, id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments', collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
data: { data: {
refunds: [...refundIds, doc.id as any], refunds: [...refundIds, doc.id],
}, },
}) })
} catch (error) { } catch (error) {
@@ -139,7 +135,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
}, },
], ],
beforeChange: [ beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<RefundData>) => { ({ data, operation }) => {
if (operation === 'create') { if (operation === 'create') {
// Validate amount format // Validate amount format
if (data.amount && !Number.isInteger(data.amount)) { if (data.amount && !Number.isInteger(data.amount)) {

View File

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

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

@@ -0,0 +1,77 @@
import { CollectionConfig } from 'payload'
import { FieldsOverride } from '@/plugin/utils'
import { PaymentProvider } from '@/plugin/types'
export const defaults = {
paymentsCollection: 'payments',
invoicesCollection: 'invoices',
refundsCollection: 'refunds',
customerRelationSlug: 'customer'
}
// Provider configurations
export interface StripeConfig {
apiVersion?: string
publishableKey: string
secretKey: string
webhookEndpointSecret: string
}
export interface MollieConfig {
apiKey: string
testMode?: boolean
webhookUrl: string
}
export interface TestProviderConfig {
autoComplete?: boolean
defaultDelay?: number
enabled: boolean
failureRate?: number
simulateFailures?: boolean
}
// Customer info extractor callback type
export interface CustomerInfoExtractor {
(customer: any): {
name: string
email: string
phone?: string
company?: string
taxId?: string
billingAddress?: {
line1: string
line2?: string
city: string
state?: string
postalCode: string
country: string
}
}
}
// Plugin configuration
export interface BillingPluginConfig {
admin?: {
customComponents?: boolean
dashboard?: boolean
}
collections?: {
invoices?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
payments?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
refunds?: string | (Partial<CollectionConfig> & {fields?: FieldsOverride})
}
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
customerRelationSlug?: string // Customer collection slug for relationship
disabled?: boolean
providers?: PaymentProvider[]
webhooks?: {
basePath?: string
cors?: boolean
}
}
// Plugin type
export interface BillingPluginOptions extends BillingPluginConfig {
disabled?: boolean
}

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

@@ -0,0 +1,51 @@
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '@/collections'
import type { BillingPluginConfig } from '@/plugin/config'
import type { Config, Payload } from 'payload'
import { createSingleton } from '@/plugin/singleton'
import type { PaymentProvider } from '@/providers'
const singleton = createSingleton(Symbol('billingPlugin'))
type BillingPlugin = {
config: BillingPluginConfig
providerConfig: {
[key: string]: PaymentProvider
}
}
export const useBillingPlugin = (payload: Payload) => singleton.get(payload) as BillingPlugin
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) {
return config
}
config.collections = [
...(config.collections || []),
createPaymentsCollection(pluginConfig),
createInvoicesCollection(pluginConfig),
createRefundsCollection(pluginConfig),
]
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
if (incomingOnInit) {
await incomingOnInit(payload)
}
singleton.set(payload, {
config: pluginConfig,
providerConfig: (pluginConfig.providers || []).reduce(
(acc, val) => {
acc[val.key] = val
return acc
},
{} as Record<string, PaymentProvider>
)
} satisfies BillingPlugin)
console.log('Billing plugin initialized', singleton.get(payload))
await Promise.all((pluginConfig.providers || []).map(p => p.onInit(payload)))
}
return config
}
export default billingPlugin

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,6 @@
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export const extractSlug =
(arg: string | Partial<CollectionConfig>) => (typeof arg === 'string' ? arg : arg.slug!) as CollectionSlug

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

@@ -0,0 +1,2 @@
export * from './mollie'
export * from './types'

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

@@ -0,0 +1,40 @@
import type { Payment } from '@/plugin/types/payments'
import type { InitPayment, PaymentProvider } from '@/plugin/types'
import type { Payload } from 'payload'
import { createSingleton } from '@/plugin/singleton'
import type { createMollieClient, MollieClient } from '@mollie/api-client'
const symbol = Symbol('mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
export const mollieProvider = (config: MollieProviderConfig) => {
const singleton = createSingleton<MollieClient>(symbol)
return {
key: 'mollie',
onInit: async (payload: Payload) => {
const createMollieClient = (await import('@mollie/api-client')).default
const mollieClient = createMollieClient(config)
singleton.set(payload, mollieClient)
},
initPayment: async (payload, payment) => {
if (!payment.amount) {
throw new Error('Amount is required')
}
if (!payment.currency) {
throw new Error('Currency is required')
}
const molliePayment = await singleton.get(payload).payments.create({
amount: {
value: (payment.amount / 100).toFixed(2),
currency: payment.currency
},
description: payment.description || '',
redirectUrl: 'https://localhost:3000/payment/success',
webhookUrl: 'https://localhost:3000',
});
payment.providerId = molliePayment.id
payment.providerData = molliePayment.toPlainObject()
return payment
},
} satisfies PaymentProvider
}

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

@@ -0,0 +1,10 @@
import type { Payment } from '@/plugin/types/payments'
import type { Payload } from 'payload'
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
export type PaymentProvider = {
key: string
onInit: (payload: Payload) => Promise<void> | void
initPayment: InitPayment
}

View File

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

View File

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