import { prisma } from '../../config/database'; import { getStripe } from '../../services/stripe.client'; import { env } from '../../config/env'; import { paymentSettingsService } from './payment-settings.service'; import { stringify } from 'csv-stringify/sync'; import { logger } from '../../utils/logger'; export const donationsService = { /** Create a Stripe Checkout session for a donation */ async createDonationCheckout( amountCents: number, email: string, name?: string, message?: string, isAnonymous?: boolean, donationPageId?: string, donationPageSlug?: string, donationPageTitle?: string, ) { const settings = await paymentSettingsService.get(); if (!settings.enableDonations) throw new Error('Donations are currently disabled'); // Use page-specific minimum if provided via page route, otherwise global if (!donationPageId && amountCents < settings.donationMinimum) { throw new Error(`Minimum donation is $${(settings.donationMinimum / 100).toFixed(2)}`); } const stripe = await getStripe(); const productName = donationPageTitle ? `Donation — ${donationPageTitle}` : 'Donation'; const cancelUrl = donationPageSlug ? `${env.ADMIN_URL}/donate/${donationPageSlug}` : `${env.ADMIN_URL}/donate`; const session = await stripe.checkout.sessions.create({ mode: 'payment', line_items: [{ price_data: { currency: settings.defaultCurrency || 'cad', product_data: { name: productName, description: donationPageTitle || settings.donationPageTitle || 'Support Our Work', }, unit_amount: amountCents, }, quantity: 1, }], customer_email: email, success_url: `${env.ADMIN_URL}/payments/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: cancelUrl, metadata: { type: 'donation', email, name: name || '', message: message || '', isAnonymous: isAnonymous ? 'true' : 'false', donationPageId: donationPageId || '', }, }); // Create pending order await prisma.order.create({ data: { amountCAD: amountCents, status: 'PENDING', stripeCheckoutSessionId: session.id, type: 'donation', buyerEmail: email, buyerName: name || null, donorMessage: message || null, isAnonymous: isAnonymous || false, donationPageId: donationPageId || null, }, }); return { sessionId: session.id, url: session.url }; }, /** List donations (admin) */ async listDonations(filters: { page: number; limit: number; search?: string; donationPageId?: string }) { const { page, limit, search, donationPageId } = filters; const where: Record = { type: 'donation' }; if (search) { (where as Record).OR = [ { buyerEmail: { contains: search, mode: 'insensitive' } }, { buyerName: { contains: search, mode: 'insensitive' } }, ]; } if (donationPageId === 'general') { (where as Record).donationPageId = null; } else if (donationPageId) { (where as Record).donationPageId = donationPageId; } const [orders, total] = await Promise.all([ prisma.order.findMany({ where: where as import('@prisma/client').Prisma.OrderWhereInput, skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' }, include: { donationPage: { select: { id: true, title: true, slug: true } } }, }), prisma.order.count({ where: where as import('@prisma/client').Prisma.OrderWhereInput }), ]); return { donations: orders, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }; }, /** Refund a donation via Stripe */ async refundDonation(orderId: string, reason?: string) { const order = await prisma.order.findUnique({ where: { id: orderId } }); if (!order) throw new Error('Donation not found'); if (order.type !== 'donation') throw new Error('Order is not a donation'); if (order.status !== 'COMPLETED') throw new Error('Only completed donations can be refunded'); if (!order.stripePaymentIntentId) throw new Error('No Stripe payment intent found for this donation'); const stripe = await getStripe(); await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId, reason: 'requested_by_customer', metadata: { admin_reason: reason || 'Admin-initiated refund', order_id: orderId, }, }); const updated = await prisma.order.update({ where: { id: orderId }, data: { status: 'REFUNDED' }, }); logger.info(`Donation refunded: ${orderId}, $${(order.amountCAD / 100).toFixed(2)}`, { orderId, reason: reason || 'No reason provided', }); return updated; }, /** Export donations to CSV */ async exportToCsv(filters: { search?: string; status?: string; donationPageId?: string }) { const where: Record = { type: 'donation' }; if (filters.status) { (where as Record).status = filters.status; } if (filters.search) { (where as Record).OR = [ { buyerEmail: { contains: filters.search, mode: 'insensitive' } }, { buyerName: { contains: filters.search, mode: 'insensitive' } }, ]; } if (filters.donationPageId === 'general') { (where as Record).donationPageId = null; } else if (filters.donationPageId) { (where as Record).donationPageId = filters.donationPageId; } const orders = await prisma.order.findMany({ where: where as import('@prisma/client').Prisma.OrderWhereInput, orderBy: { createdAt: 'desc' }, include: { donationPage: { select: { title: true } } }, }); return stringify(orders.map((o) => ({ 'Date': o.createdAt.toISOString(), 'Donor Name': o.isAnonymous ? 'Anonymous' : (o.buyerName || ''), 'Donor Email': o.isAnonymous ? '' : (o.buyerEmail || ''), 'Amount (CAD)': (o.amountCAD / 100).toFixed(2), 'Status': o.status, 'Donation Page': o.donationPage?.title || 'General', 'Message': o.donorMessage || '', 'Anonymous': o.isAnonymous ? 'Yes' : 'No', 'Stripe Payment Intent': o.stripePaymentIntentId || '', 'Stripe Checkout Session': o.stripeCheckoutSessionId || '', 'Completed At': o.completedAt ? o.completedAt.toISOString() : '', 'Order ID': o.id, })), { header: true }); }, /** Get donation stats */ async getDonationStats() { const [totalDonations, totalAmount, recentDonations] = await Promise.all([ prisma.order.count({ where: { type: 'donation', status: 'COMPLETED' } }), prisma.order.aggregate({ where: { type: 'donation', status: 'COMPLETED' }, _sum: { amountCAD: true }, }), prisma.order.findMany({ where: { type: 'donation', status: 'COMPLETED' }, orderBy: { createdAt: 'desc' }, take: 5, }), ]); return { totalDonations, totalAmount: totalAmount._sum.amountCAD || 0, recentDonations, }; }, };