// 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( (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( (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( (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( (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((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> = { 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> { // Get global options const globalOptions = await dbGet( (db as any) .select() .from(paymentOptions) ); // Get event overrides const overrides = await dbGet( (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( (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( (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( (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( (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 = { 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( (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( (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( (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( (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( (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( (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( (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, }, }); }