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>
475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
// 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,
|
|
},
|
|
});
|
|
}
|