Files
Spanglish/backend/src/lib/email/paymentEmails.ts
T
MichilisandClaude Opus 5.5 b51c1360e2 Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price):
null falls back to the ticket price, 0 is a free walk-in. It is set in
the Create/Edit Event modal (EN/ES) and carried through create, update
and duplicate, but it is internal: public event responses and the
attendee-facing ticket/dashboard endpoints drop it, and only admin,
organizer and staff callers receive it.

The door screen no longer trusts the amount the client sends. It sends
a quantity and the server prices the charge from the event record:
walk-ins pay the walk-in price, existing tickets the ticket price. A
typed amount is only honoured with amountOverride from admin/organizer
and is written to audit_logs in the same transaction. The charged
amount stays snapshotted on the payment row.

POS is a new door tender for the physical card terminal. Staff pick
POS, see the amount to key in, charge the card, then confirm with
"Mark as paid"; it is then recorded like any other door payment
(provider and method 'pos'). It can be switched off globally or per
event via payment options, and shows up in the payments filters,
bookings, revenue summaries and door takings. PosChargePanel is where
an automatic terminal push would go later; nothing calls a terminal
today.

tickets.booking_source (online | walk_in | admin) records how a booking
was made. The migration backfills walk-ins from the door screen's
idempotency records; everything else stays online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:10:50 +00:00

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', pos: 'POS' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
};
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,
},
});
}