The current webhook handler is becoming complex and difficult to maintain. Here's a plan to refactor it into a more modular and maintainable structure.
- Complexity: The file is large and handles many different types of events with complex logic.
- Duplication: Similar logic is repeated across different event handlers.
- Maintainability: It's difficult to understand the full flow and make changes safely.
- Error Handling: Error handling is inconsistent across different parts of the code.
- Multiple Gift Subscriptions: The current implementation doesn't properly handle multiple gift subscriptions extending the pause date.
Create a modular structure with separate files for different concerns:
src/
pages/
api/
stripe/
webhook.ts # Main entry point
handlers/ # Event-specific handlers
subscription-events.ts # Subscription-related events
invoice-events.ts # Invoice-related events
checkout-events.ts # Checkout-related events
charge-events.ts # Charge-related events
customer-events.ts # Customer-related events
services/ # Reusable services
subscription-service.ts # Subscription-related operations
gift-service.ts # Gift subscription operations
customer-service.ts # Customer-related operations
utils/ # Helper utilities
idempotency.ts # Idempotency handling
error-handling.ts # Error handling utilities
transaction.ts # Transaction utilities
Implement service classes for common operations:
class SubscriptionService {
constructor(private tx: Prisma.TransactionClient) {}
async upsertSubscription(subscription: Stripe.Subscription, userId: string): Promise<void> {
// Logic to upsert subscription
}
async pauseForGift(subscriptionId: string, giftExpirationDate: Date, metadata: Record<string, string | number | boolean | null>): Promise<void> {
// Logic to pause subscription for gift
}
async updateProExpiration(userId: string, expirationDate: Date): Promise<void> {
// Logic to update proExpiration
}
}
class GiftService {
constructor(private tx: Prisma.TransactionClient) {}
async processGiftCheckout(session: Stripe.Checkout.Session): Promise<void> {
// Logic to process gift checkout
}
async extendSubscriptionWithGift(userId: string, giftType: string, giftQuantity: number): Promise<Date> {
// Logic to extend subscription with gift
}
}
Create specific handlers for each event type:
// subscription-events.ts
export async function handleSubscriptionCreated(subscription: Stripe.Subscription, tx: Prisma.TransactionClient): Promise<void> {
// Logic for subscription created
}
export async function handleSubscriptionUpdated(subscription: Stripe.Subscription, tx: Prisma.TransactionClient): Promise<void> {
// Logic for subscription updated
}
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription, tx: Prisma.TransactionClient): Promise<void> {
// Logic for subscription deleted
}
Implement consistent error handling across all handlers:
// error-handling.ts
export async function withErrorHandling<T>(
operation: () => Promise<T>,
context: string,
userId?: string
): Promise<T | null> {
try {
return await operation();
} catch (error) {
console.error(`Error in ${context}${userId ? ` for user ${userId}` : ''}:`, error);
// Optional: Log to monitoring service
return null;
}
}
Create utilities for transaction management:
// transaction.ts
export async function withTransaction<T>(
operation: (tx: Prisma.TransactionClient) => Promise<T>,
maxRetries = 3
): Promise<T | null> {
let retryCount = 0;
let lastError: Error | unknown = null;
while (retryCount < maxRetries) {
try {
return await prisma.$transaction(operation, { timeout: 10000 });
} catch (error) {
lastError = error;
retryCount++;
console.error(`Transaction attempt ${retryCount} failed:`, error);
if (retryCount < maxRetries) {
const delay = 2 ** retryCount * 500;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
console.error('Transaction failed after multiple attempts:', lastError);
return null;
}
Create a utility for idempotency:
// idempotency.ts
export async function processEventIdempotently(
eventId: string,
eventType: string,
processor: (tx: Prisma.TransactionClient) => Promise<void>,
tx: Prisma.TransactionClient
): Promise<boolean> {
// Check if we've already processed this event
const existingEvent = await tx.webhookEvent.findUnique({
where: { stripeEventId: eventId },
});
if (existingEvent) {
return false; // Already processed
}
// Record the event to ensure idempotency
await tx.webhookEvent.create({
data: {
stripeEventId: eventId,
eventType: eventType,
processedAt: new Date(),
},
});
// Process the event
await processor(tx);
return true;
}
Simplify the main webhook handler:
// webhook.ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { event, error } = await verifyWebhook(req);
if (error) {
return res.status(400).json({ error });
}
if (!relevantEvents.has(event.type)) {
return res.status(200).json({ received: true });
}
const result = await withTransaction(async (tx) => {
return processEventIdempotently(
event.id,
event.type,
async (tx) => {
await processWebhookEvent(event, tx);
},
tx
);
});
if (result === null) {
return res.status(500).json({ error: 'Webhook processing failed' });
}
return res.status(200).json({ received: true });
}
- Phase 1: Create the folder structure and move existing code
- Phase 2: Implement service classes and utilities
- Phase 3: Refactor event handlers to use the new services
- Phase 4: Update the main webhook handler
- Phase 5: Add comprehensive tests for each component
- Phase 6: Deploy and monitor
- Maintainability: Smaller, focused files are easier to understand and maintain
- Testability: Modular code is easier to test
- Scalability: New event types can be added without modifying existing code
- Reliability: Consistent error handling and transaction management
- Performance: Better handling of multiple gift subscriptions and edge cases