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 <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-07-13 03:47:35 +00:00
co-authored by Cursor
parent 75e317d73e
commit 0d47156071
9 changed files with 55 additions and 50 deletions
+17 -8
View File
@@ -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,7 +84,8 @@ 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
? data.attendees ? data.attendees
@@ -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: 610 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 - 610 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 }),
}); });
+2 -2
View File
@@ -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 -3
View File
@@ -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';
@@ -205,7 +205,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 +431,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>
)} )}
+3 -3
View File
@@ -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>
+12
View File
@@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------