import Stripe from 'stripe'; import { prisma } from '../../config/database'; import { getStripe, getWebhookSecret } from '../../services/stripe.client'; import { logger } from '../../utils/logger'; import { paymentEmailService } from './payment-email.service'; import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service'; // Helper to extract subscription ID from invoice (may be string, object, or missing in newer types) function getSubscriptionId(invoice: Stripe.Invoice): string | null { const raw = invoice as unknown as Record; const sub = raw.subscription; if (!sub) return null; if (typeof sub === 'string') return sub; if (typeof sub === 'object' && sub !== null && 'id' in sub) return (sub as { id: string }).id; return null; } export const webhookService = { /** Verify and parse a webhook event */ async constructEvent(rawBody: Buffer, signature: string): Promise { const stripe = await getStripe(); const secret = await getWebhookSecret(); if (!secret) throw new Error('Webhook secret not configured'); return stripe.webhooks.constructEvent(rawBody, signature, secret); }, /** Route and handle a webhook event */ async handleEvent(event: Stripe.Event): Promise { logger.info(`Stripe webhook: ${event.type} (${event.id})`); switch (event.type) { case 'checkout.session.completed': await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); break; case 'invoice.paid': await this.handleInvoicePaid(event.data.object as Stripe.Invoice); break; case 'invoice.payment_failed': await this.handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); break; case 'customer.subscription.updated': await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription); break; case 'customer.subscription.deleted': await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); break; case 'charge.refunded': await this.handleChargeRefunded(event.data.object as Stripe.Charge); break; case 'checkout.session.expired': await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session); break; default: logger.debug(`Unhandled Stripe event type: ${event.type}`); } }, async handleCheckoutCompleted(session: Stripe.Checkout.Session) { const type = session.metadata?.type; if (type === 'subscription') { await this.handleSubscriptionCheckout(session); } else if (type === 'product') { await this.handleProductCheckout(session); } else if (type === 'donation') { await this.handleDonationCheckout(session); } else if (type === 'event_ticket') { await this.handleEventTicketCheckout(session); } else { logger.warn(`Unknown checkout type: ${type}`); } }, async handleSubscriptionCheckout(session: Stripe.Checkout.Session) { const { userId, planId } = session.metadata || {}; if (!userId || !planId) { logger.error('Missing metadata in subscription checkout', { sessionId: session.id }); return; } const stripe = await getStripe(); const subscriptionId = typeof session.subscription === 'string' ? session.subscription : (session.subscription as { id: string } | null)?.id; const customerId = typeof session.customer === 'string' ? session.customer : (session.customer as { id: string } | null)?.id; if (!subscriptionId || !customerId) { logger.error('Missing subscription or customer ID in checkout session'); return; } // Get subscription details from Stripe const stripeSub = await stripe.subscriptions.retrieve(subscriptionId) as unknown as { current_period_end: number; cancel_at_period_end: boolean; }; const currentPeriodEnd = new Date(stripeSub.current_period_end * 1000); // Check idempotency const existing = await prisma.userSubscription.findUnique({ where: { stripeSubscriptionId: subscriptionId }, }); if (existing) { logger.info(`Subscription already exists for ${subscriptionId}`); return; } // Deactivate existing active subs await prisma.userSubscription.updateMany({ where: { userId, status: 'active' }, data: { status: 'cancelled', cancelledAt: new Date() }, }); await prisma.userSubscription.create({ data: { userId, planId: parseInt(planId, 10), status: 'active', startDate: new Date(), endDate: currentPeriodEnd, stripeSubscriptionId: subscriptionId, stripeCustomerId: customerId, currentPeriodEnd, }, }); await this.createAuditLog('subscription_created', { userId, planId, subscriptionId }); logger.info(`Subscription activated for user ${userId}, plan ${planId}`); // Send subscription welcome email (fire-and-forget) await paymentEmailService.sendSubscriptionWelcome({ userId, planId: parseInt(planId, 10), stripeSubscriptionId: subscriptionId, currentPeriodEnd, }); // Sync to Listmonk Subscribers list (fire-and-forget) const subUser = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }); const plan = await prisma.subscriptionPlan.findUnique({ where: { id: parseInt(planId, 10) }, select: { name: true } }); if (subUser) { listmonkEventSyncService.onSubscriptionActivated({ email: subUser.email, name: subUser.name || '', planName: plan?.name || `Plan ${planId}`, subscriptionId, }).catch(() => {}); } }, async handleProductCheckout(session: Stripe.Checkout.Session) { const order = await prisma.order.findUnique({ where: { stripeCheckoutSessionId: session.id }, }); if (!order) { logger.error('Order not found for checkout session', { sessionId: session.id }); return; } if (order.status === 'COMPLETED') return; // idempotent const paymentIntentId = typeof session.payment_intent === 'string' ? session.payment_intent : (session.payment_intent as { id: string } | null)?.id || null; await prisma.$transaction([ prisma.order.update({ where: { id: order.id }, data: { status: 'COMPLETED', stripePaymentIntentId: paymentIntentId, completedAt: new Date(), }, }), // Increment purchase count ...(order.productId ? [ prisma.product.update({ where: { id: order.productId }, data: { purchaseCount: { increment: 1 } }, }), ] : []), ]); await this.createAuditLog('product_purchased', { orderId: order.id, productId: order.productId, amount: order.amountCAD, }); logger.info(`Product order completed: ${order.id}`); // Send product receipt (fire-and-forget) const updatedOrder = await prisma.order.findUnique({ where: { id: order.id }, include: { product: { select: { title: true, type: true } } }, }); if (updatedOrder) { await paymentEmailService.sendProductReceipt({ id: updatedOrder.id, buyerEmail: updatedOrder.buyerEmail || '', buyerName: updatedOrder.buyerName, amountCAD: updatedOrder.amountCAD, completedAt: updatedOrder.completedAt, product: updatedOrder.product, }); // Sync to Listmonk Donors list (fire-and-forget) if (updatedOrder.buyerEmail) { listmonkEventSyncService.onProductPurchased({ email: updatedOrder.buyerEmail, name: updatedOrder.buyerName || '', productTitle: updatedOrder.product?.title || 'Product', amountCents: updatedOrder.amountCAD, orderId: updatedOrder.id, }).catch(() => {}); } } }, async handleDonationCheckout(session: Stripe.Checkout.Session) { const order = await prisma.order.findUnique({ where: { stripeCheckoutSessionId: session.id }, }); if (!order) { logger.error('Donation order not found for checkout session', { sessionId: session.id }); return; } if (order.status === 'COMPLETED') return; // idempotent const paymentIntentId = typeof session.payment_intent === 'string' ? session.payment_intent : (session.payment_intent as { id: string } | null)?.id || null; // Link to donation page if metadata contains donationPageId (from page-specific checkout) const donationPageId = session.metadata?.donationPageId || null; const updateData: Record = { status: 'COMPLETED', stripePaymentIntentId: paymentIntentId, completedAt: new Date(), }; if (donationPageId && !order.donationPageId) { updateData.donationPageId = donationPageId; } await prisma.order.update({ where: { id: order.id }, data: updateData as import('@prisma/client').Prisma.OrderUncheckedUpdateInput, }); await this.createAuditLog('donation_completed', { orderId: order.id, amount: order.amountCAD, email: order.buyerEmail, }); logger.info(`Donation completed: ${order.id}, $${(order.amountCAD / 100).toFixed(2)}`); // Send donation receipt (fire-and-forget, errors logged but not thrown) await paymentEmailService.sendDonationReceipt({ id: order.id, buyerEmail: order.buyerEmail || '', buyerName: order.buyerName, amountCAD: order.amountCAD, donorMessage: order.donorMessage, isAnonymous: order.isAnonymous, completedAt: new Date(), }); // Sync to Listmonk Donors list (fire-and-forget) if (order.buyerEmail) { listmonkEventSyncService.onDonationCompleted({ email: order.buyerEmail, name: order.buyerName || '', amountCents: order.amountCAD, orderId: order.id, }).catch(() => {}); } }, async handleInvoicePaid(invoice: Stripe.Invoice) { const subscriptionId = getSubscriptionId(invoice); if (!subscriptionId) return; const sub = await prisma.userSubscription.findUnique({ where: { stripeSubscriptionId: subscriptionId }, }); if (!sub) return; const stripe = await getStripe(); const stripeSub = await stripe.subscriptions.retrieve(subscriptionId) as unknown as { current_period_end: number; }; const currentPeriodEnd = new Date(stripeSub.current_period_end * 1000); await prisma.userSubscription.update({ where: { id: sub.id }, data: { status: 'active', currentPeriodEnd, endDate: currentPeriodEnd, }, }); logger.info(`Invoice paid, subscription ${subscriptionId} renewed to ${currentPeriodEnd}`); }, async handleInvoicePaymentFailed(invoice: Stripe.Invoice) { const subscriptionId = getSubscriptionId(invoice); if (!subscriptionId) return; const sub = await prisma.userSubscription.findUnique({ where: { stripeSubscriptionId: subscriptionId }, }); if (!sub) return; await prisma.userSubscription.update({ where: { id: sub.id }, data: { status: 'grace_period' }, }); await this.createAuditLog('payment_failed', { subscriptionId, userId: sub.userId }); logger.warn(`Payment failed for subscription ${subscriptionId}`); }, async handleSubscriptionUpdated(subscription: Stripe.Subscription) { const sub = await prisma.userSubscription.findUnique({ where: { stripeSubscriptionId: subscription.id }, }); if (!sub) return; const rawSub = subscription as unknown as { current_period_end: number; cancel_at_period_end: boolean; }; const currentPeriodEnd = new Date(rawSub.current_period_end * 1000); await prisma.userSubscription.update({ where: { id: sub.id }, data: { cancelAtPeriodEnd: rawSub.cancel_at_period_end, currentPeriodEnd, endDate: currentPeriodEnd, }, }); }, async handleSubscriptionDeleted(subscription: Stripe.Subscription) { const sub = await prisma.userSubscription.findUnique({ where: { stripeSubscriptionId: subscription.id }, }); if (!sub) return; await prisma.userSubscription.update({ where: { id: sub.id }, data: { status: 'cancelled', cancelledAt: new Date(), }, }); await this.createAuditLog('subscription_cancelled', { subscriptionId: subscription.id, userId: sub.userId, }); logger.info(`Subscription cancelled: ${subscription.id}`); }, async handleEventTicketCheckout(session: Stripe.Checkout.Session) { const order = await prisma.order.findUnique({ where: { stripeCheckoutSessionId: session.id }, }); if (!order) { logger.error('Order not found for event ticket checkout', { sessionId: session.id }); return; } if (order.status === 'COMPLETED') return; // idempotent const { eventId, tierId, quantity, buyerEmail, buyerName, userId } = session.metadata || {}; if (!eventId || !tierId || !quantity) { logger.error('Missing metadata in event ticket checkout', { sessionId: session.id }); return; } const paymentIntentId = typeof session.payment_intent === 'string' ? session.payment_intent : (session.payment_intent as { id: string } | null)?.id || null; // Complete the order await prisma.order.update({ where: { id: order.id }, data: { status: 'COMPLETED', stripePaymentIntentId: paymentIntentId, completedAt: new Date(), }, }); // Create tickets try { const { ticketsService } = await import('../ticketed-events/tickets.service'); const { ticketEmailService } = await import('../ticketed-events/ticket-email.service'); const tickets = await ticketsService.createTickets({ eventId, tierId, quantity: parseInt(quantity, 10), holderEmail: buyerEmail || order.buyerEmail, holderName: buyerName || order.buyerName || undefined, userId: userId || order.userId || undefined, orderId: order.id, }); // Fetch event + tier for email const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } }); const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } }); if (event && tier) { for (const ticket of tickets) { ticketEmailService.sendTicketConfirmation({ holderEmail: ticket.holderEmail, holderName: ticket.holderName, ticketCode: ticket.ticketCode, token: (ticket as Record).token as string, eventTitle: event.title, eventDate: event.date, eventStartTime: event.startTime, eventEndTime: event.endTime, eventSlug: event.slug, venueName: event.venueName, venueAddress: event.venueAddress, tierName: tier.name, }).catch(() => {}); } } await this.createAuditLog('event_ticket_purchased', { orderId: order.id, eventId, tierId, ticketCount: tickets.length, amount: order.amountCAD, }); logger.info(`Event ticket order completed: ${order.id}, ${tickets.length} tickets`); } catch (err) { logger.error('Failed to create tickets after checkout:', err); } }, async handleChargeRefunded(charge: Stripe.Charge) { const paymentIntentId = typeof charge.payment_intent === 'string' ? charge.payment_intent : (charge.payment_intent as { id: string } | null)?.id; if (!paymentIntentId) return; // Check orders const order = await prisma.order.findFirst({ where: { stripePaymentIntentId: paymentIntentId }, }); if (order && order.status !== 'REFUNDED') { await prisma.order.update({ where: { id: order.id }, data: { status: 'REFUNDED' }, }); await this.createAuditLog('order_refunded', { orderId: order.id }); // If this was an event ticket order, refund the linked tickets if (order.type === 'event_ticket') { const tickets = await prisma.ticket.findMany({ where: { orderId: order.id, status: 'VALID' }, }); for (const ticket of tickets) { await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'REFUNDED' }, }); await prisma.ticketTier.update({ where: { id: ticket.tierId }, data: { soldCount: { decrement: 1 } }, }); await prisma.ticketedEvent.update({ where: { id: ticket.eventId }, data: { currentAttendees: { decrement: 1 } }, }); } if (tickets.length > 0) { logger.info(`Refunded ${tickets.length} event tickets for order ${order.id}`); } } } // Check payments const payment = await prisma.payment.findFirst({ where: { stripePaymentIntentId: paymentIntentId }, }); if (payment && payment.status !== 'refunded') { await prisma.payment.update({ where: { id: payment.id }, data: { status: 'refunded' }, }); } }, async handleCheckoutExpired(session: Stripe.Checkout.Session) { // Clean up pending orders for expired checkout sessions const order = await prisma.order.findUnique({ where: { stripeCheckoutSessionId: session.id }, }); if (order && order.status === 'PENDING') { await prisma.order.update({ where: { id: order.id }, data: { status: 'FAILED' }, }); logger.info(`Checkout expired, order marked failed: ${order.id}`); } }, async createAuditLog(action: string, metadata: Record) { try { logger.info(`Payment audit: ${action}`, metadata); } catch (err) { logger.error('Failed to create audit log', err); } }, };