Merge pull request 'Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.' (#27) from tpago-fix into main

Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
2026-07-20 03:55:01 +00:00
7 changed files with 267 additions and 53 deletions
+15 -4
View File
@@ -5,11 +5,20 @@
// abandoned checkout would otherwise hold those seats forever. This job cancels // abandoned checkout would otherwise hold those seats forever. This job cancels
// pending tickets whose payment is still 'pending' (i.e. never paid and not // pending tickets whose payment is still 'pending' (i.e. never paid and not
// awaiting admin approval) after a configurable TTL, freeing the seats. // awaiting admin approval) after a configurable TTL, freeing the seats.
//
// Two exclusions:
// - Manual-verification providers (bank transfer / TPago / cash) are never
// auto-failed; they are settled by an admin and instead follow the 72h on-hold
// sweep. See holdSweep.ts and MANUAL_PAYMENT_PROVIDERS.
// - Staleness is measured from `updatedAt`, not `createdAt`, so an admin action
// (e.g. reopening a payment to 'pending' via /reopen) restarts the TTL rather
// than being immediately re-failed on the next run.
import { and, eq, lt, inArray } from 'drizzle-orm'; import { and, eq, lt, inArray, notInArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js'; import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js'; import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js'; import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
function getTtlMs(): number { function getTtlMs(): number {
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10); const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
@@ -20,8 +29,9 @@ function getTtlMs(): number {
* Cancel stale pending bookings. Returns the number of tickets cancelled. * Cancel stale pending bookings. Returns the number of tickets cancelled.
* *
* A booking is considered stale when its payment is still 'pending' (not * A booking is considered stale when its payment is still 'pending' (not
* 'pending_approval', which means an admin is reviewing a manual transfer) and * 'pending_approval', which means an admin is reviewing a manual transfer),
* older than PENDING_BOOKING_TTL_MINUTES. * uses a non-manual provider, and has not been touched (updatedAt) for
* PENDING_BOOKING_TTL_MINUTES.
*/ */
export async function cleanupStalePendingBookings(): Promise<number> { export async function cleanupStalePendingBookings(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getTtlMs())); const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
@@ -35,7 +45,8 @@ export async function cleanupStalePendingBookings(): Promise<number> {
.from(payments) .from(payments)
.where(and( .where(and(
eq((payments as any).status, 'pending'), eq((payments as any).status, 'pending'),
lt((payments as any).createdAt, cutoff) notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
lt((payments as any).updatedAt, cutoff)
)) ))
); );
+47 -15
View File
@@ -1,10 +1,15 @@
// Shared capacity-checked recovery for on-hold bookings. // Shared capacity-checked recovery for released bookings.
// //
// When a booking is put on hold, its ticket(s) drop out of the capacity-counting // When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the
// statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. Recovering // capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat.
// an on-hold booking (user "I've paid" again, or an admin reactivating / marking it // Recovering such a booking (user "I've paid" again, or an admin reactivating / marking
// paid) must atomically re-check that the event still has room before re-reserving // it paid / reopening a failed payment) must atomically re-check that the event still has
// the seat, exactly like the original booking-creation flow in routes/tickets.ts. // room before re-reserving the seat, exactly like the original booking-creation flow in
// routes/tickets.ts.
//
// Callers choose which ticket statuses are eligible to be re-reserved via
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and
// tickets that already hold a seat — are left untouched and don't consume capacity.
import { eq, and, inArray, sql } from 'drizzle-orm'; import { eq, and, inArray, sql } from 'drizzle-orm';
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js'; import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
@@ -19,22 +24,29 @@ export class HoldCapacityError extends Error {
interface ReserveOptions { interface ReserveOptions {
paidByAdminId?: string; paidByAdminId?: string;
extraPaymentFields?: Record<string, any>; extraPaymentFields?: Record<string, any>;
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
} }
/** /**
* Re-reserve seats for a group of on-hold tickets (e.g. all tickets sharing a * Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
* bookingId), atomically re-checking capacity before flipping their status. * bookingId), atomically re-checking capacity before flipping their status.
* Throws HoldCapacityError if the event no longer has room for ticketIds.length seats. * Only tickets whose current status is in `fromTicketStatuses` are flipped.
* Capacity is asserted against the number of those tickets that don't already
* hold a seat, so re-reserving tickets that are already seated is a no-op.
* Throws HoldCapacityError if the event no longer has room.
*/ */
export async function reserveOnHoldBooking( export async function reserveOnHoldBooking(
eventId: string, eventId: string,
ticketIds: string[], ticketIds: string[],
targetTicketStatus: 'pending' | 'confirmed', targetTicketStatus: 'pending' | 'confirmed',
targetPaymentStatus: 'pending_approval' | 'paid', targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
options: ReserveOptions = {} options: ReserveOptions = {}
): Promise<void> { ): Promise<void> {
if (ticketIds.length === 0) return; if (ticketIds.length === 0) return;
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
const event = await dbGet<any>( const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId)) (db as any).select().from(events).where(eq((events as any).id, eventId))
); );
@@ -53,12 +65,15 @@ export async function reserveOnHoldBooking(
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId; if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
} }
const assertCapacity = (reserved: number) => { // `needed` is how many of these tickets don't currently hold a seat and so must
// be found new capacity; tickets already in a seat-holding status cost nothing.
const assertCapacity = (reserved: number, needed: number) => {
if (needed <= 0) return;
if (isEventSoldOut(event.capacity, reserved)) { if (isEventSoldOut(event.capacity, reserved)) {
throw new HoldCapacityError(0); throw new HoldCapacityError(0);
} }
const seatsLeft = calculateAvailableSeats(event.capacity, reserved); const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (ticketIds.length > seatsLeft) { if (needed > seatsLeft) {
throw new HoldCapacityError(seatsLeft); throw new HoldCapacityError(seatsLeft);
} }
}; };
@@ -73,13 +88,21 @@ export async function reserveOnHoldBooking(
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)) ))
.get(); .get();
assertCapacity(Number(countRow?.count || 0)); const neededRow = tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
inArray((tickets as any).id, ticketIds),
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
))
.get();
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
tx.update(tickets) tx.update(tickets)
.set({ status: targetTicketStatus }) .set({ status: targetTicketStatus })
.where(and( .where(and(
inArray((tickets as any).id, ticketIds), inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'on_hold') inArray((tickets as any).status, fromTicketStatuses)
)) ))
.run(); .run();
@@ -99,13 +122,22 @@ export async function reserveOnHoldBooking(
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)) ))
); );
assertCapacity(Number(countRow?.count || 0)); const neededRow = await dbGet<any>(
tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
inArray((tickets as any).id, ticketIds),
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
))
);
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
await tx.update(tickets) await tx.update(tickets)
.set({ status: targetTicketStatus }) .set({ status: targetTicketStatus })
.where(and( .where(and(
inArray((tickets as any).id, ticketIds), inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'on_hold') inArray((tickets as any).status, fromTicketStatuses)
)); ));
await tx.update(payments) await tx.update(payments)
+25 -12
View File
@@ -1,17 +1,23 @@
// Auto-hold stale pending-approval bookings. // Auto-hold stale manual-payment bookings.
// //
// A payment enters 'pending_approval' when a user clicks "I've paid" on a manual // This job releases the seat held by an abandoned manual-payment booking (bank
// payment method (bank transfer / TPago) and is waiting for an admin to review it. // transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of
// If no admin acts within HOLD_THRESHOLD_HOURS, this job silently moves the payment // which keep a seat reserved while awaiting a human:
// (and its ticket) to 'on_hold', which drops it out of the capacity-counting statuses // - 'pending_approval': the user clicked "I've paid" and is waiting for an admin.
// ('pending', 'confirmed', 'checked_in') and so releases the seat back to the event. // - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was
// The user receives no notification — they can recover via "I've paid" again, and an // never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts,
// admin can reactivate or mark it paid directly, both re-checking capacity. // so this is their only seat-release path).
// In either case the payment (and its ticket) is silently moved to 'on_hold', which
// drops it out of the capacity-counting statuses ('pending', 'confirmed', 'checked_in')
// and so releases the seat back to the event. The user receives no notification — they
// can recover via "I've paid" again, and an admin can reactivate or mark it paid
// directly, both re-checking capacity.
import { and, eq, lt, inArray } from 'drizzle-orm'; import { and, or, eq, lt, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js'; import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js'; import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js'; import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
function getThresholdMs(): number { function getThresholdMs(): number {
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10); const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
@@ -19,7 +25,8 @@ function getThresholdMs(): number {
} }
/** /**
* Move stale pending-approval payments (and their tickets) to 'on_hold'. * Move stale awaiting-verification payments (and their tickets) to 'on_hold'.
* Covers 'pending_approval' payments and 'pending' payments on manual providers.
* Returns the number of payments put on hold. * Returns the number of payments put on hold.
*/ */
export async function sweepStaleApprovals(): Promise<number> { export async function sweepStaleApprovals(): Promise<number> {
@@ -33,7 +40,13 @@ export async function sweepStaleApprovals(): Promise<number> {
}) })
.from(payments) .from(payments)
.where(and( .where(and(
eq((payments as any).status, 'pending_approval'), or(
eq((payments as any).status, 'pending_approval'),
and(
eq((payments as any).status, 'pending'),
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS])
)
),
lt((payments as any).createdAt, cutoff) lt((payments as any).createdAt, cutoff)
)) ))
); );
@@ -59,7 +72,7 @@ export async function sweepStaleApprovals(): Promise<number> {
)); ));
} }
console.log(`[HoldSweep] Put ${stale.length} stale pending-approval payment(s) on hold.`); console.log(`[HoldSweep] Put ${stale.length} stale awaiting-verification payment(s) on hold.`);
return stale.length; return stale.length;
} }
+7
View File
@@ -0,0 +1,7 @@
// Payment providers that require a human to verify the money arrived.
//
// These methods (bank transfer / TPago / cash) are never auto-confirmed and —
// crucially — are never auto-failed by the stale-booking cleanup: an admin settles
// them by hand. Note this is broader than the set of methods that expose an online
// "I've paid" step (bank transfer / TPago only); cash is settled at the door.
export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const;
+124 -8
View File
@@ -26,6 +26,10 @@ const rejectPaymentSchema = z.object({
sendEmail: z.boolean().optional().default(true), sendEmail: z.boolean().optional().default(true),
}); });
const reopenPaymentSchema = z.object({
adminNote: z.string().optional(),
});
// Get all payments (admin) - with ticket and event details // Get all payments (admin) - with ticket and event details
paymentsRouter.get('/', requireAuth(['admin']), async (c) => { paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status'); const status = c.req.query('status');
@@ -232,10 +236,16 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
} }
// Confirming a failed payment must go through /approve, which re-checks event
// capacity before re-reserving the (previously released) seat. Block the raw path.
if (data.status === 'paid' && existing.status === 'failed') {
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
}
const now = getNow(); const now = getNow();
const updateData: any = { ...data, updatedAt: now }; const updateData: any = { ...data, updatedAt: now };
// If marking as paid, record who approved it and when // If marking as paid, record who approved it and when
if (data.status === 'paid' && existing.status !== 'paid') { if (data.status === 'paid' && existing.status !== 'paid') {
updateData.paidAt = now; updateData.paidAt = now;
@@ -321,8 +331,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
} }
// Can approve pending, pending_approval, or on_hold payments // Can approve pending, pending_approval, on_hold, or failed payments.
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) { // 'failed' covers an admin confirming a payment that was auto-failed or rejected
// in error; its tickets are cancelled, so recovery re-checks capacity below.
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400); return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
} }
@@ -350,16 +362,39 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`); console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
} }
if (payment.status === 'on_hold') { // For a failed payment, only recover tickets whose own payment is also failed.
// The seat was released when this booking went on hold - re-check capacity // This protects mixed bookings (e.g. a sibling ticket was refunded) from being
// before confirming it directly. // resurrected or having its payment flipped to paid.
if (payment.status === 'failed') {
const bookingPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToConfirm.length === 0) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
}
if (payment.status === 'on_hold' || payment.status === 'failed') {
// The seat was released when this booking went on hold or failed - re-check
// capacity before confirming it directly.
try { try {
await reserveOnHoldBooking( await reserveOnHoldBooking(
ticket.eventId, ticket.eventId,
ticketsToConfirm.map((t: any) => t.id), ticketsToConfirm.map((t: any) => t.id),
'confirmed', 'confirmed',
'paid', 'paid',
{ paidByAdminId: user.id } {
paidByAdminId: user.id,
fromTicketStatuses:
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'],
}
); );
} catch (err) { } catch (err) {
if (err instanceof HoldCapacityError) { if (err instanceof HoldCapacityError) {
@@ -548,6 +583,87 @@ paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), asyn
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' }); return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
}); });
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
// For when a payment was failed in error (auto-fail or rejection) and should
// return to the normal pending flow (reminders, "I've paid", approve/reject).
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
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);
}
if (payment.status !== 'failed') {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
let ticketsToReopen: any[] = [ticket];
if (ticket.bookingId) {
ticketsToReopen = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
const bookingPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToReopen.length === 0) {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const reopenIds = ticketsToReopen.map((t: any) => t.id);
try {
await reserveOnHoldBooking(
ticket.eventId,
reopenIds,
'pending',
'pending',
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
if (adminNote) {
await (db as any)
.update(payments)
.set({ adminNote })
.where(inArray((payments as any).ticketId, reopenIds));
}
const updated = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
});
// Send payment reminder email // Send payment reminder email
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
+43 -14
View File
@@ -162,6 +162,22 @@ export default function AdminPaymentsPage() {
} }
}; };
const handleReopen = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.reopen(payment.id, noteText);
toast.success(locale === 'es' ? 'Pago cambiado a pendiente' : 'Payment changed to pending');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'No se pudo reabrir el pago' : 'Failed to reopen payment'));
} finally {
setProcessing(false);
}
};
const handleRefund = async (id: string) => { const handleRefund = async (id: string) => {
if (!confirm('Are you sure you want to process this refund?')) return; if (!confirm('Are you sure you want to process this refund?')) return;
@@ -503,19 +519,32 @@ export default function AdminPaymentsPage() {
</div> </div>
)} )}
<div className="flex gap-3"> {selectedPayment.status === 'failed' ? (
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]"> <div className="flex gap-3">
<CheckCircleIcon className="w-5 h-5 mr-2" /> <Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
{locale === 'es' ? 'Aprobar' : 'Approve'} <CheckCircleIcon className="w-5 h-5 mr-2" />
</Button> {locale === 'es' ? 'Confirmar pago' : 'Confirm payment'}
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing} </Button>
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]"> <Button variant="outline" onClick={() => handleReopen(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<XCircleIcon className="w-5 h-5 mr-2" /> <ArrowPathIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'} {locale === 'es' ? 'Cambiar a pendiente' : 'Change to pending'}
</Button> </Button>
</div> </div>
) : (
<div className="flex gap-3">
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Aprobar' : 'Approve'}
</Button>
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
<XCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'}
</Button>
</div>
)}
{selectedPayment.status !== 'on_hold' && ( {!['on_hold', 'failed'].includes(selectedPayment.status) && (
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]"> <Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
<EnvelopeIcon className="w-5 h-5 mr-2" /> <EnvelopeIcon className="w-5 h-5 mr-2" />
@@ -980,7 +1009,7 @@ export default function AdminPaymentsPage() {
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td> <td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && ( {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold' || payment.status === 'failed') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1"> <Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
@@ -1042,7 +1071,7 @@ export default function AdminPaymentsPage() {
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100"> <div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p> <p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && ( {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold' || payment.status === 'failed') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]"> <Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
+6
View File
@@ -51,4 +51,10 @@ export const paymentsApi = {
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, { fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, {
method: 'POST', method: 'POST',
}), }),
reopen: (id: string, adminNote?: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reopen`, {
method: 'POST',
body: JSON.stringify({ adminNote }),
}),
}; };