From 28e9e8d2088e350e955466e70bd95bfba4303fb2 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Mon, 15 Sep 2025 21:40:41 +0200 Subject: [PATCH 1/4] docs: Update CLAUDE.md to reflect current implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove outdated payment provider and testing information - Focus on current customer data management features - Document customer info extractor pattern and usage - Include clear configuration examples - Remove references to unimplemented features 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 232 +++++++++++++++++++++++++++--------------------------- 1 file changed, 118 insertions(+), 114 deletions(-) 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 From f17b4c064e181b998f42ddf1bc25c34190a1479e Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Mon, 15 Sep 2025 23:14:25 +0200 Subject: [PATCH 2/4] chore: Remove unused billing-related collections, types, and utility modules - Drop `customers` collection and associated types (`types/index.ts`, `payload.ts`) - Remove generated `payload-types.ts` file - Clean up unused exports and dependencies across modules - Streamline codebase by eliminating redundant billing logic --- dev/payload-types.ts | 656 ----------------------------------- eslint.config.js | 1 + src/collections/customers.ts | 149 -------- src/collections/invoices.ts | 37 +- src/collections/payments.ts | 26 +- src/index.ts | 95 ----- src/plugin/config.ts | 76 ++++ src/plugin/index.ts | 70 ++++ src/plugin/types.ts | 168 +++++++++ src/types/index.ts | 260 -------------- src/types/payload.ts | 178 ---------- 11 files changed, 338 insertions(+), 1378 deletions(-) delete mode 100644 dev/payload-types.ts delete mode 100644 src/collections/customers.ts create mode 100644 src/plugin/config.ts create mode 100644 src/plugin/index.ts create mode 100644 src/plugin/types.ts delete mode 100644 src/types/index.ts delete mode 100644 src/types/payload.ts diff --git a/dev/payload-types.ts b/dev/payload-types.ts deleted file mode 100644 index 3c141b2..0000000 --- a/dev/payload-types.ts +++ /dev/null @@ -1,656 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * This file was automatically generated by Payload. - * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, - * and re-run `payload generate:types` to regenerate this file. - */ - -/** - * Supported timezones in IANA format. - * - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "supportedTimezones". - */ -export type SupportedTimezones = - | 'Pacific/Midway' - | 'Pacific/Niue' - | 'Pacific/Honolulu' - | 'Pacific/Rarotonga' - | 'America/Anchorage' - | 'Pacific/Gambier' - | 'America/Los_Angeles' - | 'America/Tijuana' - | 'America/Denver' - | 'America/Phoenix' - | 'America/Chicago' - | 'America/Guatemala' - | 'America/New_York' - | 'America/Bogota' - | 'America/Caracas' - | 'America/Santiago' - | 'America/Buenos_Aires' - | 'America/Sao_Paulo' - | 'Atlantic/South_Georgia' - | 'Atlantic/Azores' - | 'Atlantic/Cape_Verde' - | 'Europe/London' - | 'Europe/Berlin' - | 'Africa/Lagos' - | 'Europe/Athens' - | 'Africa/Cairo' - | 'Europe/Moscow' - | 'Asia/Riyadh' - | 'Asia/Dubai' - | 'Asia/Baku' - | 'Asia/Karachi' - | 'Asia/Tashkent' - | 'Asia/Calcutta' - | 'Asia/Dhaka' - | 'Asia/Almaty' - | 'Asia/Jakarta' - | 'Asia/Bangkok' - | 'Asia/Shanghai' - | 'Asia/Singapore' - | 'Asia/Tokyo' - | 'Asia/Seoul' - | 'Australia/Brisbane' - | 'Australia/Sydney' - | 'Pacific/Guam' - | 'Pacific/Noumea' - | 'Pacific/Auckland' - | 'Pacific/Fiji'; - -export interface Config { - auth: { - users: UserAuthOperations; - }; - blocks: {}; - collections: { - posts: Post; - media: Media; - payments: Payment; - customers: Customer; - invoices: Invoice; - refunds: Refund; - users: User; - 'payload-locked-documents': PayloadLockedDocument; - 'payload-preferences': PayloadPreference; - 'payload-migrations': PayloadMigration; - }; - collectionsJoins: {}; - collectionsSelect: { - posts: PostsSelect | PostsSelect; - media: MediaSelect | MediaSelect; - payments: PaymentsSelect | PaymentsSelect; - customers: CustomersSelect | CustomersSelect; - invoices: InvoicesSelect | InvoicesSelect; - refunds: RefundsSelect | RefundsSelect; - users: UsersSelect | UsersSelect; - 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; - 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; - 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; - }; - db: { - defaultIDType: number; - }; - globals: {}; - globalsSelect: {}; - locale: null; - user: User & { - collection: 'users'; - }; - jobs: { - tasks: unknown; - workflows: unknown; - }; -} -export interface UserAuthOperations { - forgotPassword: { - email: string; - password: string; - }; - login: { - email: string; - password: string; - }; - registerFirstUser: { - email: string; - password: string; - }; - unlock: { - email: string; - password: string; - }; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts". - */ -export interface Post { - id: number; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "media". - */ -export interface Media { - id: number; - updatedAt: string; - createdAt: string; - url?: string | null; - thumbnailURL?: string | null; - filename?: string | null; - mimeType?: string | null; - filesize?: number | null; - width?: number | null; - height?: number | null; - focalX?: number | null; - focalY?: number | null; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payments". - */ -export interface Payment { - id: number; - provider: 'stripe' | 'mollie' | 'test'; - /** - * The payment ID from the payment provider - */ - providerId: string; - status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded'; - /** - * Amount in cents (e.g., 2000 = $20.00) - */ - amount: number; - /** - * ISO 4217 currency code (e.g., USD, EUR) - */ - currency: string; - /** - * Payment description - */ - description?: string | null; - customer?: (number | null) | Customer; - invoice?: (number | 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; -} -/** - * 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". - */ -export interface Invoice { - id: number; - /** - * Invoice number (e.g., INV-001) - */ - number: string; - customer: number | Customer; - status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'; - /** - * ISO 4217 currency code (e.g., USD, EUR) - */ - currency: string; - items: { - description: string; - quantity: number; - /** - * Amount in cents - */ - unitAmount: number; - /** - * Calculated: quantity × unitAmount - */ - totalAmount?: number | null; - id?: string | null; - }[]; - /** - * Sum of all line items - */ - subtotal?: number | null; - /** - * Tax amount in cents - */ - taxAmount?: number | null; - /** - * Total amount (subtotal + tax) - */ - amount?: number | null; - dueDate?: string | null; - paidAt?: string | null; - payment?: (number | null) | Payment; - /** - * Internal notes - */ - notes?: string | null; - /** - * Additional invoice metadata - */ - metadata?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "refunds". - */ -export interface Refund { - id: 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; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users". - */ -export interface User { - id: number; - updatedAt: string; - createdAt: string; - email: string; - resetPasswordToken?: string | null; - resetPasswordExpiration?: string | null; - salt?: string | null; - hash?: string | null; - loginAttempts?: number | null; - lockUntil?: string | null; - password?: string | null; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-locked-documents". - */ -export interface PayloadLockedDocument { - id: number; - document?: - | ({ - relationTo: 'posts'; - value: number | Post; - } | null) - | ({ - relationTo: 'media'; - value: number | Media; - } | null) - | ({ - relationTo: 'payments'; - value: number | Payment; - } | null) - | ({ - relationTo: 'customers'; - value: number | Customer; - } | null) - | ({ - relationTo: 'invoices'; - value: number | Invoice; - } | null) - | ({ - relationTo: 'refunds'; - value: number | Refund; - } | null) - | ({ - relationTo: 'users'; - value: number | User; - } | null); - globalSlug?: string | null; - user: { - relationTo: 'users'; - value: number | User; - }; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-preferences". - */ -export interface PayloadPreference { - id: number; - user: { - relationTo: 'users'; - value: number | User; - }; - key?: string | null; - value?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-migrations". - */ -export interface PayloadMigration { - id: number; - name?: string | null; - batch?: number | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts_select". - */ -export interface PostsSelect { - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "media_select". - */ -export interface MediaSelect { - updatedAt?: T; - createdAt?: T; - url?: T; - thumbnailURL?: T; - filename?: T; - mimeType?: T; - filesize?: T; - width?: T; - height?: T; - focalX?: T; - focalY?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payments_select". - */ -export interface PaymentsSelect { - provider?: T; - providerId?: T; - status?: T; - amount?: T; - currency?: T; - description?: T; - customer?: T; - invoice?: T; - metadata?: T; - providerData?: T; - refunds?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "customers_select". - */ -export interface CustomersSelect { - email?: T; - name?: T; - phone?: T; - address?: - | T - | { - line1?: T; - line2?: T; - city?: T; - state?: T; - postal_code?: T; - country?: T; - }; - providerIds?: T; - metadata?: T; - payments?: T; - invoices?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "invoices_select". - */ -export interface InvoicesSelect { - number?: T; - customer?: T; - status?: T; - currency?: T; - items?: - | T - | { - description?: T; - quantity?: T; - unitAmount?: T; - totalAmount?: T; - id?: T; - }; - subtotal?: T; - taxAmount?: T; - amount?: T; - dueDate?: T; - paidAt?: T; - payment?: T; - notes?: T; - metadata?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "refunds_select". - */ -export interface RefundsSelect { - providerId?: T; - payment?: T; - status?: T; - amount?: T; - currency?: T; - reason?: T; - description?: T; - metadata?: T; - providerData?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users_select". - */ -export interface UsersSelect { - updatedAt?: T; - createdAt?: T; - email?: T; - resetPasswordToken?: T; - resetPasswordExpiration?: T; - salt?: T; - hash?: T; - loginAttempts?: T; - lockUntil?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-locked-documents_select". - */ -export interface PayloadLockedDocumentsSelect { - document?: T; - globalSlug?: T; - user?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-preferences_select". - */ -export interface PayloadPreferencesSelect { - user?: T; - key?: T; - value?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-migrations_select". - */ -export interface PayloadMigrationsSelect { - name?: T; - batch?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "auth". - */ -export interface Auth { - [k: string]: unknown; -} - - -declare module 'payload' { - export interface GeneratedTypes extends Config {} -} \ No newline at end of file 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/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/invoices.ts b/src/collections/invoices.ts index fdaf804..647c09d 100644 --- a/src/collections/invoices.ts +++ b/src/collections/invoices.ts @@ -1,15 +1,12 @@ -import type { CollectionConfig } from 'payload' - -import type { +import { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionBeforeValidateHook, - InvoiceData, - InvoiceDocument, - InvoiceItemData -} from '../types/payload' -import type { CustomerInfoExtractor } from '../types' + CollectionConfig, +} from 'payload' +import { CustomerInfoExtractor } from '@/plugin/config' +import { Invoice } from '@/plugin/types' export function createInvoicesCollection( slug: string = 'invoices', @@ -281,7 +278,7 @@ export function createInvoicesCollection( name: 'paidAt', type: 'date', admin: { - condition: (data: InvoiceData) => data.status === 'paid', + condition: (data) => data.status === 'paid', readOnly: true, }, }, @@ -289,7 +286,7 @@ export function createInvoicesCollection( name: 'payment', type: 'relationship', admin: { - condition: (data: InvoiceData) => data.status === 'paid', + condition: (data) => data.status === 'paid', position: 'sidebar', }, relationTo: 'payments', @@ -311,14 +308,14 @@ export function createInvoicesCollection( ], 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) { // Check if customer changed or this is a new invoice @@ -329,7 +326,7 @@ export function createInvoicesCollection( try { // Fetch the customer data const customer = await req.payload.findByID({ - collection: customerCollectionSlug, + collection: customerCollectionSlug as any, id: data.customer, }) @@ -383,9 +380,9 @@ export function createInvoicesCollection( data.paidAt = new Date().toISOString() } }, - ], + ] satisfies CollectionBeforeChangeHook[], beforeValidate: [ - ({ data }: CollectionBeforeValidateHook) => { + ({ data }) => { if (!data) return // If using extractor, customer relationship is required @@ -406,14 +403,14 @@ export function createInvoicesCollection( 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 +418,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..4d9d8bf 100644 --- a/src/collections/payments.ts +++ b/src/collections/payments.ts @@ -1,12 +1,5 @@ -import type { CollectionConfig } from 'payload' - -import type { - AccessArgs, - CollectionAfterChangeHook, - CollectionBeforeChangeHook, - PaymentData, - PaymentDocument -} from '../types/payload' +import { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from 'payload' +import { Payment } from '@/plugin/types' export function createPaymentsCollection(slug: string = 'payments'): CollectionConfig { return { @@ -131,21 +124,14 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC }, ], hooks: { - afterChange: [ - ({ doc, operation, req }: CollectionAfterChangeHook) => { - if (operation === 'create') { - req.payload.logger.info(`Payment created: ${doc.id} (${doc.provider})`) - } - }, - ], beforeChange: [ - ({ data, operation }: CollectionBeforeChangeHook) => { + ({ data, operation }) => { if (operation === 'create') { // Validate amount format if (data.amount && !Number.isInteger(data.amount)) { throw new Error('Amount must be an integer (in cents)') } - + // Validate currency format if (data.currency) { data.currency = data.currency.toUpperCase() @@ -155,8 +141,8 @@ export function createPaymentsCollection(slug: string = 'payments'): CollectionC } } }, - ], + ] satisfies CollectionBeforeChangeHook[], }, timestamps: true, } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 303a57d..509a567 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,98 +1,3 @@ -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 diff --git a/src/plugin/config.ts b/src/plugin/config.ts new file mode 100644 index 0000000..ace743d --- /dev/null +++ b/src/plugin/config.ts @@ -0,0 +1,76 @@ + +export const defaults = { + paymentsCollection: 'payments' +} + +// 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 + } +} + +// 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..e644aec --- /dev/null +++ b/src/plugin/index.ts @@ -0,0 +1,70 @@ +import type { Config } from 'payload' +import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '@/collections' +import { BillingPluginConfig } from '@/plugin/config' + +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'), + 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 diff --git a/src/plugin/types.ts b/src/plugin/types.ts new file mode 100644 index 0000000..d7ae2ee --- /dev/null +++ b/src/plugin/types.ts @@ -0,0 +1,168 @@ +import { Customer, Refund } from '../../dev/payload-types' + +export type Id = string | number + +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; + customer?: (Id | null) | Customer; + 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; +} + +export interface Invoice { + id: Id; + /** + * Invoice number (e.g., INV-001) + */ + number: string; + /** + * Link to customer record (optional) + */ + customer?: (Id | null) | Customer; + /** + * 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/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 -} From 0308e30ebda710415746ff556900bb125fb6e8f4 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Tue, 16 Sep 2025 00:06:18 +0200 Subject: [PATCH 3/4] refactor: Replace hardcoded billing data seeding with plugin-configurable collection overrides - Remove `seedBillingData` function for sample data creation - Update refunds, invoices, and payments collections to use pluginConfig for dynamic overrides - Introduce utility functions like `extractSlug` for customizable collection slugs - Streamline customer relation and data extractor logic across collections --- dev/payload.config.ts | 38 +-- dev/seed.ts | 120 -------- src/collections/invoices.ts | 591 ++++++++++++++++++------------------ src/collections/payments.ts | 227 +++++++------- src/collections/refunds.ts | 24 +- src/index.ts | 1 - src/plugin/config.ts | 16 +- src/plugin/index.ts | 14 +- src/plugin/types.ts | 59 +++- src/plugin/utils.ts | 5 + 10 files changed, 515 insertions(+), 580 deletions(-) create mode 100644 src/plugin/utils.ts diff --git a/dev/payload.config.ts b/dev/payload.config.ts index d18ca44..b8a8c73 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -2,12 +2,12 @@ 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' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -56,27 +56,29 @@ const buildConfigWithSQLite = () => { }, 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: + // // Customer relationship configuration + // customerRelationSlug: 'customers', // Use 'customers' collection for relationship + // // customerRelationSlug: false, // Or set to false to disable customer relationship + // // customerRelationSlug: 'clients', // Or use a custom collection slug + // + // // Provide an extractor for your 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, - // } + // 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, // }) }), ], diff --git a/dev/seed.ts b/dev/seed.ts index 97e1e34..54ba04f 100644 --- a/dev/seed.ts +++ b/dev/seed.ts @@ -26,124 +26,4 @@ export const seed = async (payload: 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) - } } diff --git a/src/collections/invoices.ts b/src/collections/invoices.ts index 647c09d..d45c888 100644 --- a/src/collections/invoices.ts +++ b/src/collections/invoices.ts @@ -3,18 +3,300 @@ import { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionBeforeValidateHook, - CollectionConfig, + CollectionConfig, Field, } from 'payload' -import { CustomerInfoExtractor } from '@/plugin/config' +import { BillingPluginConfig, CustomerInfoExtractor, defaults } from '@/plugin/config' import { Invoice } from '@/plugin/types' +import { extractSlug } from '@/plugin/utils' -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: pluginConfig.customerRelationSlug as never, + 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: 'payments', + }, + { + 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, @@ -26,286 +308,7 @@ 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) => data.status === 'paid', - readOnly: true, - }, - }, - { - name: 'payment', - type: 'relationship', - admin: { - condition: (data) => 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 }) => { @@ -317,7 +320,7 @@ export function createInvoicesCollection( beforeChange: [ 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) @@ -326,8 +329,8 @@ export function createInvoicesCollection( try { // Fetch the customer data const customer = await req.payload.findByID({ - collection: customerCollectionSlug as any, - id: data.customer, + collection: customerRelationSlug as never, + id: data.customer as never, }) // Extract customer info using the provided callback @@ -386,18 +389,18 @@ export function createInvoicesCollection( 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') } diff --git a/src/collections/payments.ts b/src/collections/payments.ts index 4d9d8bf..14ad86a 100644 --- a/src/collections/payments.ts +++ b/src/collections/payments.ts @@ -1,10 +1,117 @@ -import { AccessArgs, CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from 'payload' -import { Payment } from '@/plugin/types' +import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload' +import type { Payment } from '@/plugin/types' +import type { BillingPluginConfig} from '@/plugin/config'; +import { defaults } from '@/plugin/config' +import { extractSlug } from '@/plugin/utils' -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', + 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: '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', + }, + ] + 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, @@ -14,115 +121,9 @@ 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: { beforeChange: [ ({ data, operation }) => { diff --git a/src/collections/refunds.ts b/src/collections/refunds.ts index 7aba74a..6b383cf 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 type { - AccessArgs, - CollectionAfterChangeHook, - CollectionBeforeChangeHook, - RefundData, - RefundDocument -} from '../types/payload' - -export function createRefundsCollection(slug: string = 'refunds'): CollectionConfig { +export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { + const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {} + // TODO: finish collection overrides return { - slug, + slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection), access: { create: ({ req: { user } }: AccessArgs) => !!user, delete: ({ req: { user } }: AccessArgs) => !!user, @@ -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}`) @@ -129,7 +125,7 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id, collection: 'payments', 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 509a567..139597f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ -export * from './types' diff --git a/src/plugin/config.ts b/src/plugin/config.ts index ace743d..9e75e1d 100644 --- a/src/plugin/config.ts +++ b/src/plugin/config.ts @@ -1,6 +1,11 @@ +import { CollectionConfig } from 'payload' +import { FieldsOverride } from '@/plugin/utils' export const defaults = { - paymentsCollection: 'payments' + paymentsCollection: 'payments', + invoicesCollection: 'invoices', + refundsCollection: 'refunds', + customerRelationSlug: 'customer' } // Provider configurations @@ -51,13 +56,12 @@ export interface BillingPluginConfig { dashboard?: boolean } collections?: { - customerRelation?: boolean | string // false to disable, string for custom collection slug - customers?: string - invoices?: string - payments?: string - refunds?: string + 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?: { mollie?: MollieConfig diff --git a/src/plugin/index.ts b/src/plugin/index.ts index e644aec..ebf0aa4 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,6 +1,6 @@ import type { Config } from 'payload' import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '@/collections' -import { BillingPluginConfig } from '@/plugin/config' +import type { BillingPluginConfig } from '@/plugin/config' export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => { if (pluginConfig.disabled) { @@ -12,16 +12,10 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config config.collections = [] } - const customerSlug = pluginConfig.collections?.customers || 'customers' - config.collections.push( - createPaymentsCollection(pluginConfig.collections?.payments || 'payments'), - createInvoicesCollection( - pluginConfig.collections?.invoices || 'invoices', - pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined, - pluginConfig.customerInfoExtractor - ), - createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'), + createPaymentsCollection(pluginConfig), + createInvoicesCollection(pluginConfig), + createRefundsCollection(pluginConfig), ) // Initialize endpoints diff --git a/src/plugin/types.ts b/src/plugin/types.ts index d7ae2ee..a2f3a2d 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -1,7 +1,58 @@ -import { Customer, Refund } from '../../dev/payload-types' export type Id = string | number +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; +} + export interface Payment { id: Id; provider: 'stripe' | 'mollie' | 'test'; @@ -22,7 +73,6 @@ export interface Payment { * Payment description */ description?: string | null; - customer?: (Id | null) | Customer; invoice?: (Id | null) | Invoice; /** * Additional metadata for the payment @@ -53,7 +103,7 @@ export interface Payment { createdAt: string; } -export interface Invoice { +export interface Invoice { id: Id; /** * Invoice number (e.g., INV-001) @@ -62,7 +112,7 @@ export interface Invoice { /** * Link to customer record (optional) */ - customer?: (Id | null) | Customer; + customer?: (Id | null) | TCustomer; /** * Customer billing information (auto-populated from customer relationship) */ @@ -166,3 +216,4 @@ export interface Invoice { updatedAt: string; createdAt: string; } + diff --git a/src/plugin/utils.ts b/src/plugin/utils.ts new file mode 100644 index 0000000..543c708 --- /dev/null +++ b/src/plugin/utils.ts @@ -0,0 +1,5 @@ +import type { CollectionConfig, Field } from 'payload' + +export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] + +export const extractSlug = (arg: string | Partial) => typeof arg === 'string' ? arg : arg.slug! From e3a58fe6bcfcd97c65656c06c92e9d47a64b3655 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Tue, 16 Sep 2025 22:10:47 +0200 Subject: [PATCH 4/4] feat: Add Mollie payment provider support - Introduce `mollieProvider` for handling Mollie payments - Add configurable payment hooks for initialization and processing - Implement `initPayment` logic to create Mollie payments and update metadata - Include types for Mollie integration in payments and refunds - Update `package.json` to include `@mollie/api-client` dependency - Refactor existing payment-related types into modular files for better maintainability --- dev/app/my-route/route.ts | 2 + dev/payload-types.ts | 627 +++++++++++++++++++++ dev/payload.config.ts | 33 +- dev/seed.ts | 8 +- package.json | 3 +- pnpm-lock.yaml | 12 +- src/collections/hooks.ts | 11 + src/collections/invoices.ts | 9 +- src/collections/payments.ts | 14 +- src/collections/refunds.ts | 10 +- src/index.ts | 4 +- src/plugin/config.ts | 7 +- src/plugin/index.ts | 71 +-- src/plugin/singleton.ts | 11 + src/plugin/types/id.ts | 1 + src/plugin/types/index.ts | 5 + src/plugin/{types.ts => types/invoices.ts} | 106 +--- src/plugin/types/payments.ts | 53 ++ src/plugin/types/refunds.ts | 53 ++ src/plugin/utils.ts | 5 +- src/providers/index.ts | 2 + src/providers/mollie.ts | 40 ++ src/providers/types.ts | 10 + 23 files changed, 890 insertions(+), 207 deletions(-) create mode 100644 dev/payload-types.ts create mode 100644 src/collections/hooks.ts create mode 100644 src/plugin/singleton.ts create mode 100644 src/plugin/types/id.ts create mode 100644 src/plugin/types/index.ts rename src/plugin/{types.ts => types/invoices.ts} (52%) create mode 100644 src/plugin/types/payments.ts create mode 100644 src/plugin/types/refunds.ts create mode 100644 src/providers/index.ts create mode 100644 src/providers/mollie.ts create mode 100644 src/providers/types.ts 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 new file mode 100644 index 0000000..5f1fb92 --- /dev/null +++ b/dev/payload-types.ts @@ -0,0 +1,627 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + posts: Post; + media: Media; + payments: Payment; + invoices: Invoice; + refunds: Refund; + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + posts: PostsSelect | PostsSelect; + media: MediaSelect | MediaSelect; + payments: PaymentsSelect | PaymentsSelect; + invoices: InvoicesSelect | InvoicesSelect; + refunds: RefundsSelect | RefundsSelect; + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: number; + }; + globals: {}; + globalsSelect: {}; + locale: null; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: number; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media". + */ +export interface Media { + id: number; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payments". + */ +export interface Payment { + id: number; + provider: 'stripe' | 'mollie' | 'test'; + /** + * The payment ID from the payment provider + */ + providerId: string; + status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded'; + /** + * Amount in cents (e.g., 2000 = $20.00) + */ + amount: number; + /** + * ISO 4217 currency code (e.g., USD, EUR) + */ + currency: string; + /** + * Payment description + */ + description?: string | null; + invoice?: (number | 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; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "invoices". + */ +export interface Invoice { + id: number; + /** + * Invoice number (e.g., INV-001) + */ + number: string; + /** + * 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) + */ + currency: string; + items: { + description: string; + quantity: number; + /** + * Amount in cents + */ + unitAmount: number; + /** + * Calculated: quantity × unitAmount + */ + totalAmount?: number | null; + id?: string | null; + }[]; + /** + * Sum of all line items + */ + subtotal?: number | null; + /** + * Tax amount in cents + */ + taxAmount?: number | null; + /** + * Total amount (subtotal + tax) + */ + amount?: number | null; + dueDate?: string | null; + paidAt?: string | null; + payment?: (number | null) | Payment; + /** + * Internal notes + */ + notes?: string | null; + /** + * Additional invoice metadata + */ + metadata?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "refunds". + */ +export interface Refund { + id: 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; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: number; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: number; + document?: + | ({ + relationTo: 'posts'; + value: number | Post; + } | null) + | ({ + relationTo: 'media'; + value: number | Media; + } | null) + | ({ + relationTo: 'payments'; + value: number | Payment; + } | null) + | ({ + relationTo: 'invoices'; + value: number | Invoice; + } | null) + | ({ + relationTo: 'refunds'; + value: number | Refund; + } | null) + | ({ + relationTo: 'users'; + value: number | User; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: number | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: number; + user: { + relationTo: 'users'; + value: number | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: number; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts_select". + */ +export interface PostsSelect { + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media_select". + */ +export interface MediaSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payments_select". + */ +export interface PaymentsSelect { + provider?: T; + providerId?: T; + status?: T; + amount?: T; + currency?: T; + description?: T; + invoice?: T; + metadata?: T; + providerData?: T; + refunds?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "invoices_select". + */ +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; + postalCode?: T; + country?: T; + }; + status?: T; + currency?: T; + items?: + | T + | { + description?: T; + quantity?: T; + unitAmount?: T; + totalAmount?: T; + id?: T; + }; + subtotal?: T; + taxAmount?: T; + amount?: T; + dueDate?: T; + paidAt?: T; + payment?: T; + notes?: T; + metadata?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "refunds_select". + */ +export interface RefundsSelect { + providerId?: T; + payment?: T; + status?: T; + amount?: T; + currency?: T; + reason?: T; + description?: T; + metadata?: T; + providerData?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/dev/payload.config.ts b/dev/payload.config.ts index b8a8c73..f7db56d 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -8,6 +8,7 @@ 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,38 +49,16 @@ const buildConfigWithSQLite = () => { }, plugins: [ billingPlugin({ - providers: { - test: { - enabled: true, - autoComplete: true, - } - }, + providers: [ + mollieProvider({ + apiKey: process.env.MOLLIE_KEY! + }) + ], collections: { payments: 'payments', invoices: 'invoices', refunds: 'refunds', }, - // // Customer relationship configuration - // customerRelationSlug: 'customers', // Use 'customers' collection for relationship - // // customerRelationSlug: false, // Or set to false to disable customer relationship - // // customerRelationSlug: 'clients', // Or use a custom collection slug - // - // // Provide an extractor for your customer collection structure: - // customerInfoExtractor: (customer) => ({ - // 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, - // }) }), ], secret: process.env.PAYLOAD_SECRET || 'test-secret_key', diff --git a/dev/seed.ts b/dev/seed.ts index 54ba04f..935948a 100644 --- a/dev/seed.ts +++ b/dev/seed.ts @@ -21,9 +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...') -} +// async function seedBillingData(payload: Payload): Promise { +// payload.logger.info('Seeding billing sample data...') +// } 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/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 d45c888..fd689bc 100644 --- a/src/collections/invoices.ts +++ b/src/collections/invoices.ts @@ -5,9 +5,10 @@ import { CollectionBeforeValidateHook, CollectionConfig, Field, } from 'payload' -import { BillingPluginConfig, CustomerInfoExtractor, defaults } from '@/plugin/config' -import { Invoice } from '@/plugin/types' +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(pluginConfig: BillingPluginConfig): CollectionConfig { const {customerRelationSlug, customerInfoExtractor} = pluginConfig @@ -31,7 +32,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col position: 'sidebar' as const, description: 'Link to customer record (optional)', }, - relationTo: pluginConfig.customerRelationSlug as never, + relationTo: extractSlug(customerRelationSlug), required: false, }] : []), // Basic customer info fields (embedded) @@ -275,7 +276,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col condition: (data) => data.status === 'paid', position: 'sidebar', }, - relationTo: 'payments', + relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), }, { name: 'notes', diff --git a/src/collections/payments.ts b/src/collections/payments.ts index 14ad86a..b5d2665 100644 --- a/src/collections/payments.ts +++ b/src/collections/payments.ts @@ -1,8 +1,9 @@ -import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload' -import type { Payment } from '@/plugin/types' +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' export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {} @@ -27,7 +28,6 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col description: 'The payment ID from the payment provider', }, label: 'Provider Payment ID', - required: true, unique: true, }, { @@ -78,7 +78,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col admin: { position: 'sidebar', }, - relationTo: 'invoices', + relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection) as CollectionSlug, }, { name: 'metadata', @@ -103,7 +103,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col readOnly: true, }, hasMany: true, - relationTo: 'refunds', + relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection) as CollectionSlug, }, ] if (overrides?.fields) { @@ -126,7 +126,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col fields, hooks: { beforeChange: [ - ({ data, operation }) => { + async ({ data, operation, req }) => { if (operation === 'create') { // Validate amount format if (data.amount && !Number.isInteger(data.amount)) { @@ -140,6 +140,8 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col throw new Error('Currency must be a 3-letter ISO code') } } + + await initProviderPayment(req.payload, data) } }, ] satisfies CollectionBeforeChangeHook[], diff --git a/src/collections/refunds.ts b/src/collections/refunds.ts index 6b383cf..aa484e9 100644 --- a/src/collections/refunds.ts +++ b/src/collections/refunds.ts @@ -1,9 +1,9 @@ import type { AccessArgs, CollectionConfig } from 'payload' import { BillingPluginConfig, defaults } from '@/plugin/config' import { extractSlug } from '@/plugin/utils' +import { Payment } from '@/plugin/types' export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { - const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {} // TODO: finish collection overrides return { slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection), @@ -35,7 +35,7 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll admin: { position: 'sidebar', }, - relationTo: 'payments', + relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), required: true, }, { @@ -117,13 +117,13 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll 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], }, diff --git a/src/index.ts b/src/index.ts index 139597f..882fe58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,4 @@ - +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 index 9e75e1d..d2385d8 100644 --- a/src/plugin/config.ts +++ b/src/plugin/config.ts @@ -1,5 +1,6 @@ import { CollectionConfig } from 'payload' import { FieldsOverride } from '@/plugin/utils' +import { PaymentProvider } from '@/plugin/types' export const defaults = { paymentsCollection: 'payments', @@ -63,11 +64,7 @@ export interface BillingPluginConfig { customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship customerRelationSlug?: string // Customer collection slug for relationship disabled?: boolean - providers?: { - mollie?: MollieConfig - stripe?: StripeConfig - test?: TestProviderConfig - } + providers?: PaymentProvider[] webhooks?: { basePath?: string cors?: boolean diff --git a/src/plugin/index.ts b/src/plugin/index.ts index ebf0aa4..3bac540 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,62 +1,49 @@ -import type { Config } from 'payload' 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 } - // Initialize collections - if (!config.collections) { - config.collections = [] - } - - config.collections.push( + config.collections = [ + ...(config.collections || []), createPaymentsCollection(pluginConfig), createInvoicesCollection(pluginConfig), createRefundsCollection(pluginConfig), - ) + ] - // 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) } - + 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 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.ts b/src/plugin/types/invoices.ts similarity index 52% rename from src/plugin/types.ts rename to src/plugin/types/invoices.ts index a2f3a2d..b42d342 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types/invoices.ts @@ -1,107 +1,6 @@ +import { Payment } from '@/plugin/types/payments' -export type Id = string | number - -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; -} - -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; -} +import { Id } from '@/plugin/types/id' export interface Invoice { id: Id; @@ -216,4 +115,3 @@ export interface Invoice { 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 index 543c708..9ebdd01 100644 --- a/src/plugin/utils.ts +++ b/src/plugin/utils.ts @@ -1,5 +1,6 @@ -import type { CollectionConfig, Field } from 'payload' +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! +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 +}