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] 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!