Refactor monolithic modules and harden booking, email, and auth infrastructure.
Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
// High-level payment-related email senders and payment config resolution.
|
||||
|
||||
import { db, dbGet, dbAll, events, tickets, payments, paymentOptions, eventPaymentOverrides } from '../../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendTemplateEmail } from './templateService.js';
|
||||
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
|
||||
|
||||
/**
|
||||
* Send payment receipt email
|
||||
*/
|
||||
export async function sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment with ticket and event info
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Calculate total amount for multi-ticket bookings
|
||||
let totalAmount = payment.amount;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
// Get all payments for this booking
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
|
||||
ticketCount = bookingTickets.length;
|
||||
|
||||
// Sum up all payment amounts for the booking
|
||||
const bookingPayments = await Promise.all(
|
||||
bookingTickets.map((t: any) =>
|
||||
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
|
||||
)
|
||||
);
|
||||
|
||||
totalAmount = bookingPayments
|
||||
.filter((p: any) => p)
|
||||
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
|
||||
};
|
||||
|
||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
|
||||
: formatCurrency(totalAmount, payment.currency);
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug: 'payment-receipt',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: receiptFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: receiptFullName,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
paymentAmount: amountDisplay,
|
||||
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
||||
paymentReference: payment.reference || payment.id,
|
||||
paymentDate: formatDate(payment.paidAt || payment.createdAt, locale, timezone),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged payment configuration for an event (global + overrides)
|
||||
*/
|
||||
export async function getPaymentConfig(eventId: string): Promise<Record<string, any>> {
|
||||
// Get global options
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
);
|
||||
|
||||
// Get event overrides
|
||||
const overrides = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
);
|
||||
|
||||
// Defaults
|
||||
const defaults = {
|
||||
tpagoEnabled: false,
|
||||
tpagoLink: null,
|
||||
tpagoLink2: null,
|
||||
tpagoLink3: null,
|
||||
tpagoLink4: null,
|
||||
tpagoLink5: null,
|
||||
tpagoInstructions: null,
|
||||
tpagoInstructionsEs: null,
|
||||
bankTransferEnabled: false,
|
||||
bankName: null,
|
||||
bankAccountHolder: null,
|
||||
bankAccountNumber: null,
|
||||
bankAlias: null,
|
||||
bankPhone: null,
|
||||
bankNotes: null,
|
||||
bankNotesEs: null,
|
||||
};
|
||||
|
||||
const global = globalOptions || defaults;
|
||||
|
||||
// Merge: override values take precedence if they're not null/undefined
|
||||
return {
|
||||
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
||||
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
||||
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
|
||||
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
|
||||
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
|
||||
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
|
||||
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
||||
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
||||
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
||||
bankName: overrides?.bankName ?? global.bankName,
|
||||
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
|
||||
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
|
||||
bankAlias: overrides?.bankAlias ?? global.bankAlias,
|
||||
bankPhone: overrides?.bankPhone ?? global.bankPhone,
|
||||
bankNotes: overrides?.bankNotes ?? global.bankNotes,
|
||||
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment instructions email (for TPago or Bank Transfer)
|
||||
* This email is sent immediately after user clicks "Continue to Payment"
|
||||
*/
|
||||
export async function sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Get payment
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Only send for manual payment methods
|
||||
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
|
||||
}
|
||||
|
||||
// Get merged payment config for this event
|
||||
const paymentConfig = await getPaymentConfig(event.id);
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Calculate total price for multi-ticket bookings
|
||||
let totalPrice = event.price;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
// Count all tickets in this booking
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
ticketCount = bookingTickets.length;
|
||||
totalPrice = event.price * ticketCount;
|
||||
}
|
||||
|
||||
// Generate a payment reference using booking ID or ticket ID
|
||||
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
|
||||
|
||||
// Generate the booking URL for returning to payment page
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
||||
|
||||
// Determine which template to use
|
||||
const templateSlug = payment.provider === 'tpago'
|
||||
? 'payment-instructions-tpago'
|
||||
: 'payment-instructions-bank-transfer';
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
||||
: formatCurrency(totalPrice, event.currency);
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
// Build variables based on payment method
|
||||
const variables: Record<string, any> = {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
paymentAmount: amountDisplay,
|
||||
paymentReference,
|
||||
bookingUrl,
|
||||
};
|
||||
|
||||
// Add payment-method specific variables
|
||||
if (payment.provider === 'tpago') {
|
||||
// Select the TPago link matching the number of tickets (1-5), falling back to the base link
|
||||
const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`;
|
||||
variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || '';
|
||||
} else {
|
||||
// Bank transfer
|
||||
variables.bankName = paymentConfig.bankName || '';
|
||||
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
|
||||
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
|
||||
variables.bankAlias = paymentConfig.bankAlias || '';
|
||||
variables.bankPhone = paymentConfig.bankPhone || '';
|
||||
}
|
||||
|
||||
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment rejection email
|
||||
* This email is sent when admin rejects a TPago or Bank Transfer payment
|
||||
*/
|
||||
export async function sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Get ticket
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Generate a new booking URL for the event
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug: 'payment-rejected',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
newBookingUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment reminder email
|
||||
* This email is sent when admin wants to remind attendee about pending payment
|
||||
*/
|
||||
export async function sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Only send for pending/pending_approval payments
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
|
||||
}
|
||||
|
||||
// Get ticket
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Calculate total price for multi-ticket bookings
|
||||
let totalPrice = event.price;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
ticketCount = bookingTickets.length;
|
||||
totalPrice = event.price * ticketCount;
|
||||
}
|
||||
|
||||
// Generate the booking URL for returning to payment page
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
||||
: formatCurrency(totalPrice, event.currency);
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug: 'payment-reminder',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
paymentAmount: amountDisplay,
|
||||
bookingUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user