mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 10:53:23 +00:00
refactor: Extract common provider utilities to reduce duplication
- Create shared utilities module for payment providers - Add webhook response helpers for consistent API responses - Extract common database operations (find payment, update status) - Implement shared invoice update logic - Add consistent error handling and logging utilities - Refactor Mollie provider to use shared utilities - Refactor Stripe provider to use shared utilities - Remove duplicate code between providers 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
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 { Config, 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 { defaults } from '@/plugin/config'
|
import {
|
||||||
import { extractSlug } from '@/plugin/utils'
|
webhookResponses,
|
||||||
|
findPaymentByProviderId,
|
||||||
|
updatePaymentStatus,
|
||||||
|
updateInvoiceOnPaymentSuccess,
|
||||||
|
handleWebhookError
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
const symbol = Symbol('mollie')
|
const symbol = Symbol('mollie')
|
||||||
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
||||||
@@ -29,11 +34,11 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
|
|
||||||
// Parse the webhook body to get the Mollie payment ID
|
// Parse the webhook body to get the Mollie payment ID
|
||||||
if (!req.text) {
|
if (!req.text) {
|
||||||
return Response.json({ error: 'Missing request body' }, { status: 400 })
|
return webhookResponses.missingBody()
|
||||||
}
|
}
|
||||||
const body = await req.text()
|
const body = await req.text()
|
||||||
if (!body || !body.startsWith('id=')) {
|
if (!body || !body.startsWith('id=')) {
|
||||||
return Response.json({ error: 'Invalid webhook payload' }, { status: 400 })
|
return webhookResponses.invalidPayload()
|
||||||
}
|
}
|
||||||
|
|
||||||
const molliePaymentId = body.slice(3) // Remove 'id=' prefix
|
const molliePaymentId = body.slice(3) // Remove 'id=' prefix
|
||||||
@@ -42,22 +47,12 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
const molliePayment = await mollieClient.payments.get(molliePaymentId)
|
const molliePayment = await mollieClient.payments.get(molliePaymentId)
|
||||||
|
|
||||||
// Find the corresponding payment in our database
|
// Find the corresponding payment in our database
|
||||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
const payment = await findPaymentByProviderId(payload, molliePaymentId, pluginConfig)
|
||||||
const payments = await payload.find({
|
|
||||||
collection: paymentsCollection,
|
|
||||||
where: {
|
|
||||||
providerId: {
|
|
||||||
equals: molliePaymentId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (payments.docs.length === 0) {
|
if (!payment) {
|
||||||
return Response.json({ error: 'Payment not found' }, { status: 404 })
|
return webhookResponses.paymentNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentDoc = payments.docs[0]
|
|
||||||
|
|
||||||
// Map Mollie status to our status
|
// Map Mollie status to our status
|
||||||
let status: Payment['status'] = 'pending'
|
let status: Payment['status'] = 'pending'
|
||||||
// Cast to string to avoid ESLint enum comparison warning
|
// Cast to string to avoid ESLint enum comparison warning
|
||||||
@@ -83,41 +78,22 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the payment status and provider data
|
// Update the payment status and provider data
|
||||||
await payload.update({
|
await updatePaymentStatus(
|
||||||
collection: paymentsCollection,
|
payload,
|
||||||
id: paymentDoc.id,
|
payment.id,
|
||||||
data: {
|
|
||||||
status,
|
status,
|
||||||
providerData: molliePayment.toPlainObject()
|
molliePayment.toPlainObject(),
|
||||||
}
|
pluginConfig
|
||||||
})
|
)
|
||||||
|
|
||||||
// If payment is successful and linked to an invoice, update the invoice
|
// If payment is successful and linked to an invoice, update the invoice
|
||||||
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
|
if (status === 'succeeded') {
|
||||||
const payment = paymentDoc as Payment
|
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||||
|
|
||||||
if (status === 'succeeded' && payment.invoice) {
|
|
||||||
const invoiceId = typeof payment.invoice === 'object'
|
|
||||||
? payment.invoice.id
|
|
||||||
: payment.invoice
|
|
||||||
|
|
||||||
await payload.update({
|
|
||||||
collection: invoicesCollection,
|
|
||||||
id: invoiceId,
|
|
||||||
data: {
|
|
||||||
status: 'paid',
|
|
||||||
payment: paymentDoc.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ received: true }, { status: 200 })
|
return webhookResponses.success()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Mollie Webhook] Error processing webhook:', error)
|
return handleWebhookError('Mollie', error)
|
||||||
return Response.json({
|
|
||||||
error: 'Webhook processing failed',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}, { status: 500 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { Payment } from '@/plugin/types/payments'
|
import type { Payment } from '@/plugin/types/payments'
|
||||||
import type { PaymentProvider } from '@/plugin/types'
|
import type { PaymentProvider } from '@/plugin/types'
|
||||||
import type { Config, Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import { createSingleton } from '@/plugin/singleton'
|
import { createSingleton } from '@/plugin/singleton'
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from 'stripe'
|
||||||
import { defaults } from '@/plugin/config'
|
import {
|
||||||
import { extractSlug } from '@/plugin/utils'
|
webhookResponses,
|
||||||
|
findPaymentByProviderId,
|
||||||
|
updatePaymentStatus,
|
||||||
|
updateInvoiceOnPaymentSuccess,
|
||||||
|
handleWebhookError,
|
||||||
|
logWebhookEvent
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
const symbol = Symbol('stripe')
|
const symbol = Symbol('stripe')
|
||||||
|
|
||||||
@@ -34,14 +40,14 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
|
|
||||||
// Get the raw body for signature verification
|
// Get the raw body for signature verification
|
||||||
if (!req.text) {
|
if (!req.text) {
|
||||||
return Response.json({ error: 'Missing request body' }, { status: 400 })
|
return webhookResponses.missingBody()
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.text()
|
const body = await req.text()
|
||||||
const signature = req.headers.get('stripe-signature')
|
const signature = req.headers.get('stripe-signature')
|
||||||
|
|
||||||
if (!signature || !stripeConfig.webhookSecret) {
|
if (!signature || !stripeConfig.webhookSecret) {
|
||||||
return Response.json({ error: 'Missing webhook signature or secret' }, { status: 400 })
|
return webhookResponses.error('Missing webhook signature or secret')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify webhook signature and construct event
|
// Verify webhook signature and construct event
|
||||||
@@ -49,36 +55,24 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret)
|
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Stripe Webhook] Signature verification failed:', err)
|
return handleWebhookError('Stripe', err, 'Signature verification failed')
|
||||||
return Response.json({ error: 'Invalid signature' }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different event types
|
// Handle different event types
|
||||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'payment_intent.succeeded':
|
case 'payment_intent.succeeded':
|
||||||
case 'payment_intent.payment_failed':
|
case 'payment_intent.payment_failed':
|
||||||
case 'payment_intent.canceled': {
|
case 'payment_intent.canceled': {
|
||||||
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
const paymentIntent = event.data.object
|
||||||
|
|
||||||
// Find the corresponding payment in our database
|
// Find the corresponding payment in our database
|
||||||
const payments = await payload.find({
|
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
||||||
collection: paymentsCollection,
|
|
||||||
where: {
|
|
||||||
providerId: {
|
|
||||||
equals: paymentIntent.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (payments.docs.length === 0) {
|
if (!payment) {
|
||||||
console.error(`[Stripe Webhook] Payment not found for intent: ${paymentIntent.id}`)
|
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`)
|
||||||
return Response.json({ received: true }, { status: 200 }) // Still return 200 to acknowledge receipt
|
return webhookResponses.success() // Still return 200 to acknowledge receipt
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentDoc = payments.docs[0]
|
|
||||||
|
|
||||||
// Map Stripe status to our status
|
// Map Stripe status to our status
|
||||||
let status: Payment['status'] = 'pending'
|
let status: Payment['status'] = 'pending'
|
||||||
|
|
||||||
@@ -97,88 +91,64 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the payment status and provider data
|
// Update the payment status and provider data
|
||||||
await payload.update({
|
await updatePaymentStatus(
|
||||||
collection: paymentsCollection,
|
payload,
|
||||||
id: paymentDoc.id,
|
payment.id,
|
||||||
data: {
|
|
||||||
status,
|
status,
|
||||||
providerData: paymentIntent as any
|
paymentIntent as any,
|
||||||
}
|
pluginConfig
|
||||||
})
|
)
|
||||||
|
|
||||||
// If payment is successful and linked to an invoice, update the invoice
|
// If payment is successful and linked to an invoice, update the invoice
|
||||||
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
|
if (status === 'succeeded') {
|
||||||
const payment = paymentDoc as Payment
|
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||||
|
|
||||||
if (status === 'succeeded' && payment.invoice) {
|
|
||||||
const invoiceId = typeof payment.invoice === 'object'
|
|
||||||
? payment.invoice.id
|
|
||||||
: payment.invoice
|
|
||||||
|
|
||||||
await payload.update({
|
|
||||||
collection: invoicesCollection,
|
|
||||||
id: invoiceId,
|
|
||||||
data: {
|
|
||||||
status: 'paid',
|
|
||||||
payment: paymentDoc.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'charge.refunded': {
|
case 'charge.refunded': {
|
||||||
const charge = event.data.object as Stripe.Charge
|
const charge = event.data.object
|
||||||
|
|
||||||
// Find the payment by charge ID (which might be stored in providerData)
|
// Find the payment by charge ID or payment intent
|
||||||
const payments = await payload.find({
|
let payment: Payment | null = null
|
||||||
collection: paymentsCollection,
|
|
||||||
where: {
|
|
||||||
or: [
|
|
||||||
{
|
|
||||||
providerId: {
|
|
||||||
equals: charge.payment_intent as string
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
providerId: {
|
|
||||||
equals: charge.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (payments.docs.length > 0) {
|
// First try to find by payment intent ID
|
||||||
const paymentDoc = payments.docs[0]
|
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
|
// Determine if fully or partially refunded
|
||||||
const isFullyRefunded = charge.amount_refunded === charge.amount
|
const isFullyRefunded = charge.amount_refunded === charge.amount
|
||||||
|
|
||||||
await payload.update({
|
await updatePaymentStatus(
|
||||||
collection: paymentsCollection,
|
payload,
|
||||||
id: paymentDoc.id,
|
payment.id,
|
||||||
data: {
|
isFullyRefunded ? 'refunded' : 'partially_refunded',
|
||||||
status: isFullyRefunded ? 'refunded' : 'partially_refunded',
|
charge as any,
|
||||||
providerData: charge as any
|
pluginConfig
|
||||||
}
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Unhandled event type
|
// Unhandled event type
|
||||||
console.log(`[Stripe Webhook] Unhandled event type: ${event.type}`)
|
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ received: true }, { status: 200 })
|
return webhookResponses.success()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Stripe Webhook] Error processing webhook:', error)
|
return handleWebhookError('Stripe', error)
|
||||||
return Response.json({
|
|
||||||
error: 'Webhook processing failed',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}, { status: 500 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +157,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
onInit: async (payload: Payload) => {
|
onInit: async (payload: Payload) => {
|
||||||
const { default: Stripe } = await import('stripe')
|
const { default: Stripe } = await import('stripe')
|
||||||
const stripe = new Stripe(stripeConfig.secretKey, {
|
const stripe = new Stripe(stripeConfig.secretKey, {
|
||||||
apiVersion: stripeConfig.apiVersion || '2024-11-20.acacia',
|
apiVersion: stripeConfig.apiVersion,
|
||||||
})
|
})
|
||||||
singleton.set(payload, stripe)
|
singleton.set(payload, stripe)
|
||||||
},
|
},
|
||||||
@@ -208,8 +178,12 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
description: payment.description || undefined,
|
description: payment.description || undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
payloadPaymentId: payment.id?.toString() || '',
|
payloadPaymentId: payment.id?.toString() || '',
|
||||||
...(typeof payment.metadata === 'object' && payment.metadata !== null ? payment.metadata : {})
|
...(typeof payment.metadata === 'object' &&
|
||||||
},
|
payment.metadata !== null &&
|
||||||
|
!Array.isArray(payment.metadata)
|
||||||
|
? payment.metadata
|
||||||
|
: {})
|
||||||
|
} as Stripe.MetadataParam,
|
||||||
automatic_payment_methods: {
|
automatic_payment_methods: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
115
src/providers/utils.ts
Normal file
115
src/providers/utils.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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
|
||||||
|
*/
|
||||||
|
export const webhookResponses = {
|
||||||
|
success: () => Response.json({ received: true }, { status: 200 }),
|
||||||
|
error: (message: string, status = 400) => Response.json({ error: message }, { status }),
|
||||||
|
missingBody: () => Response.json({ error: 'Missing request body' }, { status: 400 }),
|
||||||
|
paymentNotFound: () => Response.json({ error: 'Payment not found' }, { status: 404 }),
|
||||||
|
invalidPayload: () => Response.json({ error: 'Invalid webhook payload' }, { status: 400 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export async function updatePaymentStatus(
|
||||||
|
payload: Payload,
|
||||||
|
paymentId: string | number,
|
||||||
|
status: Payment['status'],
|
||||||
|
providerData: any,
|
||||||
|
pluginConfig: BillingPluginConfig
|
||||||
|
): Promise<void> {
|
||||||
|
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
id: paymentId,
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
providerData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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]`
|
||||||
|
|
||||||
|
console.error(`${fullContext} Error:`, error)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
error: 'Webhook processing failed',
|
||||||
|
details: message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log webhook events
|
||||||
|
*/
|
||||||
|
export function logWebhookEvent(
|
||||||
|
provider: string,
|
||||||
|
event: string,
|
||||||
|
details?: any
|
||||||
|
): void {
|
||||||
|
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user