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 +}