feat: add payment management improvements and reminder emails
- Add option to approve/reject payments without sending notification emails (checkbox in review popup, default enabled) - Add payment reminder email template and send functionality - Track when reminder emails are sent (reminderSentAt field) - Display reminder sent timestamp in payment review popup - Make payment review popup scrollable for better UX - Add payment-reminder template to email system (available in admin emails)
This commit is contained in:
@@ -209,6 +209,9 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN payer_name TEXT`);
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN payer_name TEXT`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
@@ -577,6 +580,9 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN payer_name VARCHAR(255)`);
|
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN payer_name VARCHAR(255)`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export const sqlitePayments = sqliteTable('payments', {
|
|||||||
paidAt: text('paid_at'),
|
paidAt: text('paid_at'),
|
||||||
paidByAdminId: text('paid_by_admin_id'),
|
paidByAdminId: text('paid_by_admin_id'),
|
||||||
adminNote: text('admin_note'), // Internal admin notes
|
adminNote: text('admin_note'), // Internal admin notes
|
||||||
|
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -405,6 +406,7 @@ export const pgPayments = pgTable('payments', {
|
|||||||
paidAt: timestamp('paid_at'),
|
paidAt: timestamp('paid_at'),
|
||||||
paidByAdminId: uuid('paid_by_admin_id'),
|
paidByAdminId: uuid('paid_by_admin_id'),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
|
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -980,6 +980,106 @@ export const emailService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment reminder email
|
||||||
|
* This email is sent when admin wants to remind attendee about pending payment
|
||||||
|
*/
|
||||||
|
async 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
|
||||||
|
? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
||||||
|
: this.formatCurrency(totalPrice, event.currency);
|
||||||
|
|
||||||
|
// Get site timezone for proper date/time formatting
|
||||||
|
const timezone = await this.getSiteTimezone();
|
||||||
|
|
||||||
|
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
|
||||||
|
|
||||||
|
return this.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: this.formatDate(event.startDatetime, locale, timezone),
|
||||||
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||||
|
eventLocation: event.location,
|
||||||
|
eventLocationUrl: event.locationUrl || '',
|
||||||
|
paymentAmount: amountDisplay,
|
||||||
|
bookingUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send custom email to event attendees
|
* Send custom email to event attendees
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -991,6 +991,118 @@ Spanglish`,
|
|||||||
],
|
],
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Payment Reminder',
|
||||||
|
slug: 'payment-reminder',
|
||||||
|
subject: 'Reminder: Complete your payment for Spanglish',
|
||||||
|
subjectEs: 'Recordatorio: Completa tu pago para Spanglish',
|
||||||
|
bodyHtml: `
|
||||||
|
<h2>Payment Reminder</h2>
|
||||||
|
<p>Hi {{attendeeName}},</p>
|
||||||
|
<p>We wanted to follow up on your booking for <strong>{{eventTitle}}</strong>.</p>
|
||||||
|
<p>We haven't been able to locate your payment yet. To receive your ticket and confirm your spot, please complete your payment.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Event Details</h3>
|
||||||
|
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
|
||||||
|
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
|
||||||
|
<div class="event-detail"><strong>Amount Due:</strong> {{paymentAmount}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin: 24px 0;">
|
||||||
|
<a href="{{bookingUrl}}" class="btn">Complete Payment</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Already paid?</strong><br>
|
||||||
|
If you have already completed your payment and believe this is an error, please reply to this email with your payment details (date, amount, and method used) and we'll be happy to look into it.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>We hope to see you at the event!</p>
|
||||||
|
<p>The Spanglish Team</p>
|
||||||
|
`,
|
||||||
|
bodyHtmlEs: `
|
||||||
|
<h2>Recordatorio de Pago</h2>
|
||||||
|
<p>Hola {{attendeeName}},</p>
|
||||||
|
<p>Queríamos dar seguimiento a tu reserva para <strong>{{eventTitle}}</strong>.</p>
|
||||||
|
<p>Aún no hemos podido localizar tu pago. Para recibir tu entrada y confirmar tu lugar, por favor completa tu pago.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Detalles del Evento</h3>
|
||||||
|
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
|
||||||
|
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
|
||||||
|
<div class="event-detail"><strong>Monto a Pagar:</strong> {{paymentAmount}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin: 24px 0;">
|
||||||
|
<a href="{{bookingUrl}}" class="btn">Completar Pago</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>¿Ya pagaste?</strong><br>
|
||||||
|
Si ya completaste tu pago y crees que esto es un error, por favor responde a este correo con los detalles de tu pago (fecha, monto y método utilizado) y con gusto lo revisaremos.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>¡Esperamos verte en el evento!</p>
|
||||||
|
<p>El Equipo de Spanglish</p>
|
||||||
|
`,
|
||||||
|
bodyText: `Payment Reminder
|
||||||
|
|
||||||
|
Hi {{attendeeName}},
|
||||||
|
|
||||||
|
We wanted to follow up on your booking for {{eventTitle}}.
|
||||||
|
|
||||||
|
We haven't been able to locate your payment yet. To receive your ticket and confirm your spot, please complete your payment.
|
||||||
|
|
||||||
|
Event Details:
|
||||||
|
- Event: {{eventTitle}}
|
||||||
|
- Date: {{eventDate}}
|
||||||
|
- Time: {{eventTime}}
|
||||||
|
- Location: {{eventLocation}}
|
||||||
|
- Amount Due: {{paymentAmount}}
|
||||||
|
|
||||||
|
Complete your payment here: {{bookingUrl}}
|
||||||
|
|
||||||
|
Already paid?
|
||||||
|
If you have already completed your payment and believe this is an error, please reply to this email with your payment details (date, amount, and method used) and we'll be happy to look into it.
|
||||||
|
|
||||||
|
We hope to see you at the event!
|
||||||
|
The Spanglish Team`,
|
||||||
|
bodyTextEs: `Recordatorio de Pago
|
||||||
|
|
||||||
|
Hola {{attendeeName}},
|
||||||
|
|
||||||
|
Queríamos dar seguimiento a tu reserva para {{eventTitle}}.
|
||||||
|
|
||||||
|
Aún no hemos podido localizar tu pago. Para recibir tu entrada y confirmar tu lugar, por favor completa tu pago.
|
||||||
|
|
||||||
|
Detalles del Evento:
|
||||||
|
- Evento: {{eventTitle}}
|
||||||
|
- Fecha: {{eventDate}}
|
||||||
|
- Hora: {{eventTime}}
|
||||||
|
- Ubicación: {{eventLocation}}
|
||||||
|
- Monto a Pagar: {{paymentAmount}}
|
||||||
|
|
||||||
|
Completa tu pago aquí: {{bookingUrl}}
|
||||||
|
|
||||||
|
¿Ya pagaste?
|
||||||
|
Si ya completaste tu pago y crees que esto es un error, por favor responde a este correo con los detalles de tu pago (fecha, monto y método utilizado) y con gusto lo revisaremos.
|
||||||
|
|
||||||
|
¡Esperamos verte en el evento!
|
||||||
|
El Equipo de Spanglish`,
|
||||||
|
description: 'Sent to remind attendees to complete their pending payment',
|
||||||
|
variables: [
|
||||||
|
...commonVariables,
|
||||||
|
...bookingVariables,
|
||||||
|
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
|
||||||
|
{ name: 'bookingUrl', description: 'URL to complete payment', example: 'https://spanglish.com/booking/abc123?step=payment' },
|
||||||
|
],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Payment Rejected',
|
name: 'Payment Rejected',
|
||||||
slug: 'payment-rejected',
|
slug: 'payment-rejected',
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ const updatePaymentSchema = z.object({
|
|||||||
|
|
||||||
const approvePaymentSchema = z.object({
|
const approvePaymentSchema = z.object({
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
|
sendEmail: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const rejectPaymentSchema = z.object({
|
const rejectPaymentSchema = z.object({
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
|
sendEmail: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all payments (admin) - with ticket and event details
|
// Get all payments (admin) - with ticket and event details
|
||||||
@@ -266,7 +268,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
// Approve payment (admin) - specifically for pending_approval payments
|
// Approve payment (admin) - specifically for pending_approval payments
|
||||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const { adminNote } = c.req.valid('json');
|
const { adminNote, sendEmail } = c.req.valid('json');
|
||||||
const user = (c as any).get('user');
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
@@ -329,13 +331,17 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
.where(eq((tickets as any).id, (t as any).id));
|
.where(eq((tickets as any).id, (t as any).id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation emails asynchronously
|
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||||
Promise.all([
|
if (sendEmail !== false) {
|
||||||
emailService.sendBookingConfirmation(payment.ticketId),
|
Promise.all([
|
||||||
emailService.sendPaymentReceipt(id),
|
emailService.sendBookingConfirmation(payment.ticketId),
|
||||||
]).catch(err => {
|
emailService.sendPaymentReceipt(id),
|
||||||
console.error('[Email] Failed to send confirmation emails:', err);
|
]).catch(err => {
|
||||||
});
|
console.error('[Email] Failed to send confirmation emails:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('[Payment] Skipping confirmation emails per admin request');
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await dbGet(
|
const updated = await dbGet(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -350,7 +356,7 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
// Reject payment (admin)
|
// Reject payment (admin)
|
||||||
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
|
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const { adminNote } = c.req.valid('json');
|
const { adminNote, sendEmail } = c.req.valid('json');
|
||||||
const user = (c as any).get('user');
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
@@ -390,11 +396,13 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
|||||||
})
|
})
|
||||||
.where(eq((tickets as any).id, payment.ticketId));
|
.where(eq((tickets as any).id, payment.ticketId));
|
||||||
|
|
||||||
// Send rejection email asynchronously (for manual payment methods only)
|
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
|
||||||
if (['bank_transfer', 'tpago'].includes(payment.provider)) {
|
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||||
emailService.sendPaymentRejectionEmail(id).catch(err => {
|
emailService.sendPaymentRejectionEmail(id).catch(err => {
|
||||||
console.error('[Email] Failed to send payment rejection email:', err);
|
console.error('[Email] Failed to send payment rejection email:', err);
|
||||||
});
|
});
|
||||||
|
} else if (sendEmail === false) {
|
||||||
|
console.log('[Payment] Skipping rejection email per admin request');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await dbGet(
|
const updated = await dbGet(
|
||||||
@@ -407,6 +415,51 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
|||||||
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send payment reminder email
|
||||||
|
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const payment = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow sending reminders for pending payments
|
||||||
|
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||||
|
return c.json({ error: 'Payment reminder can only be sent for pending payments' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await emailService.sendPaymentReminder(id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Record when reminder was sent
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
reminderSentAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).id, id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Payment reminder sent successfully', reminderSentAt: now });
|
||||||
|
} else {
|
||||||
|
return c.json({ error: result.error || 'Failed to send payment reminder' }, 500);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Payment] Failed to send payment reminder:', err);
|
||||||
|
return c.json({ error: 'Failed to send payment reminder' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update admin note
|
// Update admin note
|
||||||
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
|
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
|||||||
@@ -473,8 +473,8 @@ export default function BookingPage() {
|
|||||||
paymentMethods.push({
|
paymentMethods.push({
|
||||||
id: 'tpago',
|
id: 'tpago',
|
||||||
icon: CreditCardIcon,
|
icon: CreditCardIcon,
|
||||||
label: locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card',
|
label: locale === 'es' ? 'TPago / Tarjetas de Crédito' : 'TPago / Credit Cards',
|
||||||
description: locale === 'es' ? 'Paga con tarjeta de crédito o débito' : 'Pay with credit or debit card',
|
description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
|
||||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
badge: locale === 'es' ? 'Manual' : 'Manual',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -483,8 +483,8 @@ export default function BookingPage() {
|
|||||||
paymentMethods.push({
|
paymentMethods.push({
|
||||||
id: 'bank_transfer',
|
id: 'bank_transfer',
|
||||||
icon: BuildingLibraryIcon,
|
icon: BuildingLibraryIcon,
|
||||||
label: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
label: locale === 'es' ? 'Transferencia Bancaria Local' : 'Local Bank Transfer',
|
||||||
description: locale === 'es' ? 'Transferencia bancaria local' : 'Local bank transfer',
|
description: locale === 'es' ? 'Pago por transferencia bancaria en Paraguay' : 'Pay via Paraguayan bank transfer',
|
||||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
badge: locale === 'es' ? 'Manual' : 'Manual',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
BuildingLibraryIcon,
|
BuildingLibraryIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@ export default function AdminPaymentsPage() {
|
|||||||
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
|
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [sendEmail, setSendEmail] = useState(true);
|
||||||
|
const [sendingReminder, setSendingReminder] = useState(false);
|
||||||
|
|
||||||
// Export state
|
// Export state
|
||||||
const [showExportModal, setShowExportModal] = useState(false);
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
@@ -77,10 +80,11 @@ export default function AdminPaymentsPage() {
|
|||||||
const handleApprove = async (payment: PaymentWithDetails) => {
|
const handleApprove = async (payment: PaymentWithDetails) => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(payment.id, noteText);
|
await paymentsApi.approve(payment.id, noteText, sendEmail);
|
||||||
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
||||||
setSelectedPayment(null);
|
setSelectedPayment(null);
|
||||||
setNoteText('');
|
setNoteText('');
|
||||||
|
setSendEmail(true);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to approve payment');
|
toast.error(error.message || 'Failed to approve payment');
|
||||||
@@ -92,10 +96,11 @@ export default function AdminPaymentsPage() {
|
|||||||
const handleReject = async (payment: PaymentWithDetails) => {
|
const handleReject = async (payment: PaymentWithDetails) => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
await paymentsApi.reject(payment.id, noteText);
|
await paymentsApi.reject(payment.id, noteText, sendEmail);
|
||||||
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
|
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
|
||||||
setSelectedPayment(null);
|
setSelectedPayment(null);
|
||||||
setNoteText('');
|
setNoteText('');
|
||||||
|
setSendEmail(true);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to reject payment');
|
toast.error(error.message || 'Failed to reject payment');
|
||||||
@@ -104,6 +109,24 @@ export default function AdminPaymentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendReminder = async (payment: PaymentWithDetails) => {
|
||||||
|
setSendingReminder(true);
|
||||||
|
try {
|
||||||
|
const result = await paymentsApi.sendReminder(payment.id);
|
||||||
|
toast.success(locale === 'es' ? 'Recordatorio enviado' : 'Reminder sent');
|
||||||
|
// Update the selected payment with the new reminderSentAt timestamp
|
||||||
|
if (result.reminderSentAt) {
|
||||||
|
setSelectedPayment({ ...payment, reminderSentAt: result.reminderSentAt });
|
||||||
|
}
|
||||||
|
// Also refresh the data to update the lists
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to send reminder');
|
||||||
|
} finally {
|
||||||
|
setSendingReminder(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirmPayment = async (id: string) => {
|
const handleConfirmPayment = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(id);
|
await paymentsApi.approve(id);
|
||||||
@@ -317,7 +340,7 @@ export default function AdminPaymentsPage() {
|
|||||||
const modalBookingInfo = getBookingInfo(selectedPayment);
|
const modalBookingInfo = getBookingInfo(selectedPayment);
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-lg p-6">
|
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
||||||
<h2 className="text-xl font-bold mb-4">
|
<h2 className="text-xl font-bold mb-4">
|
||||||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -374,6 +397,13 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedPayment.reminderSentAt && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-600">
|
||||||
|
<EnvelopeIcon className="w-4 h-4" />
|
||||||
|
{locale === 'es' ? 'Recordatorio enviado:' : 'Reminder sent:'} {formatDate(selectedPayment.reminderSentAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedPayment.payerName && (
|
{selectedPayment.payerName && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
<p className="text-sm text-amber-800 font-medium">
|
<p className="text-sm text-amber-800 font-medium">
|
||||||
@@ -395,6 +425,19 @@ export default function AdminPaymentsPage() {
|
|||||||
placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
|
placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="sendEmail"
|
||||||
|
checked={sendEmail}
|
||||||
|
onChange={(e) => setSendEmail(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
<label htmlFor="sendEmail" className="text-sm text-gray-700">
|
||||||
|
{locale === 'es' ? 'Enviar email de notificación' : 'Send notification email'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -417,8 +460,20 @@ export default function AdminPaymentsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleSendReminder(selectedPayment)}
|
||||||
|
isLoading={sendingReminder}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedPayment(null); setNoteText(''); }}
|
onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
||||||
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
|
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
|
|||||||
@@ -218,16 +218,21 @@ export const paymentsApi = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
approve: (id: string, adminNote?: string) =>
|
approve: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
||||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ adminNote }),
|
body: JSON.stringify({ adminNote, sendEmail }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reject: (id: string, adminNote?: string) =>
|
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
||||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ adminNote }),
|
body: JSON.stringify({ adminNote, sendEmail }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendReminder: (id: string) =>
|
||||||
|
fetchApi<{ message: string; reminderSentAt?: string }>(`/api/payments/${id}/send-reminder`, {
|
||||||
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateNote: (id: string, adminNote: string) =>
|
updateNote: (id: string, adminNote: string) =>
|
||||||
@@ -502,6 +507,7 @@ export interface Payment {
|
|||||||
paidAt?: string;
|
paidAt?: string;
|
||||||
paidByAdminId?: string;
|
paidByAdminId?: string;
|
||||||
adminNote?: string;
|
adminNote?: string;
|
||||||
|
reminderSentAt?: string; // When payment reminder email was sent
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user