Add "paid at the door" ticket type to the Add Ticket modal.

Door walk-ins were only expressible as an unpaid ticket, which left the
cash out of revenue. The new type records the cash payment as paid and
makes every field optional, since a walk-in often gives no details:
a blank name is logged as "Walk-in", and a confirmation email only goes
out when an email is entered.

Door tickets reuse paymentStatus 'paid' (the column enum is capped at
paid/unpaid/comp) so badges and revenue totals pick them up with no
migration; the cash payment row is referenced "Paid at door" to keep
them distinguishable from emailed manual tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-22 04:39:54 +00:00
co-authored by Claude Opus 5
parent be4dd5b47f
commit a0161a67d2
7 changed files with 68 additions and 20 deletions
+26 -10
View File
@@ -1529,14 +1529,18 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
// type drives payment handling:
// paid — email required; paid cash payment; confirmation email + QR sent
// door — paid in cash at the door; all fields optional; counts toward revenue;
// confirmation email only when an email is provided
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
// pay-link (Bancard/TPago) email sent when an email is provided
// guest — free comp ticket, not counted in revenue; confirmation email only
// when an email is provided
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
type: z.enum(['paid', 'unpaid', 'guest']),
firstName: z.string().min(1),
type: z.enum(['paid', 'door', 'unpaid', 'guest']),
// Door walk-ins can be logged with nothing filled in, so firstName is only
// required for the other types
firstName: z.string().optional().or(z.literal('')),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
@@ -1546,6 +1550,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
message: 'Email is required for paid tickets',
path: ['email'],
}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), {
message: 'First name is required',
path: ['firstName'],
})), async (c) => {
const data = c.req.valid('json');
@@ -1565,9 +1572,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
? data.email!.trim()
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
// Nameless door walk-ins still need a display name on the ticket
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in';
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
? `${firstName} ${data.lastName.trim()}`
: firstName;
// Find or create user
let user = await dbGet<any>(
@@ -1613,13 +1622,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const ticketId = generateId();
const qrCode = generateTicketCode();
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeFirstName: firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
@@ -1636,7 +1645,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
await (db as any).insert(tickets).values(newTicket);
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid
// Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid
const paymentId = generateId();
const newPayment = data.type === 'unpaid'
? {
@@ -1659,7 +1668,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency,
status: 'paid',
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket',
reference: data.type === 'guest'
? 'Guest invite'
: data.type === 'door'
? 'Paid at door'
: 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
@@ -1668,8 +1681,8 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
await (db as any).insert(payments).values(newPayment);
// Emails (asynchronous): paid always confirms; guest confirms when an email
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead
// Emails (asynchronous): paid always confirms; door/guest confirm only when an
// email exists; unpaid sends the TPago (Bancard) pay-link instructions instead
if (data.type === 'unpaid') {
if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => {
@@ -1692,6 +1705,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const messages: Record<string, string> = {
paid: 'Ticket created — confirmation email sent',
door: hasEmail
? 'Ticket created — paid at the door, confirmation email sent'
: 'Ticket created — paid at the door',
unpaid: hasEmail
? 'Unpaid ticket created — payment link sent'
: 'Unpaid ticket created — collect payment at the door',