Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.
Exclude manual providers from booking cleanup, hold them via the 72h sweep instead, and let admins reopen failed payments to pending. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user