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

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,
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',

View File

@@ -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<Payment>[],

View File

@@ -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],
},

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 { 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

View File

@@ -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<string, PaymentProvider>
)
} satisfies BillingPlugin)
console.log('Billing plugin initialized', singleton.get(payload))
await Promise.all((pluginConfig.providers || []).map(p => p.onInit(payload)))
}
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
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<TCustomer = unknown> {
id: Id;
@@ -216,4 +115,3 @@ export interface Invoice<TCustomer = unknown> {
updatedAt: 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 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
}