changemaker.lite/api/src/modules/payments/webhook.service.ts
bunker-admin 08d8066157 Add ticketed events, Jitsi meeting integration, social features, and calendar system
- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout,
  QR-based check-in scanner, public event pages, ticket confirmation emails
- Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle,
  ticket-gated meeting access, moderator JWT tokens, feature-flag guarded
- Social engagement: challenges with scoring/leaderboards, referral tracking,
  volunteer spotlight, impact stories, campaign celebrations, wall of fame
- Social calendar: personal calendar layers, shared calendar items with
  recurrence, scheduling polls, mobile day view
- MCP server: events tool pack with full admin CRUD + meeting token generation
- Unified calendar: eventFormat-aware tags, online event indicators
- Updated docs site, pangolin configs, and various admin UI improvements

Bunker Admin
2026-03-06 14:33:33 -07:00

524 lines
18 KiB
TypeScript

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<string, unknown>;
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<Stripe.Event> {
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<void> {
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<string, unknown> = {
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<string, unknown>).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<string, unknown>) {
try {
logger.info(`Payment audit: ${action}`, metadata);
} catch (err) {
logger.error('Failed to create audit log', err);
}
},
};