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