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:
2025-09-17 18:24:45 +02:00
parent d08bb221ec
commit 209b683a8a
3 changed files with 201 additions and 136 deletions

View File

@@ -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 })
} }
} }
} }

View File

@@ -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
View 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) : '')
}