Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19461098a0 | ||
|
|
4772b85f3d | ||
|
|
74dcc8e5ac | ||
|
|
0d47156071 |
@@ -5,11 +5,20 @@
|
||||
// abandoned checkout would otherwise hold those seats forever. This job cancels
|
||||
// pending tickets whose payment is still 'pending' (i.e. never paid and not
|
||||
// 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 { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
||||
|
||||
function getTtlMs(): number {
|
||||
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.
|
||||
*
|
||||
* A booking is considered stale when its payment is still 'pending' (not
|
||||
* 'pending_approval', which means an admin is reviewing a manual transfer) and
|
||||
* older than PENDING_BOOKING_TTL_MINUTES.
|
||||
* 'pending_approval', which means an admin is reviewing a manual transfer),
|
||||
* uses a non-manual provider, and has not been touched (updatedAt) for
|
||||
* PENDING_BOOKING_TTL_MINUTES.
|
||||
*/
|
||||
export async function cleanupStalePendingBookings(): Promise<number> {
|
||||
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
|
||||
@@ -35,7 +45,8 @@ export async function cleanupStalePendingBookings(): Promise<number> {
|
||||
.from(payments)
|
||||
.where(and(
|
||||
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
|
||||
// statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. Recovering
|
||||
// an on-hold booking (user "I've paid" again, or an admin reactivating / marking it
|
||||
// paid) must atomically re-check that the event still has room before re-reserving
|
||||
// the seat, exactly like the original booking-creation flow in routes/tickets.ts.
|
||||
// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the
|
||||
// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat.
|
||||
// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking
|
||||
// it paid / reopening a failed payment) must atomically re-check that the event still has
|
||||
// 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 { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
||||
@@ -19,22 +24,29 @@ export class HoldCapacityError extends Error {
|
||||
interface ReserveOptions {
|
||||
paidByAdminId?: string;
|
||||
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.
|
||||
* 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(
|
||||
eventId: string,
|
||||
ticketIds: string[],
|
||||
targetTicketStatus: 'pending' | 'confirmed',
|
||||
targetPaymentStatus: 'pending_approval' | 'paid',
|
||||
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
|
||||
options: ReserveOptions = {}
|
||||
): Promise<void> {
|
||||
if (ticketIds.length === 0) return;
|
||||
|
||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(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;
|
||||
}
|
||||
|
||||
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)) {
|
||||
throw new HoldCapacityError(0);
|
||||
}
|
||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
||||
if (ticketIds.length > seatsLeft) {
|
||||
if (needed > seatsLeft) {
|
||||
throw new HoldCapacityError(seatsLeft);
|
||||
}
|
||||
};
|
||||
@@ -73,13 +88,21 @@ export async function reserveOnHoldBooking(
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
))
|
||||
.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)
|
||||
.set({ status: targetTicketStatus })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
eq((tickets as any).status, 'on_hold')
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
))
|
||||
.run();
|
||||
|
||||
@@ -99,13 +122,22 @@ export async function reserveOnHoldBooking(
|
||||
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)
|
||||
.set({ status: targetTicketStatus })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
eq((tickets as any).status, 'on_hold')
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
));
|
||||
|
||||
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
|
||||
// payment method (bank transfer / TPago) and is waiting for an admin to review it.
|
||||
// If no admin acts within HOLD_THRESHOLD_HOURS, this job silently moves the payment
|
||||
// (and its ticket) 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.
|
||||
// This job releases the seat held by an abandoned manual-payment booking (bank
|
||||
// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of
|
||||
// which keep a seat reserved while awaiting a human:
|
||||
// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin.
|
||||
// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was
|
||||
// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts,
|
||||
// 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 { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
||||
|
||||
function getThresholdMs(): number {
|
||||
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.
|
||||
*/
|
||||
export async function sweepStaleApprovals(): Promise<number> {
|
||||
@@ -33,7 +40,13 @@ export async function sweepStaleApprovals(): Promise<number> {
|
||||
})
|
||||
.from(payments)
|
||||
.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)
|
||||
))
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
const reopenPaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
// Get all payments (admin) - with ticket and event details
|
||||
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 updateData: any = { ...data, updatedAt: now };
|
||||
|
||||
|
||||
// If marking as paid, record who approved it and when
|
||||
if (data.status === 'paid' && existing.status !== 'paid') {
|
||||
updateData.paidAt = now;
|
||||
@@ -321,8 +331,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Can approve pending, pending_approval, or on_hold payments
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
// Can approve pending, pending_approval, on_hold, or failed payments.
|
||||
// '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);
|
||||
}
|
||||
|
||||
@@ -350,16 +362,39 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
if (payment.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
// For a failed payment, only recover tickets whose own payment is also failed.
|
||||
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
|
||||
// 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 {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
{
|
||||
paidByAdminId: user.id,
|
||||
fromTicketStatuses:
|
||||
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'],
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
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' });
|
||||
});
|
||||
|
||||
// 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
|
||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
@@ -31,11 +31,19 @@ const createTicketSchema = z.object({
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
||||
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)
|
||||
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
|
||||
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
||||
const truthy = (v: any) => v === true || v === 1;
|
||||
@@ -76,7 +84,8 @@ const adminCreateTicketSchema = z.object({
|
||||
// Book a ticket (public) - supports single or multi-ticket bookings
|
||||
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
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)
|
||||
const attendeesList = data.attendees && data.attendees.length > 0
|
||||
? data.attendees
|
||||
@@ -168,19 +177,19 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
rucNumber: data.ruc || null,
|
||||
rucNumber,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
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
|
||||
// out an existing value if this booking didn't include one.
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ rucNumber: data.ruc, updatedAt: now })
|
||||
.set({ rucNumber, updatedAt: now })
|
||||
.where(eq((users as any).id, user.id));
|
||||
user.rucNumber = data.ruc;
|
||||
user.rucNumber = rucNumber;
|
||||
}
|
||||
|
||||
// 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,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
attendeeRuc: rucNumber,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
@@ -314,7 +323,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
attendeeRuc: rucNumber,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
|
||||
@@ -8,11 +8,17 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
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 {
|
||||
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. */
|
||||
|
||||
@@ -226,25 +226,10 @@ export function BookingFormStep({
|
||||
onBlur={handleRucBlur}
|
||||
placeholder={t('booking.form.rucPlaceholder')}
|
||||
error={errors.ruc}
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
aria-label={t('booking.form.ruc')}
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface BookingFormData {
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
preferredLanguage: 'en' | 'es';
|
||||
// Empty until the user explicitly picks a method (no default selection).
|
||||
paymentMethod: PaymentMethod | '';
|
||||
ruc: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
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 toast from 'react-hot-toast';
|
||||
import type {
|
||||
@@ -56,7 +56,6 @@ export default function BookingPage() {
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
paymentMethod: '',
|
||||
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 = () => {
|
||||
if (!formData.ruc) return;
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (digits.length > 0 && !rucPattern.test(digits)) {
|
||||
if (!rucPattern.test(formData.ruc)) {
|
||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
||||
}
|
||||
};
|
||||
@@ -152,8 +150,7 @@ export default function BookingPage() {
|
||||
lastName: prev.lastName || lastName,
|
||||
email: prev.email || user.email || '',
|
||||
phone: prev.phone || user.phone || '',
|
||||
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
|
||||
ruc: prev.ruc || user.rucNumber || '',
|
||||
ruc: prev.ruc || formatRucDisplay(user.rucNumber),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -197,12 +194,9 @@ export default function BookingPage() {
|
||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||
}
|
||||
|
||||
// RUC validation (optional field - 6–10 digits if filled)
|
||||
if (formData.ruc.trim()) {
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (!/^\d{6,10}$/.test(digits)) {
|
||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||
}
|
||||
// RUC validation (optional field - base + "-" + check digit if filled)
|
||||
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) {
|
||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||
}
|
||||
|
||||
// Payment method must be explicitly chosen and currently enabled
|
||||
@@ -301,9 +295,9 @@ export default function BookingPage() {
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
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
|
||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
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>
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</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">
|
||||
<span className="text-sm truncate max-w-[150px] block">
|
||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
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) => {
|
||||
if (!confirm('Are you sure you want to process this refund?')) return;
|
||||
|
||||
@@ -205,7 +221,7 @@ export default function AdminPaymentsPage() {
|
||||
p.createdAt,
|
||||
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||
p.attendeeEmail || '',
|
||||
p.attendeeRuc || '',
|
||||
formatRucDisplay(p.attendeeRuc) || '',
|
||||
p.eventTitle,
|
||||
p.eventDate,
|
||||
]);
|
||||
@@ -431,7 +447,7 @@ export default function AdminPaymentsPage() {
|
||||
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
@@ -503,19 +519,32 @@ export default function AdminPaymentsPage() {
|
||||
</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 === 'failed' ? (
|
||||
<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' ? 'Confirmar pago' : 'Confirm payment'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleReopen(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||||
<ArrowPathIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Cambiar a pendiente' : 'Change to pending'}
|
||||
</Button>
|
||||
</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">
|
||||
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
||||
<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">
|
||||
<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">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</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">
|
||||
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
|
||||
<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]">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
@@ -314,7 +314,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
@@ -363,7 +363,7 @@ export default function AdminUsersPage() {
|
||||
<p className="font-medium text-sm truncate">{user.name}</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.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>
|
||||
{getRoleBadge(user.role)}
|
||||
</div>
|
||||
|
||||
@@ -51,4 +51,10 @@ export const paymentsApi = {
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user