- 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
524 lines
18 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|