From 0d471560719e635256e513f0cb2a97b6a8fcd187 Mon Sep 17 00:00:00 2001 From: Michilis Date: Mon, 13 Jul 2026 03:47:35 +0000 Subject: [PATCH] Normalize RUC formatting to base-checkdigit across booking and admin views. Accept dashed or digits-only RUC input on the backend, store a canonical dashed form, and display it consistently in booking and admin UIs. Co-authored-by: Cursor --- backend/src/routes/tickets.ts | 25 +++++++++++++------ .../(public)/book/[eventId]/_logic/booking.ts | 12 ++++++--- .../book/[eventId]/_steps/BookingFormStep.tsx | 15 ----------- .../src/app/(public)/book/[eventId]/_types.ts | 1 - .../src/app/(public)/book/[eventId]/page.tsx | 24 +++++++----------- frontend/src/app/admin/bookings/page.tsx | 4 +-- frontend/src/app/admin/payments/page.tsx | 6 ++--- frontend/src/app/admin/users/page.tsx | 6 ++--- frontend/src/lib/utils.ts | 12 +++++++++ 9 files changed, 55 insertions(+), 50 deletions(-) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 08fcde1..1ff4610 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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): 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, diff --git a/frontend/src/app/(public)/book/[eventId]/_logic/booking.ts b/frontend/src/app/(public)/book/[eventId]/_logic/booking.ts index fc506a4..7c63872 100644 --- a/frontend/src/app/(public)/book/[eventId]/_logic/booking.ts +++ b/frontend/src/app/(public)/book/[eventId]/_logic/booking.ts @@ -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. */ diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx index b495d7b..bfa741f 100644 --- a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx +++ b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx @@ -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')} /> - -
- - -
diff --git a/frontend/src/app/(public)/book/[eventId]/_types.ts b/frontend/src/app/(public)/book/[eventId]/_types.ts index e5f67c8..1216140 100644 --- a/frontend/src/app/(public)/book/[eventId]/_types.ts +++ b/frontend/src/app/(public)/book/[eventId]/_types.ts @@ -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; diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 04d7cd7..e51fff0 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -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 }), }); diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index 100aa7b..a1ecda1 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -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() {

{ticket.attendeeEmail || 'N/A'}

{ticket.attendeePhone &&

{ticket.attendeePhone}

} - {ticket.attendeeRuc || '-'} + {formatRucDisplay(ticket.attendeeRuc) || '-'} {ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 134b55c..905823c 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -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'; @@ -205,7 +205,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 +431,7 @@ export default function AdminPaymentsPage() {

{selectedPayment.ticket.attendeePhone}

)} {selectedPayment.ticket.attendeeRuc && ( -

RUC: {selectedPayment.ticket.attendeeRuc}

+

RUC: {formatRucDisplay(selectedPayment.ticket.attendeeRuc)}

)} )} diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index 1b62b57..f1c4093 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -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() { {user.phone || '-'} - {user.rucNumber || '-'} + {formatRucDisplay(user.rucNumber) || '-'}