Merge pull request #11 from xtr-dev/dev

Dev
This commit is contained in:
Bas
2025-09-17 19:16:45 +02:00
committed by GitHub
12 changed files with 749 additions and 66 deletions

View File

@@ -24,30 +24,46 @@ pnpm add @xtr-dev/payload-billing
yarn add @xtr-dev/payload-billing yarn add @xtr-dev/payload-billing
``` ```
### Provider Dependencies
Payment providers are peer dependencies and must be installed separately based on which providers you plan to use:
```bash
# For Stripe support
npm install stripe
# or
pnpm add stripe
# For Mollie support
npm install @mollie/api-client
# or
pnpm add @mollie/api-client
```
## Quick Start ## Quick Start
```typescript ```typescript
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
import { billingPlugin } from '@xtr-dev/payload-billing' import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
export default buildConfig({ export default buildConfig({
// ... your config // ... your config
plugins: [ plugins: [
billingPlugin({ billingPlugin({
providers: { providers: [
stripe: { stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!, secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
webhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!, }),
}, mollieProvider({
mollie: {
apiKey: process.env.MOLLIE_API_KEY!, apiKey: process.env.MOLLIE_API_KEY!,
webhookUrl: process.env.MOLLIE_WEBHOOK_URL!, webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
}, }),
test: { ],
enabled: process.env.NODE_ENV === 'development', collections: {
autoComplete: true, payments: 'payments',
} invoices: 'invoices',
refunds: 'refunds',
} }
}) })
] ]
@@ -60,11 +76,11 @@ export default buildConfig({
// Main plugin // Main plugin
import { billingPlugin } from '@xtr-dev/payload-billing' import { billingPlugin } from '@xtr-dev/payload-billing'
// Provider utilities // Payment providers
import { getPaymentProvider } from '@xtr-dev/payload-billing' import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
// Types // Types
import type { PaymentProvider, CreatePaymentOptions, Payment } from '@xtr-dev/payload-billing' import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing'
``` ```
## Provider Types ## Provider Types
@@ -83,16 +99,14 @@ Local development testing with configurable scenarios, automatic completion, deb
The plugin adds these collections: The plugin adds these collections:
- **payments** - Payment transactions with status and provider data - **payments** - Payment transactions with status and provider data
- **customers** - Customer profiles with billing information
- **invoices** - Invoice generation with line items and PDF support - **invoices** - Invoice generation with line items and PDF support
- **refunds** - Refund tracking and management - **refunds** - Refund tracking and management
## Webhook Endpoints ## Webhook Endpoints
Automatic webhook endpoints are created: Automatic webhook endpoints are created for configured providers:
- `/api/billing/webhooks/stripe` - `/api/payload-billing/stripe/webhook` - Stripe payment notifications
- `/api/billing/webhooks/mollie` - `/api/payload-billing/mollie/webhook` - Mollie payment notifications
- `/api/billing/webhooks/test`
## Requirements ## Requirements

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-billing", "name": "@xtr-dev/payload-billing",
"version": "0.1.2", "version": "0.1.3",
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing", "description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -100,16 +100,17 @@
"rimraf": "3.0.2", "rimraf": "3.0.2",
"sharp": "0.34.2", "sharp": "0.34.2",
"sort-package-json": "^2.10.0", "sort-package-json": "^2.10.0",
"stripe": "^18.5.0",
"typescript": "5.7.3", "typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2" "vitest": "^3.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@mollie/api-client": "^3.7.0", "@mollie/api-client": "^3.7.0",
"payload": "^3.37.0" "payload": "^3.37.0",
"stripe": "^18.5.0"
}, },
"dependencies": { "dependencies": {
"stripe": "^14.15.0",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"engines": { "engines": {

20
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
stripe:
specifier: ^14.15.0
version: 14.25.0
zod: zod:
specifier: ^3.22.4 specifier: ^3.22.4
version: 3.25.76 version: 3.25.76
@@ -111,6 +108,9 @@ importers:
sort-package-json: sort-package-json:
specifier: ^2.10.0 specifier: ^2.10.0
version: 2.15.1 version: 2.15.1
stripe:
specifier: ^18.5.0
version: 18.5.0(@types/node@22.18.1)
typescript: typescript:
specifier: 5.7.3 specifier: 5.7.3
version: 5.7.3 version: 5.7.3
@@ -5165,9 +5165,14 @@ packages:
strip-literal@3.0.0: strip-literal@3.0.0:
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
stripe@14.25.0: stripe@18.5.0:
resolution: {integrity: sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==} resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==}
engines: {node: '>=12.*'} engines: {node: '>=12.*'}
peerDependencies:
'@types/node': '>=12.x.x'
peerDependenciesMeta:
'@types/node':
optional: true
strtok3@10.3.4: strtok3@10.3.4:
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
@@ -11434,10 +11439,11 @@ snapshots:
dependencies: dependencies:
js-tokens: 9.0.1 js-tokens: 9.0.1
stripe@14.25.0: stripe@18.5.0(@types/node@22.18.1):
dependencies: dependencies:
'@types/node': 22.18.1
qs: 6.14.0 qs: 6.14.0
optionalDependencies:
'@types/node': 22.18.1
strtok3@10.3.4: strtok3@10.3.4:
dependencies: dependencies:

View File

@@ -29,6 +29,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
}, },
label: 'Provider Payment ID', label: 'Provider Payment ID',
unique: true, unique: true,
index: true, // Ensure this field is indexed for webhook lookups
}, },
{ {
name: 'status', name: 'status',

View File

@@ -10,18 +10,6 @@ export const defaults = {
} }
// Provider configurations // Provider configurations
export interface StripeConfig {
apiVersion?: string
publishableKey: string
secretKey: string
webhookEndpointSecret: string
}
export interface MollieConfig {
apiKey: string
testMode?: boolean
webhookUrl: string
}
export interface TestProviderConfig { export interface TestProviderConfig {
autoComplete?: boolean autoComplete?: boolean
@@ -65,13 +53,5 @@ export interface BillingPluginConfig {
customerRelationSlug?: string // Customer collection slug for relationship customerRelationSlug?: string // Customer collection slug for relationship
disabled?: boolean disabled?: boolean
providers?: PaymentProvider[] providers?: PaymentProvider[]
webhooks?: {
basePath?: string
cors?: boolean
}
} }
// Plugin type
export interface BillingPluginOptions extends BillingPluginConfig {
disabled?: boolean
}

View File

@@ -25,7 +25,11 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
createPaymentsCollection(pluginConfig), createPaymentsCollection(pluginConfig),
createInvoicesCollection(pluginConfig), createInvoicesCollection(pluginConfig),
createRefundsCollection(pluginConfig), createRefundsCollection(pluginConfig),
] ];
(pluginConfig.providers || [])
.filter(provider => provider.onConfig)
.forEach(provider => provider.onConfig!(config, pluginConfig))
const incomingOnInit = config.onInit const incomingOnInit = config.onInit
config.onInit = async (payload) => { config.onInit = async (payload) => {
@@ -35,15 +39,16 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
singleton.set(payload, { singleton.set(payload, {
config: pluginConfig, config: pluginConfig,
providerConfig: (pluginConfig.providers || []).reduce( providerConfig: (pluginConfig.providers || []).reduce(
(acc, val) => { (record, provider) => {
acc[val.key] = val record[provider.key] = provider
return acc return record
}, },
{} as Record<string, PaymentProvider> {} as Record<string, PaymentProvider>
) )
} satisfies BillingPlugin) } satisfies BillingPlugin)
console.log('Billing plugin initialized', singleton.get(payload)) await Promise.all((pluginConfig.providers || [])
await Promise.all((pluginConfig.providers || []).map(p => p.onInit(payload))) .filter(provider => provider.onInit)
.map(provider => provider.onInit!(payload)))
} }
return config return config

94
src/providers/currency.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* Currency utilities for payment processing
*/
// Currencies that don't use centesimal units (no decimal places)
const NON_CENTESIMAL_CURRENCIES = new Set([
'BIF', // Burundian Franc
'CLP', // Chilean Peso
'DJF', // Djiboutian Franc
'GNF', // Guinean Franc
'JPY', // Japanese Yen
'KMF', // Comorian Franc
'KRW', // South Korean Won
'MGA', // Malagasy Ariary
'PYG', // Paraguayan Guaraní
'RWF', // Rwandan Franc
'UGX', // Ugandan Shilling
'VND', // Vietnamese Đồng
'VUV', // Vanuatu Vatu
'XAF', // Central African CFA Franc
'XOF', // West African CFA Franc
'XPF', // CFP Franc
])
// Currencies that use 3 decimal places
const THREE_DECIMAL_CURRENCIES = new Set([
'BHD', // Bahraini Dinar
'IQD', // Iraqi Dinar
'JOD', // Jordanian Dinar
'KWD', // Kuwaiti Dinar
'LYD', // Libyan Dinar
'OMR', // Omani Rial
'TND', // Tunisian Dinar
])
/**
* Convert amount from smallest unit to decimal for display
* @param amount - Amount in smallest unit (e.g., cents for USD)
* @param currency - ISO 4217 currency code
* @returns Formatted amount string for the payment provider
*/
export function formatAmountForProvider(amount: number, currency: string): string {
const upperCurrency = currency.toUpperCase()
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
// No decimal places
return amount.toString()
}
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
// 3 decimal places
return (amount / 1000).toFixed(3)
}
// Default: 2 decimal places (most currencies)
return (amount / 100).toFixed(2)
}
/**
* Get the number of decimal places for a currency
* @param currency - ISO 4217 currency code
* @returns Number of decimal places
*/
export function getCurrencyDecimals(currency: string): number {
const upperCurrency = currency.toUpperCase()
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
return 0
}
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
return 3
}
return 2
}
/**
* Validate currency code format
* @param currency - Currency code to validate
* @returns True if valid ISO 4217 format
*/
export function isValidCurrencyCode(currency: string): boolean {
return /^[A-Z]{3}$/.test(currency.toUpperCase())
}
/**
* Validate amount is positive and within reasonable limits
* @param amount - Amount to validate
* @returns True if valid
*/
export function isValidAmount(amount: number): boolean {
return Number.isInteger(amount) && amount > 0 && amount <= 99999999999 // Max ~999 million in major units
}

View File

@@ -1,2 +1,4 @@
export * from './mollie' export * from './mollie'
export * from './stripe'
export * from './types' export * from './types'
export * from './currency'

View File

@@ -1,36 +1,155 @@
import type { Payment } from '@/plugin/types/payments' import type { Payment } from '@/plugin/types/payments'
import type { InitPayment, PaymentProvider } from '@/plugin/types' import type { PaymentProvider } from '@/plugin/types'
import type { Payload } from 'payload' import type { Payload } from 'payload'
import { createSingleton } from '@/plugin/singleton' import { createSingleton } from '@/plugin/singleton'
import type { createMollieClient, MollieClient } from '@mollie/api-client' import type { createMollieClient, MollieClient } from '@mollie/api-client'
import {
webhookResponses,
findPaymentByProviderId,
updatePaymentStatus,
updateInvoiceOnPaymentSuccess,
handleWebhookError,
validateProductionUrl
} from './utils'
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
const symbol = Symbol('mollie') const symbol = Symbol('mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0] export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
export const mollieProvider = (config: MollieProviderConfig) => { /**
* Type-safe mapping of Mollie payment status to internal status
*/
function mapMollieStatusToPaymentStatus(mollieStatus: string): Payment['status'] {
// Define known Mollie statuses for type safety
const mollieStatusMap: Record<string, Payment['status']> = {
'paid': 'succeeded',
'failed': 'failed',
'canceled': 'canceled',
'expired': 'canceled',
'pending': 'pending',
'open': 'pending',
'authorized': 'pending',
}
return mollieStatusMap[mollieStatus] || 'processing'
}
export const mollieProvider = (mollieConfig: MollieProviderConfig & {
webhookUrl?: string
redirectUrl?: string
}) => {
// Validate required configuration at initialization
if (!mollieConfig.apiKey) {
throw new Error('Mollie API key is required')
}
const singleton = createSingleton<MollieClient>(symbol) const singleton = createSingleton<MollieClient>(symbol)
return { return {
key: 'mollie', key: 'mollie',
onConfig: (config, pluginConfig) => {
// Always register Mollie webhook since it doesn't require a separate webhook secret
// Mollie validates webhooks through payment ID verification
config.endpoints = [
...(config.endpoints || []),
{
path: '/payload-billing/mollie/webhook',
method: 'post',
handler: async (req) => {
try {
const payload = req.payload
const mollieClient = singleton.get(payload)
// Parse the webhook body to get the Mollie payment ID
if (!req.text) {
return webhookResponses.missingBody()
}
const body = await req.text()
if (!body || !body.startsWith('id=')) {
return webhookResponses.invalidPayload()
}
const molliePaymentId = body.slice(3) // Remove 'id=' prefix
// Fetch the payment details from Mollie
const molliePayment = await mollieClient.payments.get(molliePaymentId)
// Find the corresponding payment in our database
const payment = await findPaymentByProviderId(payload, molliePaymentId, pluginConfig)
if (!payment) {
return webhookResponses.paymentNotFound()
}
// Map Mollie status to our status using proper type-safe mapping
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
// Update the payment status and provider data
const updateSuccess = await updatePaymentStatus(
payload,
payment.id,
status,
molliePayment.toPlainObject(),
pluginConfig
)
// If payment is successful and update succeeded, update the invoice
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
console.warn(`[Mollie Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Mollie', error)
}
}
}
]
},
onInit: async (payload: Payload) => { onInit: async (payload: Payload) => {
const createMollieClient = (await import('@mollie/api-client')).default const createMollieClient = (await import('@mollie/api-client')).default
const mollieClient = createMollieClient(config) const mollieClient = createMollieClient(mollieConfig)
singleton.set(payload, mollieClient) singleton.set(payload, mollieClient)
}, },
initPayment: async (payload, payment) => { initPayment: async (payload, payment) => {
// Validate required fields
if (!payment.amount) { if (!payment.amount) {
throw new Error('Amount is required') throw new Error('Amount is required')
} }
if (!payment.currency) { if (!payment.currency) {
throw new Error('Currency is required') throw new Error('Currency is required')
} }
// Validate amount
if (!isValidAmount(payment.amount)) {
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
}
// Validate currency code
if (!isValidCurrencyCode(payment.currency)) {
throw new Error('Invalid currency: must be a 3-letter ISO code')
}
// Setup URLs with development defaults
const isProduction = process.env.NODE_ENV === 'production'
const redirectUrl = mollieConfig.redirectUrl ||
(!isProduction ? 'https://localhost:3000/payment/success' : undefined)
const webhookUrl = mollieConfig.webhookUrl ||
`${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook`
// Validate URLs for production
validateProductionUrl(redirectUrl, 'Redirect')
validateProductionUrl(webhookUrl, 'Webhook')
const molliePayment = await singleton.get(payload).payments.create({ const molliePayment = await singleton.get(payload).payments.create({
amount: { amount: {
value: (payment.amount / 100).toFixed(2), value: formatAmountForProvider(payment.amount, payment.currency),
currency: payment.currency currency: payment.currency.toUpperCase()
}, },
description: payment.description || '', description: payment.description || '',
redirectUrl: 'https://localhost:3000/payment/success', redirectUrl,
webhookUrl: 'https://localhost:3000', webhookUrl,
}); });
payment.providerId = molliePayment.id payment.providerId = molliePayment.id
payment.providerData = molliePayment.toPlainObject() payment.providerData = molliePayment.toPlainObject()

260
src/providers/stripe.ts Normal file
View File

@@ -0,0 +1,260 @@
import type { Payment } from '@/plugin/types/payments'
import type { PaymentProvider, ProviderData } from '@/plugin/types'
import type { Payload } from 'payload'
import { createSingleton } from '@/plugin/singleton'
import type Stripe from 'stripe'
import {
webhookResponses,
findPaymentByProviderId,
updatePaymentStatus,
updateInvoiceOnPaymentSuccess,
handleWebhookError,
logWebhookEvent
} from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
const symbol = Symbol('stripe')
export interface StripeProviderConfig {
secretKey: string
webhookSecret?: string
apiVersion?: Stripe.StripeConfig['apiVersion']
returnUrl?: string
webhookUrl?: string
}
// Default API version for consistency
const DEFAULT_API_VERSION: Stripe.StripeConfig['apiVersion'] = '2025-08-27.basil'
export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
// Validate required configuration at initialization
if (!stripeConfig.secretKey) {
throw new Error('Stripe secret key is required')
}
const singleton = createSingleton<Stripe>(symbol)
return {
key: 'stripe',
onConfig: (config, pluginConfig) => {
// Only register webhook endpoint if webhook secret is configured
if (stripeConfig.webhookSecret) {
config.endpoints = [
...(config.endpoints || []),
{
path: '/payload-billing/stripe/webhook',
method: 'post',
handler: async (req) => {
try {
const payload = req.payload
const stripe = singleton.get(payload)
// Get the raw body for signature verification
let body: string
try {
if (!req.text) {
return webhookResponses.missingBody()
}
body = await req.text()
if (!body) {
return webhookResponses.missingBody()
}
} catch (error) {
return handleWebhookError('Stripe', error, 'Failed to read request body')
}
const signature = req.headers.get('stripe-signature')
if (!signature) {
return webhookResponses.error('Missing webhook signature', 400)
}
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
// Verify webhook signature and construct event
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret)
} catch (err) {
return handleWebhookError('Stripe', err, 'Signature verification failed')
}
// Handle different event types
switch (event.type) {
case 'payment_intent.succeeded':
case 'payment_intent.payment_failed':
case 'payment_intent.canceled': {
const paymentIntent = event.data.object
// Find the corresponding payment in our database
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
if (!payment) {
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`)
return webhookResponses.success() // Still return 200 to acknowledge receipt
}
// Map Stripe status to our status
let status: Payment['status'] = 'pending'
if (paymentIntent.status === 'succeeded') {
status = 'succeeded'
} else if (paymentIntent.status === 'canceled') {
status = 'canceled'
} else if (paymentIntent.status === 'requires_payment_method' ||
paymentIntent.status === 'requires_confirmation' ||
paymentIntent.status === 'requires_action') {
status = 'pending'
} else if (paymentIntent.status === 'processing') {
status = 'processing'
} else {
status = 'failed'
}
// Update the payment status and provider data
const providerData: ProviderData<Stripe.PaymentIntent> = {
raw: paymentIntent,
timestamp: new Date().toISOString(),
provider: 'stripe'
}
const updateSuccess = await updatePaymentStatus(
payload,
payment.id,
status,
providerData,
pluginConfig
)
// If payment is successful and update succeeded, update the invoice
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
console.warn(`[Stripe Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
}
break
}
case 'charge.refunded': {
const charge = event.data.object
// Find the payment by charge ID or payment intent
let payment: Payment | null = null
// First try to find by payment intent ID
if (charge.payment_intent) {
payment = await findPaymentByProviderId(
payload,
charge.payment_intent as string,
pluginConfig
)
}
// If not found, try charge ID
if (!payment) {
payment = await findPaymentByProviderId(payload, charge.id, pluginConfig)
}
if (payment) {
// Determine if fully or partially refunded
const isFullyRefunded = charge.amount_refunded === charge.amount
const providerData: ProviderData<Stripe.Charge> = {
raw: charge,
timestamp: new Date().toISOString(),
provider: 'stripe'
}
const updateSuccess = await updatePaymentStatus(
payload,
payment.id,
isFullyRefunded ? 'refunded' : 'partially_refunded',
providerData,
pluginConfig
)
if (!updateSuccess) {
console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`)
}
}
break
}
default:
// Unhandled event type
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Stripe', error)
}
}
}
]
} else {
// Log that webhook endpoint is not registered
console.warn('[Stripe Provider] Webhook endpoint not registered - webhookSecret not configured')
}
},
onInit: async (payload: Payload) => {
const { default: Stripe } = await import('stripe')
const stripe = new Stripe(stripeConfig.secretKey, {
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
})
singleton.set(payload, stripe)
},
initPayment: async (payload, payment) => {
// Validate required fields
if (!payment.amount) {
throw new Error('Amount is required')
}
if (!payment.currency) {
throw new Error('Currency is required')
}
// Validate amount
if (!isValidAmount(payment.amount)) {
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
}
// Validate currency code
if (!isValidCurrencyCode(payment.currency)) {
throw new Error('Invalid currency: must be a 3-letter ISO code')
}
// Validate description length if provided
if (payment.description && payment.description.length > 1000) {
throw new Error('Description must be 1000 characters or less')
}
const stripe = singleton.get(payload)
// Create a payment intent
const paymentIntent = await stripe.paymentIntents.create({
amount: payment.amount, // Stripe handles currency conversion internally
currency: payment.currency.toLowerCase(),
description: payment.description || undefined,
metadata: {
payloadPaymentId: payment.id?.toString() || '',
...(typeof payment.metadata === 'object' &&
payment.metadata !== null &&
!Array.isArray(payment.metadata)
? payment.metadata
: {})
} as Stripe.MetadataParam,
automatic_payment_methods: {
enabled: true,
},
})
payment.providerId = paymentIntent.id
const providerData: ProviderData<Stripe.PaymentIntent> = {
raw: { ...paymentIntent, client_secret: paymentIntent.client_secret },
timestamp: new Date().toISOString(),
provider: 'stripe'
}
payment.providerData = providerData
return payment
},
} satisfies PaymentProvider
}

View File

@@ -1,10 +1,21 @@
import type { Payment } from '@/plugin/types/payments' import type { Payment } from '@/plugin/types/payments'
import type { Payload } from 'payload' import type { Config, Payload } from 'payload'
import type { BillingPluginConfig } from '@/plugin/config'
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
export type PaymentProvider = { export type PaymentProvider = {
key: string key: string
onInit: (payload: Payload) => Promise<void> | void onConfig?: (config: Config, pluginConfig: BillingPluginConfig) => void
onInit?: (payload: Payload) => Promise<void> | void
initPayment: InitPayment initPayment: InitPayment
} }
/**
* Type-safe provider data wrapper
*/
export type ProviderData<T = unknown> = {
raw: T
timestamp: string
provider: string
}

190
src/providers/utils.ts Normal file
View File

@@ -0,0 +1,190 @@
import type { Payload } from 'payload'
import type { Payment } from '@/plugin/types/payments'
import type { BillingPluginConfig } from '@/plugin/config'
import { defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils'
/**
* Common webhook response utilities
* Note: Always return 200 for webhook acknowledgment to prevent information disclosure
*/
export const webhookResponses = {
success: () => Response.json({ received: true }, { status: 200 }),
error: (message: string, status = 400) => {
// Log error internally but don't expose details
console.error('[Webhook] Error:', message)
return Response.json({ error: 'Invalid request' }, { status })
},
missingBody: () => Response.json({ received: true }, { status: 200 }),
paymentNotFound: () => Response.json({ received: true }, { status: 200 }),
invalidPayload: () => Response.json({ received: true }, { status: 200 }),
}
/**
* Find a payment by provider ID
*/
export async function findPaymentByProviderId(
payload: Payload,
providerId: string,
pluginConfig: BillingPluginConfig
): Promise<Payment | null> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
const payments = await payload.find({
collection: paymentsCollection,
where: {
providerId: {
equals: providerId
}
}
})
return payments.docs.length > 0 ? payments.docs[0] as Payment : null
}
/**
* Update payment status and provider data with proper optimistic locking
*/
export async function updatePaymentStatus(
payload: Payload,
paymentId: string | number,
status: Payment['status'],
providerData: any,
pluginConfig: BillingPluginConfig
): Promise<boolean> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
// Get current payment to check for concurrent modifications
const currentPayment = await payload.findByID({
collection: paymentsCollection,
id: paymentId as any // Cast to avoid type mismatch between Id and PayloadCMS types
}) as Payment
const now = new Date().toISOString()
// First, try to find payments that match both ID and current updatedAt
const conflictCheck = await payload.find({
collection: paymentsCollection,
where: {
id: { equals: paymentId },
updatedAt: { equals: currentPayment.updatedAt }
}
})
// If no matching payment found, it means it was modified concurrently
if (conflictCheck.docs.length === 0) {
console.warn(`[Payment Update] Concurrent modification detected for payment ${paymentId}, skipping update`)
return false
}
try {
const result = await payload.update({
collection: paymentsCollection,
id: paymentId as any, // Cast to avoid type mismatch between Id and PayloadCMS types
data: {
status,
providerData: {
...providerData,
webhookProcessedAt: now,
previousStatus: currentPayment.status
}
}
})
// Verify the update actually happened by checking if updatedAt changed
if (result.updatedAt === currentPayment.updatedAt) {
console.warn(`[Payment Update] Update may have failed for payment ${paymentId} - updatedAt unchanged`)
return false
}
return true
} catch (error) {
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
return false
}
}
/**
* Update invoice status when payment succeeds
*/
export async function updateInvoiceOnPaymentSuccess(
payload: Payload,
payment: Payment,
pluginConfig: BillingPluginConfig
): Promise<void> {
if (!payment.invoice) return
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
const invoiceId = typeof payment.invoice === 'object'
? payment.invoice.id
: payment.invoice
await payload.update({
collection: invoicesCollection,
id: invoiceId as any, // Cast to avoid type mismatch between Id and PayloadCMS types
data: {
status: 'paid',
payment: payment.id
}
})
}
/**
* Handle webhook errors with consistent logging
*/
export function handleWebhookError(
provider: string,
error: unknown,
context?: string
): Response {
const message = error instanceof Error ? error.message : 'Unknown error'
const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]`
// Log detailed error internally for debugging
console.error(`${fullContext} Error:`, error)
// Return generic response to avoid information disclosure
return Response.json({
received: false,
error: 'Processing error'
}, { status: 200 })
}
/**
* Log webhook events
*/
export function logWebhookEvent(
provider: string,
event: string,
details?: any
): void {
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
}
/**
* Validate URL for production use
*/
export function validateProductionUrl(url: string | undefined, urlType: string): void {
const isProduction = process.env.NODE_ENV === 'production'
if (!isProduction) return
if (!url) {
throw new Error(`${urlType} URL is required for production`)
}
if (url.includes('localhost') || url.includes('127.0.0.1')) {
throw new Error(`${urlType} URL cannot use localhost in production`)
}
if (!url.startsWith('https://')) {
throw new Error(`${urlType} URL must use HTTPS in production`)
}
// Basic URL validation
try {
new URL(url)
} catch {
throw new Error(`${urlType} URL is not a valid URL`)
}
}