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>
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', 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,
|
|
},
|
|
});
|
|
}
|