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
This commit is contained in:
2025-09-16 22:10:47 +02:00
parent 0308e30ebd
commit e3a58fe6bc
23 changed files with 890 additions and 207 deletions

View File

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

627
dev/payload-types.ts Normal file
View File

@@ -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<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
payments: PaymentsSelect<false> | PaymentsSelect<true>;
invoices: InvoicesSelect<false> | InvoicesSelect<true>;
refunds: RefundsSelect<false> | RefundsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
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<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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 {}
}

View File

@@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter' import { testEmailAdapter } from './helpers/testEmailAdapter'
import { seed } from './seed' import { seed } from './seed'
import billingPlugin from '../src/plugin' import billingPlugin from '../src/plugin'
import { mollieProvider } from '../src/providers'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -48,38 +49,16 @@ const buildConfigWithSQLite = () => {
}, },
plugins: [ plugins: [
billingPlugin({ billingPlugin({
providers: { providers: [
test: { mollieProvider({
enabled: true, apiKey: process.env.MOLLIE_KEY!
autoComplete: true, })
} ],
},
collections: { collections: {
payments: 'payments', payments: 'payments',
invoices: 'invoices', invoices: 'invoices',
refunds: 'refunds', 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', secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

View File

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

View File

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

12
pnpm-lock.yaml generated
View File

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

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

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

View File

@@ -5,9 +5,10 @@ import {
CollectionBeforeValidateHook, CollectionBeforeValidateHook,
CollectionConfig, Field, CollectionConfig, Field,
} from 'payload' } from 'payload'
import { BillingPluginConfig, CustomerInfoExtractor, defaults } from '@/plugin/config' import type { BillingPluginConfig} from '@/plugin/config';
import { Invoice } from '@/plugin/types' import { defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils' import { extractSlug } from '@/plugin/utils'
import type { Invoice } from '@/plugin/types/invoices'
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig { export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const {customerRelationSlug, customerInfoExtractor} = pluginConfig const {customerRelationSlug, customerInfoExtractor} = pluginConfig
@@ -31,7 +32,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
position: 'sidebar' as const, position: 'sidebar' as const,
description: 'Link to customer record (optional)', description: 'Link to customer record (optional)',
}, },
relationTo: pluginConfig.customerRelationSlug as never, relationTo: extractSlug(customerRelationSlug),
required: false, required: false,
}] : []), }] : []),
// Basic customer info fields (embedded) // Basic customer info fields (embedded)
@@ -275,7 +276,7 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
condition: (data) => data.status === 'paid', condition: (data) => data.status === 'paid',
position: 'sidebar', position: 'sidebar',
}, },
relationTo: 'payments', relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
}, },
{ {
name: 'notes', name: 'notes',

View File

@@ -1,8 +1,9 @@
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload' import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, CollectionSlug, Field } from 'payload'
import type { Payment } from '@/plugin/types'
import type { BillingPluginConfig} from '@/plugin/config'; import type { BillingPluginConfig} from '@/plugin/config';
import { defaults } from '@/plugin/config' import { defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils' import { extractSlug } from '@/plugin/utils'
import { Payment } from '@/plugin/types/payments'
import { initProviderPayment } from '@/collections/hooks'
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {} 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', description: 'The payment ID from the payment provider',
}, },
label: 'Provider Payment ID', label: 'Provider Payment ID',
required: true,
unique: true, unique: true,
}, },
{ {
@@ -78,7 +78,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
admin: { admin: {
position: 'sidebar', position: 'sidebar',
}, },
relationTo: 'invoices', relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection) as CollectionSlug,
}, },
{ {
name: 'metadata', name: 'metadata',
@@ -103,7 +103,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
readOnly: true, readOnly: true,
}, },
hasMany: true, hasMany: true,
relationTo: 'refunds', relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection) as CollectionSlug,
}, },
] ]
if (overrides?.fields) { if (overrides?.fields) {
@@ -126,7 +126,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
fields, fields,
hooks: { hooks: {
beforeChange: [ beforeChange: [
({ data, operation }) => { async ({ data, operation, req }) => {
if (operation === 'create') { if (operation === 'create') {
// Validate amount format // Validate amount format
if (data.amount && !Number.isInteger(data.amount)) { if (data.amount && !Number.isInteger(data.amount)) {
@@ -140,6 +140,8 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
throw new Error('Currency must be a 3-letter ISO code') throw new Error('Currency must be a 3-letter ISO code')
} }
} }
await initProviderPayment(req.payload, data)
} }
}, },
] satisfies CollectionBeforeChangeHook<Payment>[], ] satisfies CollectionBeforeChangeHook<Payment>[],

View File

@@ -1,9 +1,9 @@
import type { AccessArgs, CollectionConfig } from 'payload' import type { AccessArgs, CollectionConfig } from 'payload'
import { BillingPluginConfig, defaults } from '@/plugin/config' import { BillingPluginConfig, defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils' import { extractSlug } from '@/plugin/utils'
import { Payment } from '@/plugin/types'
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const overrides = typeof pluginConfig.collections?.invoices === 'object' ? pluginConfig.collections?.invoices : {}
// TODO: finish collection overrides // TODO: finish collection overrides
return { return {
slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection), slug: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection),
@@ -35,7 +35,7 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
admin: { admin: {
position: 'sidebar', position: 'sidebar',
}, },
relationTo: 'payments', relationTo: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
required: true, required: true,
}, },
{ {
@@ -117,13 +117,13 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
try { try {
const payment = await req.payload.findByID({ const payment = await req.payload.findByID({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id, id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments', collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
}) }) as Payment
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : [] const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
await req.payload.update({ await req.payload.update({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id, id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments', collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
data: { data: {
refunds: [...refundIds, doc.id], refunds: [...refundIds, doc.id],
}, },

View File

@@ -1,2 +1,4 @@
export { billingPlugin } from './plugin'
export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config'
export type { Invoice, Payment, Refund } from './plugin/types'

View File

@@ -1,5 +1,6 @@
import { CollectionConfig } from 'payload' import { CollectionConfig } from 'payload'
import { FieldsOverride } from '@/plugin/utils' import { FieldsOverride } from '@/plugin/utils'
import { PaymentProvider } from '@/plugin/types'
export const defaults = { export const defaults = {
paymentsCollection: 'payments', paymentsCollection: 'payments',
@@ -63,11 +64,7 @@ export interface BillingPluginConfig {
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
customerRelationSlug?: string // Customer collection slug for relationship customerRelationSlug?: string // Customer collection slug for relationship
disabled?: boolean disabled?: boolean
providers?: { providers?: PaymentProvider[]
mollie?: MollieConfig
stripe?: StripeConfig
test?: TestProviderConfig
}
webhooks?: { webhooks?: {
basePath?: string basePath?: string
cors?: boolean cors?: boolean

View File

@@ -1,62 +1,49 @@
import type { Config } from 'payload'
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '@/collections' import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '@/collections'
import type { BillingPluginConfig } from '@/plugin/config' 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 => { export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) { if (pluginConfig.disabled) {
return config return config
} }
// Initialize collections config.collections = [
if (!config.collections) { ...(config.collections || []),
config.collections = []
}
config.collections.push(
createPaymentsCollection(pluginConfig), createPaymentsCollection(pluginConfig),
createInvoicesCollection(pluginConfig), createInvoicesCollection(pluginConfig),
createRefundsCollection(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 const incomingOnInit = config.onInit
config.onInit = async (payload) => { config.onInit = async (payload) => {
// Execute any existing onInit functions first
if (incomingOnInit) { if (incomingOnInit) {
await incomingOnInit(payload) await incomingOnInit(payload)
} }
singleton.set(payload, {
config: pluginConfig,
providerConfig: (pluginConfig.providers || []).reduce(
(acc, val) => {
acc[val.key] = val
return acc
},
{} as Record<string, PaymentProvider>
)
} satisfies BillingPlugin)
console.log('Billing plugin initialized', singleton.get(payload))
await Promise.all((pluginConfig.providers || []).map(p => p.onInit(payload)))
} }
return config return config

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

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

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

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

View File

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

View File

@@ -1,107 +1,6 @@
import { Payment } from '@/plugin/types/payments'
export type Id = string | number import { Id } from '@/plugin/types/id'
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;
}
export interface Invoice<TCustomer = unknown> { export interface Invoice<TCustomer = unknown> {
id: Id; id: Id;
@@ -216,4 +115,3 @@ export interface Invoice<TCustomer = unknown> {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }

View File

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

View File

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

View File

@@ -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 type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export const extractSlug = (arg: string | Partial<CollectionConfig>) => typeof arg === 'string' ? arg : arg.slug! export const extractSlug =
(arg: string | Partial<CollectionConfig>) => (typeof arg === 'string' ? arg : arg.slug!) as CollectionSlug

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

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

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

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

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

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