Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4772b85f3d | ||
|
|
0d47156071 |
@@ -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,6 +236,12 @@ 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 };
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -31,11 +31,19 @@ const createTicketSchema = z.object({
|
|||||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
// 'bancard' intentionally excluded: no checkout integration exists for it
|
||||||
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
// Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save
|
||||||
|
ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||||
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
||||||
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only
|
||||||
|
function normalizeRuc(ruc: string | undefined): string | null {
|
||||||
|
if (!ruc) return null;
|
||||||
|
if (ruc.includes('-')) return ruc;
|
||||||
|
return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Maps a payment provider to the merged payment-option flag that enables it
|
// Maps a payment provider to the merged payment-option flag that enables it
|
||||||
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
||||||
const truthy = (v: any) => v === true || v === 1;
|
const truthy = (v: any) => v === true || v === 1;
|
||||||
@@ -76,6 +84,7 @@ const adminCreateTicketSchema = z.object({
|
|||||||
// Book a ticket (public) - supports single or multi-ticket bookings
|
// Book a ticket (public) - supports single or multi-ticket bookings
|
||||||
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
const rucNumber = normalizeRuc(data.ruc);
|
||||||
|
|
||||||
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
||||||
const attendeesList = data.attendees && data.attendees.length > 0
|
const attendeesList = data.attendees && data.attendees.length > 0
|
||||||
@@ -168,19 +177,19 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
languagePreference: null,
|
languagePreference: null,
|
||||||
rucNumber: data.ruc || null,
|
rucNumber,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await (db as any).insert(users).values(user);
|
await (db as any).insert(users).values(user);
|
||||||
} else if (data.ruc) {
|
} else if (rucNumber) {
|
||||||
// Keep the user's saved RUC up to date for future bookings, but never blank
|
// Keep the user's saved RUC up to date for future bookings, but never blank
|
||||||
// out an existing value if this booking didn't include one.
|
// out an existing value if this booking didn't include one.
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ rucNumber: data.ruc, updatedAt: now })
|
.set({ rucNumber, updatedAt: now })
|
||||||
.where(eq((users as any).id, user.id));
|
.where(eq((users as any).id, user.id));
|
||||||
user.rucNumber = data.ruc;
|
user.rucNumber = rucNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||||
@@ -253,7 +262,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
attendeeEmail: data.email,
|
attendeeEmail: data.email,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
attendeeRuc: data.ruc || null,
|
attendeeRuc: rucNumber,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
qrCode,
|
qrCode,
|
||||||
@@ -314,7 +323,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
attendeeEmail: data.email,
|
attendeeEmail: data.email,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
attendeeRuc: data.ruc || null,
|
attendeeRuc: rucNumber,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
qrCode,
|
qrCode,
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { PaymentMethod, BookingResult } from '../_types';
|
import type { PaymentMethod, BookingResult } from '../_types';
|
||||||
|
|
||||||
export const rucPattern = /^\d{6,10}$/;
|
// Paraguayan RUC: 5-8 digit base + "-" + 1 check digit (DV), e.g. 1234567-9 or 80012345-0
|
||||||
|
export const rucPattern = /^\d{5,8}-\d$/;
|
||||||
|
|
||||||
/** Format RUC input: digits only, max 10. */
|
/** Sanitize RUC input: digits and a single user-typed dash, max 10 chars. No dash is auto-inserted. */
|
||||||
export function formatRuc(value: string): string {
|
export function formatRuc(value: string): string {
|
||||||
return value.replace(/\D/g, '').slice(0, 10);
|
const cleaned = value.replace(/[^\d-]/g, '');
|
||||||
|
const firstDash = cleaned.indexOf('-');
|
||||||
|
const oneDash = firstDash === -1
|
||||||
|
? cleaned
|
||||||
|
: cleaned.slice(0, firstDash + 1) + cleaned.slice(firstDash + 1).replace(/-/g, '');
|
||||||
|
return oneDash.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Truncate a long invoice string for display. */
|
/** Truncate a long invoice string for display. */
|
||||||
|
|||||||
@@ -226,25 +226,10 @@ export function BookingFormStep({
|
|||||||
onBlur={handleRucBlur}
|
onBlur={handleRucBlur}
|
||||||
placeholder={t('booking.form.rucPlaceholder')}
|
placeholder={t('booking.form.rucPlaceholder')}
|
||||||
error={errors.ruc}
|
error={errors.ruc}
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
aria-label={t('booking.form.ruc')}
|
aria-label={t('booking.form.ruc')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('booking.form.preferredLanguage')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.preferredLanguage}
|
|
||||||
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
|
|
||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="es">Español</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface BookingFormData {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
preferredLanguage: 'en' | 'es';
|
|
||||||
// Empty until the user explicitly picks a method (no default selection).
|
// Empty until the user explicitly picks a method (no default selection).
|
||||||
paymentMethod: PaymentMethod | '';
|
paymentMethod: PaymentMethod | '';
|
||||||
ruc: string;
|
ruc: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
import { formatDateLong, formatTime, formatRucDisplay } from '@/lib/utils';
|
||||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type {
|
import type {
|
||||||
@@ -56,7 +56,6 @@ export default function BookingPage() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
preferredLanguage: locale as 'en' | 'es',
|
|
||||||
paymentMethod: '',
|
paymentMethod: '',
|
||||||
ruc: '',
|
ruc: '',
|
||||||
});
|
});
|
||||||
@@ -78,11 +77,10 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate RUC on blur (optional field: 6–10 digits)
|
// Validate RUC on blur (optional field: base + "-" + check digit)
|
||||||
const handleRucBlur = () => {
|
const handleRucBlur = () => {
|
||||||
if (!formData.ruc) return;
|
if (!formData.ruc) return;
|
||||||
const digits = formData.ruc.replace(/\D/g, '');
|
if (!rucPattern.test(formData.ruc)) {
|
||||||
if (digits.length > 0 && !rucPattern.test(digits)) {
|
|
||||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -152,8 +150,7 @@ export default function BookingPage() {
|
|||||||
lastName: prev.lastName || lastName,
|
lastName: prev.lastName || lastName,
|
||||||
email: prev.email || user.email || '',
|
email: prev.email || user.email || '',
|
||||||
phone: prev.phone || user.phone || '',
|
phone: prev.phone || user.phone || '',
|
||||||
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
|
ruc: prev.ruc || formatRucDisplay(user.rucNumber),
|
||||||
ruc: prev.ruc || user.rucNumber || '',
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -197,12 +194,9 @@ export default function BookingPage() {
|
|||||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RUC validation (optional field - 6–10 digits if filled)
|
// RUC validation (optional field - base + "-" + check digit if filled)
|
||||||
if (formData.ruc.trim()) {
|
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) {
|
||||||
const digits = formData.ruc.replace(/\D/g, '');
|
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||||
if (!/^\d{6,10}$/.test(digits)) {
|
|
||||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment method must be explicitly chosen and currently enabled
|
// Payment method must be explicitly chosen and currently enabled
|
||||||
@@ -301,9 +295,9 @@ export default function BookingPage() {
|
|||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
preferredLanguage: formData.preferredLanguage,
|
preferredLanguage: locale as 'en' | 'es',
|
||||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||||
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
|
...(formData.ruc.trim() && { ruc: formData.ruc.trim() }),
|
||||||
// Include attendees array for multi-ticket bookings
|
// Include attendees array for multi-ticket bookings
|
||||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
@@ -417,7 +417,7 @@ export default function AdminBookingsPage() {
|
|||||||
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
||||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{ticket.attendeeRuc || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(ticket.attendeeRuc) || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm truncate max-w-[150px] block">
|
<span className="text-sm truncate max-w-[150px] block">
|
||||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -205,7 +221,7 @@ export default function AdminPaymentsPage() {
|
|||||||
p.createdAt,
|
p.createdAt,
|
||||||
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||||
p.attendeeEmail || '',
|
p.attendeeEmail || '',
|
||||||
p.attendeeRuc || '',
|
formatRucDisplay(p.attendeeRuc) || '',
|
||||||
p.eventTitle,
|
p.eventTitle,
|
||||||
p.eventDate,
|
p.eventDate,
|
||||||
]);
|
]);
|
||||||
@@ -431,7 +447,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
||||||
)}
|
)}
|
||||||
{selectedPayment.ticket.attendeeRuc && (
|
{selectedPayment.ticket.attendeeRuc && (
|
||||||
<p className="text-sm text-gray-600">RUC: {selectedPayment.ticket.attendeeRuc}</p>
|
<p className="text-sm text-gray-600">RUC: {formatRucDisplay(selectedPayment.ticket.attendeeRuc)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
@@ -314,7 +314,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{user.rucNumber || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(user.rucNumber) || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
||||||
@@ -363,7 +363,7 @@ export default function AdminUsersPage() {
|
|||||||
<p className="font-medium text-sm truncate">{user.name}</p>
|
<p className="font-medium text-sm truncate">{user.name}</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||||
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
||||||
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
|
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {formatRucDisplay(user.rucNumber)}</p>}
|
||||||
</div>
|
</div>
|
||||||
{getRoleBadge(user.role)}
|
{getRoleBadge(user.role)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -166,6 +166,18 @@ export function formatCurrency(amount: number, currency: string = 'PYG'): string
|
|||||||
return formatPrice(amount, currency);
|
return formatPrice(amount, currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Paraguayan RUC for display as "base-checkdigit" (e.g. 1234567-9).
|
||||||
|
* Legacy records stored digits only; insert the dash before the check digit.
|
||||||
|
*/
|
||||||
|
export function formatRucDisplay(ruc: string | null | undefined): string {
|
||||||
|
if (!ruc) return '';
|
||||||
|
if (ruc.includes('-')) return ruc;
|
||||||
|
const digits = ruc.replace(/\D/g, '');
|
||||||
|
if (digits.length < 2) return ruc;
|
||||||
|
return `${digits.slice(0, -1)}-${digits.slice(-1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Payment helpers
|
// Payment helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user