diff --git a/CLAUDE.md b/CLAUDE.md index 4c97503..16d7ae6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,161 +2,165 @@ ## Project Overview -This is a PayloadCMS plugin that provides billing and payment functionality with multiple payment provider integrations (Stripe, Mollie) and a test payment provider for local development. +This is a PayloadCMS plugin that provides billing and payment functionality with flexible customer data management and invoice generation capabilities. ## Architecture Principles ### Core Design -- **Provider Abstraction**: All payment providers implement a common interface for consistency - **TypeScript First**: Full TypeScript support with strict typing throughout - **PayloadCMS Integration**: Deep integration with Payload collections, hooks, and admin UI -- **Extensible**: Easy to add new payment providers through the common interface -- **Developer Experience**: Comprehensive testing tools and local development support - -### Payment Provider Interface -All payment providers must implement the `PaymentProvider` interface: -```typescript -interface PaymentProvider { - createPayment(options: CreatePaymentOptions): Promise - retrievePayment(id: string): Promise - cancelPayment(id: string): Promise - refundPayment(id: string, amount?: number): Promise - handleWebhook(request: Request, signature?: string): Promise -} -``` +- **Flexible Customer Data**: Support for both relationship-based and embedded customer information +- **Callback-based Syncing**: Use customer info extractors to keep data in sync ### Collections Structure - **Payments**: Core payment tracking with provider-specific data -- **Customers**: Customer management with billing information -- **Invoices**: Invoice generation and management +- **Customers**: Customer management with billing information (optional) +- **Invoices**: Invoice generation with embedded customer info and optional customer relationship - **Refunds**: Refund tracking and management ## Code Organization ``` src/ -├── providers/ # Payment provider implementations -│ ├── stripe/ # Stripe integration -│ ├── mollie/ # Mollie integration -│ ├── test/ # Test provider for development -│ └── base/ # Base provider interface and utilities ├── collections/ # PayloadCMS collection configurations -├── endpoints/ # API endpoints (webhooks, etc.) -├── hooks/ # PayloadCMS lifecycle hooks -├── admin/ # Admin UI components and extensions ├── types/ # TypeScript type definitions -└── utils/ # Shared utilities and helpers +└── index.ts # Main plugin entry point ``` -## Development Guidelines +## Customer Data Management -### Payment Provider Development -1. **Implement Base Interface**: All providers must implement `PaymentProvider` -2. **Error Handling**: Use consistent error types and proper error propagation -3. **Webhook Security**: Always verify webhook signatures and implement replay protection -4. **Idempotency**: Support idempotent operations where possible -5. **Logging**: Use structured logging for debugging and monitoring +### Customer Info Extractor Pattern -### Testing Strategy -- **Unit Tests**: Test individual provider methods and utilities -- **Integration Tests**: Test provider integrations with mock APIs -- **E2E Tests**: Test complete payment flows using test provider -- **Webhook Tests**: Test webhook handling with various scenarios +The plugin uses a callback-based approach to extract customer information from customer relationships: -### TypeScript Guidelines -- Use strict TypeScript configuration -- Define proper interfaces for all external API responses -- Use discriminated unions for provider-specific data -- Implement proper generic types for extensibility - -### PayloadCMS Integration -- Follow PayloadCMS plugin patterns and conventions -- Use proper collection configurations with access control -- Implement admin UI components using PayloadCMS patterns -- Utilize PayloadCMS hooks for business logic - -### Security Considerations -- **Webhook Verification**: Always verify webhook signatures -- **API Key Storage**: Use environment variables for sensitive data -- **Access Control**: Implement proper PayloadCMS access control -- **Input Validation**: Validate all inputs and sanitize data -- **Audit Logging**: Log all payment operations for audit trails - -## Environment Configuration - -### Required Environment Variables -```bash -# Stripe Configuration -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# Mollie Configuration -MOLLIE_API_KEY=test_... -MOLLIE_WEBHOOK_URL=https://yourapp.com/api/billing/webhooks/mollie - -# Test Provider Configuration -NODE_ENV=development # Enables test provider +```typescript +// Define how to extract customer info from your customer collection +const customerInfoExtractor: CustomerInfoExtractor = (customer) => ({ + name: customer.name, + email: customer.email, + phone: customer.phone, + company: customer.company, + taxId: customer.taxId, + billingAddress: { + line1: customer.address.line1, + line2: customer.address.line2, + city: customer.address.city, + state: customer.address.state, + postalCode: customer.address.postalCode, + country: customer.address.country, + } +}) ``` -### Development Setup -1. Use test provider for local development -2. Configure webhook forwarding tools (ngrok, etc.) for local webhook testing -3. Use provider sandbox/test modes during development -4. Implement comprehensive logging for debugging +### Invoice Customer Data Options + +1. **With Customer Relationship + Extractor**: + - Customer relationship required + - Customer info auto-populated and read-only + - Syncs automatically when customer changes + +2. **With Customer Relationship (no extractor)**: + - Customer relationship optional + - Customer info manually editable + - Either relationship OR customer info required + +3. **No Customer Collection**: + - Customer info fields always required and editable + - No relationship field available ## Plugin Configuration ### Basic Configuration ```typescript +import { billingPlugin, defaultCustomerInfoExtractor } from '@xtr-dev/payload-billing' + billingPlugin({ - providers: { - // Provider configurations - }, collections: { - // Collection name overrides + customers: 'customers', // Customer collection slug + invoices: 'invoices', // Invoice collection slug + payments: 'payments', // Payment collection slug + refunds: 'refunds', // Refund collection slug + customerRelation: false, // Disable customer relationship + // OR + customerRelation: 'clients', // Use custom collection slug }, - webhooks: { - // Webhook configuration - }, - admin: { - // Admin UI configuration - } + customerInfoExtractor: defaultCustomerInfoExtractor, // For built-in customer collection }) ``` -### Advanced Configuration -- Custom collection schemas -- Provider-specific options -- Webhook endpoint customization -- Admin UI customization +### Custom Customer Info Extractor +```typescript +billingPlugin({ + customerInfoExtractor: (customer) => ({ + name: customer.fullName, + email: customer.contactEmail, + phone: customer.phoneNumber, + company: customer.companyName, + taxId: customer.vatNumber, + billingAddress: { + line1: customer.billing.street, + line2: customer.billing.apartment, + city: customer.billing.city, + state: customer.billing.state, + postalCode: customer.billing.zip, + country: customer.billing.countryCode, + } + }) +}) +``` -## Error Handling Strategy +## Development Guidelines -### Provider Errors -- Map provider-specific errors to common error types -- Preserve original error information for debugging -- Implement proper retry logic for transient failures +### TypeScript Guidelines +- Use strict TypeScript configuration +- All customer info extractors must implement `CustomerInfoExtractor` interface +- Ensure consistent camelCase naming for all address fields -### Webhook Errors -- Handle duplicate webhooks gracefully -- Implement proper error responses for webhook failures -- Log webhook processing errors with context +### PayloadCMS Integration +- Follow PayloadCMS plugin patterns and conventions +- Use proper collection configurations with access control +- Utilize PayloadCMS hooks for data syncing and validation + +### Field Validation Rules +- When using `customerInfoExtractor`: customer relationship is required, customer info auto-populated +- When not using extractor: either customer relationship OR customer info must be provided +- When no customer collection: customer info is always required + +## Collections API + +### Invoice Collection Features +- Automatic invoice number generation (INV-{timestamp}) +- Currency validation (3-letter ISO codes) +- Automatic due date setting (30 days from creation) +- Line item total calculations +- Customer info syncing via hooks + +### Customer Data Syncing +The `beforeChange` hook automatically: +1. Detects when customer relationship changes +2. Fetches customer data from the related collection +3. Extracts customer info using the provided callback +4. Updates invoice with extracted data +5. Maintains data consistency across updates + +## Error Handling + +### Validation Errors +- Customer relationship required when using extractor +- Customer info required when not using relationship +- Proper error messages for missing required fields + +### Data Extraction Errors +- Failed customer fetches are logged and throw user-friendly errors +- Invalid customer data is handled gracefully ## Performance Considerations -- Implement proper caching where appropriate -- Use database indexes for payment queries -- Optimize webhook processing for high throughput -- Consider rate limiting for API endpoints - -## Monitoring and Observability -- Log all payment operations with structured data -- Track payment success/failure rates -- Monitor webhook processing times -- Implement health check endpoints +- Customer data is only fetched when relationship changes +- Read-only fields prevent unnecessary manual edits +- Efficient hook execution with proper change detection ## Documentation Requirements - Document all public APIs with examples -- Provide integration guides for each payment provider -- Include troubleshooting guides for common issues +- Provide clear customer info extractor examples +- Include configuration guides for different use cases - Maintain up-to-date TypeScript documentation \ No newline at end of file diff --git a/dev/app/my-route/route.ts b/dev/app/my-route/route.ts index 0755886..a3bf0fe 100644 --- a/dev/app/my-route/route.ts +++ b/dev/app/my-route/route.ts @@ -1,11 +1,13 @@ import configPromise from '@payload-config' import { getPayload } from 'payload' +import { useBillingPlugin } from '../../../src/plugin' export const GET = async (request: Request) => { const payload = await getPayload({ config: configPromise, }) + return Response.json({ message: 'This is an example of a custom route.', }) diff --git a/dev/payload-types.ts b/dev/payload-types.ts index 3c141b2..5f1fb92 100644 --- a/dev/payload-types.ts +++ b/dev/payload-types.ts @@ -70,7 +70,6 @@ export interface Config { posts: Post; media: Media; payments: Payment; - customers: Customer; invoices: Invoice; refunds: Refund; users: User; @@ -83,7 +82,6 @@ export interface Config { posts: PostsSelect | PostsSelect; media: MediaSelect | MediaSelect; payments: PaymentsSelect | PaymentsSelect; - customers: CustomersSelect | CustomersSelect; invoices: InvoicesSelect | InvoicesSelect; refunds: RefundsSelect | RefundsSelect; users: UsersSelect | UsersSelect; @@ -174,7 +172,6 @@ export interface Payment { * Payment description */ description?: string | null; - customer?: (number | null) | Customer; invoice?: (number | null) | Invoice; /** * Additional metadata for the payment @@ -204,70 +201,6 @@ export interface Payment { updatedAt: string; createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "customers". - */ -export interface Customer { - id: number; - /** - * Customer email address - */ - email?: string | null; - /** - * Customer full name - */ - name?: string | null; - /** - * Customer phone number - */ - phone?: string | null; - address?: { - line1?: string | null; - line2?: string | null; - city?: string | null; - state?: string | null; - postal_code?: string | null; - /** - * ISO 3166-1 alpha-2 country code - */ - country?: string | null; - }; - /** - * Customer IDs from payment providers - */ - providerIds?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; - /** - * Additional customer metadata - */ - metadata?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; - /** - * Customer payments - */ - payments?: (number | Payment)[] | null; - /** - * Customer invoices - */ - invoices?: (number | Invoice)[] | null; - updatedAt: string; - createdAt: string; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "invoices". @@ -278,7 +211,57 @@ export interface Invoice { * Invoice number (e.g., INV-001) */ number: string; - customer: number | Customer; + /** + * Customer billing information + */ + customerInfo: { + /** + * Customer name + */ + name: string; + /** + * Customer email address + */ + email: string; + /** + * Customer phone number + */ + phone?: string | null; + /** + * Company name (optional) + */ + company?: string | null; + /** + * Tax ID or VAT number + */ + taxId?: string | null; + }; + /** + * Billing address + */ + billingAddress: { + /** + * Address line 1 + */ + line1: string; + /** + * Address line 2 + */ + line2?: string | null; + city: string; + /** + * State or province + */ + state?: string | null; + /** + * Postal or ZIP code + */ + postalCode: string; + /** + * Country code (e.g., US, GB) + */ + country: string; + }; status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'; /** * ISO 4217 currency code (e.g., USD, EUR) @@ -422,10 +405,6 @@ export interface PayloadLockedDocument { relationTo: 'payments'; value: number | Payment; } | null) - | ({ - relationTo: 'customers'; - value: number | Customer; - } | null) | ({ relationTo: 'invoices'; value: number | Invoice; @@ -516,7 +495,6 @@ export interface PaymentsSelect { amount?: T; currency?: T; description?: T; - customer?: T; invoice?: T; metadata?: T; providerData?: T; @@ -526,36 +504,29 @@ export interface PaymentsSelect { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "customers_select". + * via the `definition` "invoices_select". */ -export interface CustomersSelect { - email?: T; - name?: T; - phone?: T; - address?: +export interface InvoicesSelect { + number?: T; + customerInfo?: + | T + | { + name?: T; + email?: T; + phone?: T; + company?: T; + taxId?: T; + }; + billingAddress?: | T | { line1?: T; line2?: T; city?: T; state?: T; - postal_code?: T; + postalCode?: T; country?: T; }; - providerIds?: T; - metadata?: T; - payments?: T; - invoices?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "invoices_select". - */ -export interface InvoicesSelect { - number?: T; - customer?: T; status?: T; currency?: T; items?: diff --git a/dev/payload.config.ts b/dev/payload.config.ts index d18ca44..f7db56d 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -2,12 +2,13 @@ import { sqliteAdapter } from '@payloadcms/db-sqlite' import { lexicalEditor } from '@payloadcms/richtext-lexical' import path from 'path' import { buildConfig } from 'payload' -import { billingPlugin, defaultCustomerInfoExtractor } from '../dist/index.js' import sharp from 'sharp' import { fileURLToPath } from 'url' import { testEmailAdapter } from './helpers/testEmailAdapter' import { seed } from './seed' +import billingPlugin from '../src/plugin' +import { mollieProvider } from '../src/providers' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -48,36 +49,16 @@ const buildConfigWithSQLite = () => { }, plugins: [ billingPlugin({ - providers: { - test: { - enabled: true, - autoComplete: true, - } - }, + providers: [ + mollieProvider({ + apiKey: process.env.MOLLIE_KEY! + }) + ], collections: { payments: 'payments', - customers: 'customers', invoices: 'invoices', refunds: 'refunds', - // customerRelation: false, // Set to false to disable customer relationship in invoices - // customerRelation: 'clients', // Or set to a custom collection slug }, - // Use the default extractor for the built-in customer collection - customerInfoExtractor: defaultCustomerInfoExtractor, - // Or provide a custom extractor for your own customer collection structure: - // customerInfoExtractor: (customer) => ({ - // name: customer.fullName, - // email: customer.contactEmail, - // phone: customer.phoneNumber, - // company: customer.companyName, - // taxId: customer.vatNumber, - // billingAddress: { - // line1: customer.billing.street, - // city: customer.billing.city, - // postalCode: customer.billing.zip, - // country: customer.billing.countryCode, - // } - // }) }), ], secret: process.env.PAYLOAD_SECRET || 'test-secret_key', diff --git a/dev/seed.ts b/dev/seed.ts index 97e1e34..935948a 100644 --- a/dev/seed.ts +++ b/dev/seed.ts @@ -21,129 +21,9 @@ export const seed = async (payload: Payload) => { } // Seed billing sample data - await seedBillingData(payload) + // await seedBillingData(payload) } -async function seedBillingData(payload: Payload): Promise { - payload.logger.info('Seeding billing sample data...') - - try { - // Check if we already have sample data - const existingCustomers = await payload.count({ - collection: 'customers', - where: { - email: { - equals: 'john.doe@example.com', - }, - }, - }) - - if (existingCustomers.totalDocs > 0) { - payload.logger.info('Sample billing data already exists, skipping seed') - return - } - - // Create a sample customer - const customer = await payload.create({ - collection: 'customers', - data: { - email: 'john.doe@example.com', - name: 'John Doe', - phone: '+1-555-0123', - address: { - line1: '123 Main St', - city: 'New York', - state: 'NY', - postal_code: '10001', - country: 'US' - }, - metadata: { - source: 'seed', - created_by: 'system' - } - } - }) - - payload.logger.info(`Created sample customer: ${customer.id}`) - - // Create a sample invoice - const invoice = await payload.create({ - collection: 'invoices', - data: { - number: 'INV-001-SAMPLE', - customer: customer.id, - currency: 'USD', - items: [ - { - description: 'Web Development Services', - quantity: 10, - unitAmount: 5000, // $50.00 per hour - totalAmount: 50000 // $500.00 total - }, - { - description: 'Design Consultation', - quantity: 2, - unitAmount: 7500, // $75.00 per hour - totalAmount: 15000 // $150.00 total - } - ], - subtotal: 65000, // $650.00 - taxAmount: 5200, // $52.00 (8% tax) - amount: 70200, // $702.00 total - status: 'open', - dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now - notes: 'Payment terms: Net 30 days. This is sample data for development.', - metadata: { - project: 'website-redesign', - billable_hours: 12, - sample: true - } - } - }) - - payload.logger.info(`Created sample invoice: ${invoice.number}`) - - // Create a sample payment using test provider - const payment = await payload.create({ - collection: 'payments', - data: { - provider: 'test', - providerId: `test_pay_sample_${Date.now()}`, - status: 'succeeded', - amount: 70200, // $702.00 - currency: 'USD', - description: `Sample payment for invoice ${invoice.number}`, - customer: customer.id, - invoice: invoice.id, - metadata: { - invoice_number: invoice.number, - payment_method: 'test_card', - sample: true - }, - providerData: { - testMode: true, - simulatedPayment: true, - autoCompleted: true - } - } - }) - - payload.logger.info(`Created sample payment: ${payment.id}`) - - // Update invoice status to paid - await payload.update({ - collection: 'invoices', - id: invoice.id, - data: { - status: 'paid', - payment: payment.id, - paidAt: new Date().toISOString() - } - }) - - payload.logger.info('Billing sample data seeded successfully!') - - } catch (error) { - payload.logger.error('Error seeding billing data:', error) - } -} +// async function seedBillingData(payload: Payload): Promise { +// payload.logger.info('Seeding billing sample data...') +// } diff --git a/eslint.config.js b/eslint.config.js index aacea9f..b831856 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,7 @@ export default [ 'perfectionist/sort-switch-case': 'off', 'perfectionist/sort-union-types': 'off', 'perfectionist/sort-variable-declarations': 'off', + 'perfectionist/sort-intersection-types': 'off', }, }, { diff --git a/package.json b/package.json index a60f513..bd691ce 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "devDependencies": { "@changesets/cli": "^2.27.1", "@eslint/eslintrc": "^3.2.0", + "@mollie/api-client": "^3.7.0", "@payloadcms/db-mongodb": "3.37.0", "@payloadcms/db-postgres": "3.37.0", "@payloadcms/db-sqlite": "3.37.0", @@ -104,11 +105,11 @@ "vitest": "^3.1.2" }, "peerDependencies": { + "@mollie/api-client": "^3.7.0", "payload": "^3.37.0" }, "dependencies": { "stripe": "^14.15.0", - "@mollie/api-client": "^3.7.0", "zod": "^3.22.4" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f66fd65..b2677bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@mollie/api-client': - specifier: ^3.7.0 - version: 3.7.0 stripe: specifier: ^14.15.0 version: 14.25.0 @@ -24,6 +21,9 @@ importers: '@eslint/eslintrc': specifier: ^3.2.0 version: 3.3.1 + '@mollie/api-client': + specifier: ^3.7.0 + version: 3.7.0 '@payloadcms/db-mongodb': specifier: 3.37.0 version: 3.37.0(payload@3.37.0(graphql@16.11.0)(typescript@5.7.3)) @@ -8875,7 +8875,7 @@ snapshots: eslint: 9.35.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0) eslint-plugin-react: 7.37.5(eslint@9.35.0) eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0) @@ -8909,7 +8909,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0) eslint-plugin-import-x: 4.4.2(eslint@9.35.0)(typescript@5.7.3) transitivePeerDependencies: - supports-color @@ -8960,7 +8960,7 @@ snapshots: - typescript optional: true - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.4.2(eslint@9.35.0)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.7.3))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 diff --git a/src/collections/customers.ts b/src/collections/customers.ts deleted file mode 100644 index 6bb55f2..0000000 --- a/src/collections/customers.ts +++ /dev/null @@ -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) => { - if (operation === 'create') { - req.payload.logger.info(`Customer created: ${doc.id} (${doc.email})`) - } - }, - ], - beforeChange: [ - ({ data, operation }: CollectionBeforeChangeHook) => { - 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, - } -} \ No newline at end of file diff --git a/src/collections/hooks.ts b/src/collections/hooks.ts new file mode 100644 index 0000000..406fae5 --- /dev/null +++ b/src/collections/hooks.ts @@ -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) => { + 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) +} diff --git a/src/collections/invoices.ts b/src/collections/invoices.ts index fdaf804..fd689bc 100644 --- a/src/collections/invoices.ts +++ b/src/collections/invoices.ts @@ -1,23 +1,303 @@ -import type { CollectionConfig } from 'payload' - -import type { +import { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionBeforeValidateHook, - InvoiceData, - InvoiceDocument, - InvoiceItemData -} from '../types/payload' -import type { CustomerInfoExtractor } from '../types' + CollectionConfig, Field, +} from 'payload' +import type { BillingPluginConfig} from '@/plugin/config'; +import { defaults } from '@/plugin/config' +import { extractSlug } from '@/plugin/utils' +import type { Invoice } from '@/plugin/types/invoices' -export function createInvoicesCollection( - slug: string = 'invoices', - customerCollectionSlug?: string, - customerInfoExtractor?: CustomerInfoExtractor -): CollectionConfig { +export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig { + const {customerRelationSlug, customerInfoExtractor} = pluginConfig + const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {} + let fields: Field[] = [ + { + name: 'number', + type: 'text', + admin: { + description: 'Invoice number (e.g., INV-001)', + }, + index: true, + required: true, + unique: true, + }, + // Optional customer relationship + ...(customerRelationSlug ? [{ + name: 'customer', + type: 'relationship' as const, + admin: { + position: 'sidebar' as const, + description: 'Link to customer record (optional)', + }, + relationTo: extractSlug(customerRelationSlug), + required: false, + }] : []), + // Basic customer info fields (embedded) + { + name: 'customerInfo', + type: 'group', + admin: { + description: customerRelationSlug && customerInfoExtractor + ? 'Customer billing information (auto-populated from customer relationship)' + : 'Customer billing information', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + fields: [ + { + name: 'name', + type: 'text', + admin: { + description: 'Customer name', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + required: !customerRelationSlug || !customerInfoExtractor, + }, + { + name: 'email', + type: 'email', + admin: { + description: 'Customer email address', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + required: !customerRelationSlug || !customerInfoExtractor, + }, + { + name: 'phone', + type: 'text', + admin: { + description: 'Customer phone number', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + }, + { + name: 'company', + type: 'text', + admin: { + description: 'Company name (optional)', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + }, + { + name: 'taxId', + type: 'text', + admin: { + description: 'Tax ID or VAT number', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + }, + ], + }, + { + name: 'billingAddress', + type: 'group', + admin: { + description: customerRelationSlug && customerInfoExtractor + ? 'Billing address (auto-populated from customer relationship)' + : 'Billing address', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + fields: [ + { + name: 'line1', + type: 'text', + admin: { + description: 'Address line 1', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + required: !customerRelationSlug || !customerInfoExtractor, + }, + { + name: 'line2', + type: 'text', + admin: { + description: 'Address line 2', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + }, + { + name: 'city', + type: 'text', + admin: { + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + required: !customerRelationSlug || !customerInfoExtractor, + }, + { + name: 'state', + type: 'text', + admin: { + description: 'State or province', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + }, + { + name: 'postalCode', + type: 'text', + admin: { + description: 'Postal or ZIP code', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + required: !customerRelationSlug || !customerInfoExtractor, + }, + { + name: 'country', + type: 'text', + admin: { + description: 'Country code (e.g., US, GB)', + readOnly: !!(customerRelationSlug && customerInfoExtractor), + }, + maxLength: 2, + required: !customerRelationSlug || !customerInfoExtractor, + }, + ], + }, + { + name: 'status', + type: 'select', + admin: { + position: 'sidebar', + }, + defaultValue: 'draft', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Open', value: 'open' }, + { label: 'Paid', value: 'paid' }, + { label: 'Void', value: 'void' }, + { label: 'Uncollectible', value: 'uncollectible' }, + ], + required: true, + }, + { + name: 'currency', + type: 'text', + admin: { + description: 'ISO 4217 currency code (e.g., USD, EUR)', + }, + defaultValue: 'USD', + maxLength: 3, + required: true, + }, + { + name: 'items', + type: 'array', + admin: { + // Custom row labeling can be added here when needed + }, + fields: [ + { + name: 'description', + type: 'text', + admin: { + width: '40%', + }, + required: true, + }, + { + name: 'quantity', + type: 'number', + admin: { + width: '15%', + }, + defaultValue: 1, + min: 1, + required: true, + }, + { + name: 'unitAmount', + type: 'number', + admin: { + description: 'Amount in cents', + width: '20%', + }, + min: 0, + required: true, + }, + { + name: 'totalAmount', + type: 'number', + admin: { + description: 'Calculated: quantity × unitAmount', + readOnly: true, + width: '20%', + }, + }, + ], + minRows: 1, + required: true, + }, + { + name: 'subtotal', + type: 'number', + admin: { + description: 'Sum of all line items', + readOnly: true, + }, + }, + { + name: 'taxAmount', + type: 'number', + admin: { + description: 'Tax amount in cents', + }, + defaultValue: 0, + }, + { + name: 'amount', + type: 'number', + admin: { + description: 'Total amount (subtotal + tax)', + readOnly: true, + }, + }, + { + name: 'dueDate', + type: 'date', + admin: { + date: { + pickerAppearance: 'dayOnly', + }, + }, + }, + { + name: 'paidAt', + type: 'date', + admin: { + condition: (data) => data.status === 'paid', + readOnly: true, + }, + }, + { + name: 'payment', + type: 'relationship', + admin: { + condition: (data) => data.status === 'paid', + position: 'sidebar', + }, + relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), + }, + { + name: 'notes', + type: 'textarea', + admin: { + description: 'Internal notes', + }, + }, + { + name: 'metadata', + type: 'json', + admin: { + description: 'Additional invoice metadata', + }, + }, + ] + if (overrides?.fields) { + fields = overrides.fields({defaultFields: fields}) + } return { - slug, + slug: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection), access: { create: ({ req: { user } }: AccessArgs) => !!user, delete: ({ req: { user } }: AccessArgs) => !!user, @@ -29,298 +309,19 @@ export function createInvoicesCollection( group: 'Billing', useAsTitle: 'number', }, - fields: [ - { - name: 'number', - type: 'text', - admin: { - description: 'Invoice number (e.g., INV-001)', - }, - index: true, - required: true, - unique: true, - }, - // Optional customer relationship - ...(customerCollectionSlug ? [{ - name: 'customer', - type: 'relationship' as const, - admin: { - position: 'sidebar' as const, - description: 'Link to customer record (optional)', - }, - relationTo: customerCollectionSlug as any, - required: false, - }] : []), - // Basic customer info fields (embedded) - { - name: 'customerInfo', - type: 'group', - admin: { - description: customerCollectionSlug && customerInfoExtractor - ? 'Customer billing information (auto-populated from customer relationship)' - : 'Customer billing information', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - fields: [ - { - name: 'name', - type: 'text', - admin: { - description: 'Customer name', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - required: !customerCollectionSlug || !customerInfoExtractor, - }, - { - name: 'email', - type: 'email', - admin: { - description: 'Customer email address', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - required: !customerCollectionSlug || !customerInfoExtractor, - }, - { - name: 'phone', - type: 'text', - admin: { - description: 'Customer phone number', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - }, - { - name: 'company', - type: 'text', - admin: { - description: 'Company name (optional)', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - }, - { - name: 'taxId', - type: 'text', - admin: { - description: 'Tax ID or VAT number', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - }, - ], - }, - { - name: 'billingAddress', - type: 'group', - admin: { - description: customerCollectionSlug && customerInfoExtractor - ? 'Billing address (auto-populated from customer relationship)' - : 'Billing address', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - fields: [ - { - name: 'line1', - type: 'text', - admin: { - description: 'Address line 1', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - required: !customerCollectionSlug || !customerInfoExtractor, - }, - { - name: 'line2', - type: 'text', - admin: { - description: 'Address line 2', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - }, - { - name: 'city', - type: 'text', - admin: { - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - required: !customerCollectionSlug || !customerInfoExtractor, - }, - { - name: 'state', - type: 'text', - admin: { - description: 'State or province', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - }, - { - name: 'postalCode', - type: 'text', - admin: { - description: 'Postal or ZIP code', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - required: !customerCollectionSlug || !customerInfoExtractor, - }, - { - name: 'country', - type: 'text', - admin: { - description: 'Country code (e.g., US, GB)', - readOnly: customerCollectionSlug && customerInfoExtractor ? true : false, - }, - maxLength: 2, - required: !customerCollectionSlug || !customerInfoExtractor, - }, - ], - }, - { - name: 'status', - type: 'select', - admin: { - position: 'sidebar', - }, - defaultValue: 'draft', - options: [ - { label: 'Draft', value: 'draft' }, - { label: 'Open', value: 'open' }, - { label: 'Paid', value: 'paid' }, - { label: 'Void', value: 'void' }, - { label: 'Uncollectible', value: 'uncollectible' }, - ], - required: true, - }, - { - name: 'currency', - type: 'text', - admin: { - description: 'ISO 4217 currency code (e.g., USD, EUR)', - }, - defaultValue: 'USD', - maxLength: 3, - required: true, - }, - { - name: 'items', - type: 'array', - admin: { - // Custom row labeling can be added here when needed - }, - fields: [ - { - name: 'description', - type: 'text', - admin: { - width: '40%', - }, - required: true, - }, - { - name: 'quantity', - type: 'number', - admin: { - width: '15%', - }, - defaultValue: 1, - min: 1, - required: true, - }, - { - name: 'unitAmount', - type: 'number', - admin: { - description: 'Amount in cents', - width: '20%', - }, - min: 0, - required: true, - }, - { - name: 'totalAmount', - type: 'number', - admin: { - description: 'Calculated: quantity × unitAmount', - readOnly: true, - width: '20%', - }, - }, - ], - minRows: 1, - required: true, - }, - { - name: 'subtotal', - type: 'number', - admin: { - description: 'Sum of all line items', - readOnly: true, - }, - }, - { - name: 'taxAmount', - type: 'number', - admin: { - description: 'Tax amount in cents', - }, - defaultValue: 0, - }, - { - name: 'amount', - type: 'number', - admin: { - description: 'Total amount (subtotal + tax)', - readOnly: true, - }, - }, - { - name: 'dueDate', - type: 'date', - admin: { - date: { - pickerAppearance: 'dayOnly', - }, - }, - }, - { - name: 'paidAt', - type: 'date', - admin: { - condition: (data: InvoiceData) => data.status === 'paid', - readOnly: true, - }, - }, - { - name: 'payment', - type: 'relationship', - admin: { - condition: (data: InvoiceData) => data.status === 'paid', - position: 'sidebar', - }, - relationTo: 'payments', - }, - { - name: 'notes', - type: 'textarea', - admin: { - description: 'Internal notes', - }, - }, - { - name: 'metadata', - type: 'json', - admin: { - description: 'Additional invoice metadata', - }, - }, - ], + fields, hooks: { afterChange: [ - ({ doc, operation, req }: CollectionAfterChangeHook) => { + ({ doc, operation, req }) => { if (operation === 'create') { req.payload.logger.info(`Invoice created: ${doc.number}`) } }, - ], + ] satisfies CollectionAfterChangeHook[], beforeChange: [ - async ({ data, operation, req, originalDoc }: CollectionBeforeChangeHook) => { + async ({ data, operation, req, originalDoc }) => { // Sync customer info from relationship if extractor is provided - if (customerCollectionSlug && customerInfoExtractor && data.customer) { + if (customerRelationSlug && customerInfoExtractor && data.customer) { // Check if customer changed or this is a new invoice const customerChanged = operation === 'create' || (originalDoc && originalDoc.customer !== data.customer) @@ -329,8 +330,8 @@ export function createInvoicesCollection( try { // Fetch the customer data const customer = await req.payload.findByID({ - collection: customerCollectionSlug, - id: data.customer, + collection: customerRelationSlug as never, + id: data.customer as never, }) // Extract customer info using the provided callback @@ -383,37 +384,37 @@ export function createInvoicesCollection( data.paidAt = new Date().toISOString() } }, - ], + ] satisfies CollectionBeforeChangeHook[], beforeValidate: [ - ({ data }: CollectionBeforeValidateHook) => { + ({ data }) => { if (!data) return // If using extractor, customer relationship is required - if (customerCollectionSlug && customerInfoExtractor && !data.customer) { + if (customerRelationSlug && customerInfoExtractor && !data.customer) { throw new Error('Please select a customer') } // If not using extractor but have customer collection, either relationship or info is required - if (customerCollectionSlug && !customerInfoExtractor && + if (customerRelationSlug && !customerInfoExtractor && !data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) { throw new Error('Either select a customer or provide customer information') } // If no customer collection, ensure customer info is provided - if (!customerCollectionSlug && (!data.customerInfo?.name || !data.customerInfo?.email)) { + if (!customerRelationSlug && (!data.customerInfo?.name || !data.customerInfo?.email)) { throw new Error('Customer name and email are required') } if (data && data.items && Array.isArray(data.items)) { // Calculate totals for each line item - data.items = data.items.map((item: InvoiceItemData) => ({ + data.items = data.items.map((item) => ({ ...item, totalAmount: (item.quantity || 0) * (item.unitAmount || 0), })) // Calculate subtotal data.subtotal = data.items.reduce( - (sum: number, item: InvoiceItemData) => sum + (item.totalAmount || 0), + (sum: number, item) => sum + (item.totalAmount || 0), 0 ) @@ -421,8 +422,8 @@ export function createInvoicesCollection( data.amount = (data.subtotal || 0) + (data.taxAmount || 0) } }, - ], + ] satisfies CollectionBeforeValidateHook[], }, timestamps: true, } -} \ No newline at end of file +} diff --git a/src/collections/payments.ts b/src/collections/payments.ts index 7aff5cc..b5d2665 100644 --- a/src/collections/payments.ts +++ b/src/collections/payments.ts @@ -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 { - AccessArgs, - CollectionAfterChangeHook, - CollectionBeforeChangeHook, - PaymentData, - PaymentDocument -} from '../types/payload' - -export function createPaymentsCollection(slug: string = 'payments'): CollectionConfig { +export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { + const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {} + let fields: Field[] = [ + { + name: 'provider', + type: 'select', + admin: { + position: 'sidebar', + }, + options: [ + { label: 'Stripe', value: 'stripe' }, + { label: 'Mollie', value: 'mollie' }, + { label: 'Test', value: 'test' }, + ], + required: true, + }, + { + name: 'providerId', + type: 'text', + admin: { + description: 'The payment ID from the payment provider', + }, + label: 'Provider Payment ID', + unique: true, + }, + { + 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 { - slug, - access: { + slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), + access: overrides?.access || { create: ({ req: { user } }: AccessArgs) => !!user, delete: ({ req: { user } }: AccessArgs) => !!user, read: ({ req: { user } }: AccessArgs) => !!user, @@ -21,131 +121,18 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'], group: 'Billing', useAsTitle: 'id', + ...overrides?.admin }, - fields: [ - { - name: 'provider', - type: 'select', - admin: { - position: 'sidebar', - }, - options: [ - { label: 'Stripe', value: 'stripe' }, - { label: 'Mollie', value: 'mollie' }, - { label: 'Test', value: 'test' }, - ], - required: true, - }, - { - name: 'providerId', - type: 'text', - admin: { - description: 'The payment ID from the payment provider', - }, - label: 'Provider Payment ID', - required: true, - unique: true, - }, - { - name: 'status', - type: 'select', - admin: { - position: 'sidebar', - }, - options: [ - { label: 'Pending', value: 'pending' }, - { label: 'Processing', value: 'processing' }, - { label: 'Succeeded', value: 'succeeded' }, - { label: 'Failed', value: 'failed' }, - { label: 'Canceled', value: 'canceled' }, - { label: 'Refunded', value: 'refunded' }, - { label: 'Partially Refunded', value: 'partially_refunded' }, - ], - required: true, - }, - { - name: 'amount', - type: 'number', - admin: { - description: 'Amount in cents (e.g., 2000 = $20.00)', - }, - min: 1, - required: true, - }, - { - name: 'currency', - type: 'text', - admin: { - description: 'ISO 4217 currency code (e.g., USD, EUR)', - }, - maxLength: 3, - required: true, - }, - { - name: 'description', - type: 'text', - admin: { - description: 'Payment description', - }, - }, - { - name: 'customer', - type: 'relationship', - admin: { - position: 'sidebar', - }, - relationTo: 'customers', - }, - { - name: 'invoice', - type: 'relationship', - admin: { - position: 'sidebar', - }, - relationTo: 'invoices', - }, - { - name: 'metadata', - type: 'json', - admin: { - description: 'Additional metadata for the payment', - }, - }, - { - name: 'providerData', - type: 'json', - admin: { - description: 'Raw data from the payment provider', - readOnly: true, - }, - }, - { - name: 'refunds', - type: 'relationship', - admin: { - position: 'sidebar', - readOnly: true, - }, - hasMany: true, - relationTo: 'refunds', - }, - ], + fields, hooks: { - afterChange: [ - ({ doc, operation, req }: CollectionAfterChangeHook) => { - if (operation === 'create') { - req.payload.logger.info(`Payment created: ${doc.id} (${doc.provider})`) - } - }, - ], beforeChange: [ - ({ data, operation }: CollectionBeforeChangeHook) => { + async ({ data, operation, req }) => { if (operation === 'create') { // Validate amount format if (data.amount && !Number.isInteger(data.amount)) { throw new Error('Amount must be an integer (in cents)') } - + // Validate currency format if (data.currency) { data.currency = data.currency.toUpperCase() @@ -153,10 +140,12 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC throw new Error('Currency must be a 3-letter ISO code') } } + + await initProviderPayment(req.payload, data) } }, - ], + ] satisfies CollectionBeforeChangeHook[], }, timestamps: true, } -} \ No newline at end of file +} diff --git a/src/collections/refunds.ts b/src/collections/refunds.ts index 7aba74a..aa484e9 100644 --- a/src/collections/refunds.ts +++ b/src/collections/refunds.ts @@ -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 { - AccessArgs, - CollectionAfterChangeHook, - CollectionBeforeChangeHook, - RefundData, - RefundDocument -} from '../types/payload' - -export function createRefundsCollection(slug: string = 'refunds'): CollectionConfig { +export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { + // TODO: finish collection overrides return { - slug, + slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection), access: { create: ({ req: { user } }: AccessArgs) => !!user, delete: ({ req: { user } }: AccessArgs) => !!user, @@ -39,7 +35,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon admin: { position: 'sidebar', }, - relationTo: 'payments', + relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), required: true, }, { @@ -113,7 +109,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon ], hooks: { afterChange: [ - async ({ doc, operation, req }: CollectionAfterChangeHook) => { + async ({ doc, operation, req }) => { if (operation === 'create') { req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`) @@ -121,15 +117,15 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon try { const payment = await req.payload.findByID({ id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id, - collection: 'payments', - }) + collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), + }) as Payment const refundIds = Array.isArray(payment.refunds) ? payment.refunds : [] await req.payload.update({ id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id, - collection: 'payments', + collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), data: { - refunds: [...refundIds, doc.id as any], + refunds: [...refundIds, doc.id], }, }) } catch (error) { @@ -139,7 +135,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon }, ], beforeChange: [ - ({ data, operation }: CollectionBeforeChangeHook) => { + ({ data, operation }) => { if (operation === 'create') { // Validate amount format if (data.amount && !Number.isInteger(data.amount)) { diff --git a/src/index.ts b/src/index.ts index 303a57d..882fe58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,98 +1,4 @@ -import type { Config } from 'payload' -import type { BillingPluginConfig, CustomerInfoExtractor } from './types' - -import { createCustomersCollection } from './collections/customers' -import { createInvoicesCollection } from './collections/invoices' -import { createPaymentsCollection } from './collections/payments' -import { createRefundsCollection } from './collections/refunds' - -export * from './types' - -// Default customer info extractor for the built-in customer collection -export const defaultCustomerInfoExtractor: CustomerInfoExtractor = (customer) => { - return { - name: customer.name || '', - email: customer.email || '', - phone: customer.phone, - company: customer.company, - taxId: customer.taxId, - billingAddress: customer.address ? { - line1: customer.address.line1 || '', - line2: customer.address.line2, - city: customer.address.city || '', - state: customer.address.state, - postalCode: customer.address.postalCode || '', - country: customer.address.country || '', - } : undefined, - } -} - -export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => { - if (pluginConfig.disabled) { - return config - } - - // Initialize collections - if (!config.collections) { - config.collections = [] - } - - const customerSlug = pluginConfig.collections?.customers || 'customers' - - config.collections.push( - createPaymentsCollection(pluginConfig.collections?.payments || 'payments'), - createCustomersCollection(customerSlug), - createInvoicesCollection( - pluginConfig.collections?.invoices || 'invoices', - pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined, - pluginConfig.customerInfoExtractor - ), - createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'), - ) - - // Initialize endpoints - if (!config.endpoints) { - config.endpoints = [] - } - - config.endpoints?.push( - // Webhook endpoints - { - handler: (_req) => { - try { - const provider = null - if (!provider) { - return Response.json({ error: 'Provider not found' }, { status: 404 }) - } - - // TODO: Process webhook event and update database - - return Response.json({ received: true }) - } catch (error) { - // TODO: Use proper logger instead of console - // eslint-disable-next-line no-console - console.error('[BILLING] Webhook error:', error) - return Response.json({ error: 'Webhook processing failed' }, { status: 400 }) - } - }, - method: 'post', - path: '/billing/webhooks/:provider' - }, - ) - - // Initialize providers and onInit hook - const incomingOnInit = config.onInit - - config.onInit = async (payload) => { - // Execute any existing onInit functions first - if (incomingOnInit) { - await incomingOnInit(payload) - } - - } - - return config -} - -export default billingPlugin +export { billingPlugin } from './plugin' +export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config' +export type { Invoice, Payment, Refund } from './plugin/types' diff --git a/src/plugin/config.ts b/src/plugin/config.ts new file mode 100644 index 0000000..d2385d8 --- /dev/null +++ b/src/plugin/config.ts @@ -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 & {fields?: FieldsOverride}) + payments?: string | (Partial & {fields?: FieldsOverride}) + refunds?: string | (Partial & {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 +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts new file mode 100644 index 0000000..3bac540 --- /dev/null +++ b/src/plugin/index.ts @@ -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 + ) + } 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 diff --git a/src/plugin/singleton.ts b/src/plugin/singleton.ts new file mode 100644 index 0000000..5b90c85 --- /dev/null +++ b/src/plugin/singleton.ts @@ -0,0 +1,11 @@ +export const createSingleton = (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 + }, + } +} diff --git a/src/plugin/types/id.ts b/src/plugin/types/id.ts new file mode 100644 index 0000000..59dbb83 --- /dev/null +++ b/src/plugin/types/id.ts @@ -0,0 +1 @@ +export type Id = string | number diff --git a/src/plugin/types/index.ts b/src/plugin/types/index.ts new file mode 100644 index 0000000..dbc808b --- /dev/null +++ b/src/plugin/types/index.ts @@ -0,0 +1,5 @@ +export * from './id' +export * from './invoices' +export * from './payments' +export * from './refunds' +export * from '../../providers/types' diff --git a/src/plugin/types/invoices.ts b/src/plugin/types/invoices.ts new file mode 100644 index 0000000..b42d342 --- /dev/null +++ b/src/plugin/types/invoices.ts @@ -0,0 +1,117 @@ +import { Payment } from '@/plugin/types/payments' + +import { Id } from '@/plugin/types/id' + +export interface Invoice { + 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; +} diff --git a/src/plugin/types/payments.ts b/src/plugin/types/payments.ts new file mode 100644 index 0000000..8307679 --- /dev/null +++ b/src/plugin/types/payments.ts @@ -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; +} diff --git a/src/plugin/types/refunds.ts b/src/plugin/types/refunds.ts new file mode 100644 index 0000000..a8366d1 --- /dev/null +++ b/src/plugin/types/refunds.ts @@ -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; +} diff --git a/src/plugin/utils.ts b/src/plugin/utils.ts new file mode 100644 index 0000000..9ebdd01 --- /dev/null +++ b/src/plugin/utils.ts @@ -0,0 +1,6 @@ +import type { CollectionConfig, CollectionSlug, Field } from 'payload' + +export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] + +export const extractSlug = + (arg: string | Partial) => (typeof arg === 'string' ? arg : arg.slug!) as CollectionSlug diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..e18301a --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,2 @@ +export * from './mollie' +export * from './types' diff --git a/src/providers/mollie.ts b/src/providers/mollie.ts new file mode 100644 index 0000000..5ef7794 --- /dev/null +++ b/src/providers/mollie.ts @@ -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[0] + +export const mollieProvider = (config: MollieProviderConfig) => { + const singleton = createSingleton(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 +} diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..d15aa84 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,10 @@ +import type { Payment } from '@/plugin/types/payments' +import type { Payload } from 'payload' + +export type InitPayment = (payload: Payload, payment: Partial) => Promise> + +export type PaymentProvider = { + key: string + onInit: (payload: Payload) => Promise | void + initPayment: InitPayment +} diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 55f264d..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { Config } from 'payload' - -// Base payment provider interface -export interface PaymentProvider { - cancelPayment(id: string): Promise - createPayment(options: CreatePaymentOptions): Promise - handleWebhook(request: Request, signature?: string): Promise - name: string - refundPayment(id: string, amount?: number): Promise - retrievePayment(id: string): Promise -} - -// Payment types -export interface CreatePaymentOptions { - amount: number - cancelUrl?: string - currency: string - customer?: string - description?: string - metadata?: Record - returnUrl?: string -} - -export interface Payment { - amount: number - createdAt: string - currency: string - customer?: string - description?: string - id: string - metadata?: Record - provider: string - providerData?: Record - status: PaymentStatus - updatedAt: string -} - -export interface Refund { - amount: number - createdAt: string - currency: string - id: string - paymentId: string - providerData?: Record - reason?: string - status: RefundStatus -} - -export interface WebhookEvent { - data: Record - 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 - provider: string - providerData?: Record - 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 - name?: string - phone?: string - providerIds?: Record - 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 - 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 - ) { - super(message) - this.name = 'BillingError' - } -} - -export class PaymentProviderError extends BillingError { - constructor( - message: string, - provider: string, - code?: string, - details?: Record - ) { - super(message, code || 'PROVIDER_ERROR', provider, details) - this.name = 'PaymentProviderError' - } -} - -export class WebhookError extends BillingError { - constructor( - message: string, - provider: string, - code?: string, - details?: Record - ) { - super(message, code || 'WEBHOOK_ERROR', provider, details) - this.name = 'WebhookError' - } -} \ No newline at end of file diff --git a/src/types/payload.ts b/src/types/payload.ts deleted file mode 100644 index a4f2582..0000000 --- a/src/types/payload.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * PayloadCMS type definitions for hooks and handlers - */ - -import type { PayloadRequest, User } from 'payload' - -// Collection hook types -export interface CollectionBeforeChangeHook> { - data: T - operation: 'create' | 'delete' | 'update' - originalDoc?: T - req: PayloadRequest -} - -export interface CollectionAfterChangeHook> { - doc: T - operation: 'create' | 'delete' | 'update' - previousDoc?: T - req: PayloadRequest -} - -export interface CollectionBeforeValidateHook> { - data?: T - operation: 'create' | 'update' - originalDoc?: T - req: PayloadRequest -} - -// Access control types -export interface AccessArgs { - 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 - 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 - provider?: string - providerData?: Record - 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 - name?: string - phone?: string - providerIds?: Record -} - -// Refund data type for hooks -export interface RefundData { - amount?: number - currency?: string - description?: string - metadata?: Record - payment?: { id: string | number } | string - providerData?: Record - 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 -}