changemaker.lite/api/src/modules/payments/donations.service.ts

206 lines
7.1 KiB
TypeScript

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<string, unknown> = { type: 'donation' };
if (search) {
(where as Record<string, unknown>).OR = [
{ buyerEmail: { contains: search, mode: 'insensitive' } },
{ buyerName: { contains: search, mode: 'insensitive' } },
];
}
if (donationPageId === 'general') {
(where as Record<string, unknown>).donationPageId = null;
} else if (donationPageId) {
(where as Record<string, unknown>).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<string, unknown> = { type: 'donation' };
if (filters.status) {
(where as Record<string, unknown>).status = filters.status;
}
if (filters.search) {
(where as Record<string, unknown>).OR = [
{ buyerEmail: { contains: filters.search, mode: 'insensitive' } },
{ buyerName: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.donationPageId === 'general') {
(where as Record<string, unknown>).donationPageId = null;
} else if (filters.donationPageId) {
(where as Record<string, unknown>).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,
};
},
};