206 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
};
|