Most attendees arrive without their QR open, and taking money at the door meant leaving the scanner for the event dashboard, where the Add Ticket modal demanded an email and recorded no payment method. The screen now leads with manual name search, keeps the camera one tap away behind a fullscreen overlay, and creates and charges walk-ins inline. Check-in and payment are one action: anything done here is born confirmed, paid (or comp) and checked in through a single endpoint, POST /api/events/:eventId/door-checkin. There are no confirm dialogs anywhere, because they stall the queue; a ten-second Undo replaces them, reversing exactly what the action changed via the undo state recorded alongside its idempotency key. Writes fire in the background with retries, so venue wifi never blocks the person at the door, and a capacity limit only warns, since staff at the door are the authority. Every write carries a client-generated idempotency key, inserted in the same transaction as the writes it guards, so a double tap or a retry after a timeout cannot produce a second ticket, payment or check-in. Search runs entirely in memory over one preloaded list: names are matched accent- and case-insensitively in both directions, per word, prefix before substring, with a mostly-numeric query searching phone digits so two people with the same name can be told apart. Door money is recorded as payments.source 'door' plus payments.method (cash, bitcoin, transfer or guest) while provider keeps its existing value, so capacity counting, the stale-booking sweeps and the admin payment lists are unaffected and revenue can still be split pre-sale versus door. Bitcoin records the payment as made, on the same trust model as cash, with no invoice generated; lib/doorPayments.ts is where a real Lightning flow slots in later. Also fixes the SQLite tickets DDL, which still created the pre-split attendee_name column with NOT NULL email and phone. Only fresh databases were affected -- existing ones were relaxed by later ALTERs -- but on those, door walk-ins (and any other ticket) could not be inserted at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
652 lines
24 KiB
TypeScript
652 lines
24 KiB
TypeScript
// Door check-in screen (admin/scanner) API.
|
|
//
|
|
// At the door, check-in and ticket creation are the same action, so everything
|
|
// here is written for one-tap speed on a phone with unreliable venue wifi:
|
|
//
|
|
// GET /:eventId/door-attendees full attendee list, fetched once and searched
|
|
// client-side so typing never hits the network
|
|
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
|
|
// payment, or creates a walk-in, atomically
|
|
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
|
|
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue split
|
|
//
|
|
// Every write carries a client-generated idempotencyKey. The key is inserted in
|
|
// the same transaction as the writes, so a double tap or a retry after a timeout
|
|
// can never produce a second ticket, a second payment or a double check-in — the
|
|
// replay returns the original response instead.
|
|
|
|
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
import { eq, and, inArray, sql } from 'drizzle-orm';
|
|
import {
|
|
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
|
|
} from '../db/index.js';
|
|
import { requireAuth } from '../lib/auth.js';
|
|
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate } from '../lib/utils.js';
|
|
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
|
import { seatHolderCountQuery } from '../lib/capacity.js';
|
|
import {
|
|
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
|
|
paymentStatusForMethod, type DoorPaymentMethod,
|
|
} from '../lib/doorPayments.js';
|
|
import emailService from '../lib/email.js';
|
|
|
|
const doorRouter = new Hono();
|
|
|
|
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
|
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
|
|
|
// ==================== Shared helpers ====================
|
|
|
|
const num = (v: any): number => {
|
|
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
};
|
|
|
|
const iso = (v: any): string | null => {
|
|
if (!v) return null;
|
|
return v instanceof Date ? v.toISOString() : String(v);
|
|
};
|
|
|
|
function fullName(ticket: any): string {
|
|
return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
}
|
|
|
|
/**
|
|
* The row shape the door screen renders. Returned both by the preload list and
|
|
* by every write, so the client can splice an updated attendee straight back
|
|
* into its in-memory list without a refetch.
|
|
*/
|
|
function toDoorAttendee(
|
|
ticket: any,
|
|
opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
|
|
) {
|
|
return {
|
|
ticketId: ticket.id,
|
|
firstName: ticket.attendeeFirstName,
|
|
lastName: ticket.attendeeLastName || null,
|
|
fullName: fullName(ticket),
|
|
email: ticket.attendeeEmail || null,
|
|
phone: ticket.attendeePhone || null,
|
|
status: ticket.status,
|
|
paymentStatus: ticket.paymentStatus,
|
|
isGuest: !!ticket.isGuest,
|
|
checkedIn: ticket.status === 'checked_in',
|
|
checkinAt: iso(ticket.checkinAt),
|
|
checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
|
|
bookingId: ticket.bookingId || null,
|
|
isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
|
|
amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
|
|
doorMethod: opts.doorMethod ?? null,
|
|
qrCode: ticket.qrCode || null,
|
|
createdAt: iso(ticket.createdAt),
|
|
};
|
|
}
|
|
|
|
async function loadEvent(eventId: string) {
|
|
const event = await dbGet<any>(
|
|
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
|
);
|
|
if (!event) return null;
|
|
return {
|
|
...event,
|
|
price: num(event.price),
|
|
capacity: Number(event.capacity),
|
|
};
|
|
}
|
|
|
|
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
|
|
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
|
|
const unique = [...new Set(adminIds.filter(Boolean))];
|
|
if (unique.length === 0) return new Map();
|
|
const rows = await dbAll<any>(
|
|
(db as any)
|
|
.select({ id: (users as any).id, name: (users as any).name })
|
|
.from(users)
|
|
.where(inArray((users as any).id, unique))
|
|
);
|
|
return new Map(rows.map((r: any) => [r.id, r.name]));
|
|
}
|
|
|
|
/** Seats currently held for an event, used only to warn (never to block) at the door. */
|
|
async function seatsHeld(eventId: string): Promise<number> {
|
|
const row = await dbGet<any>(seatHolderCountQuery(db, eventId));
|
|
return Number(row?.count || 0);
|
|
}
|
|
|
|
// ==================== GET /:eventId/door-attendees ====================
|
|
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
|
|
// tickets are included on purpose: staff must be able to see and reactivate them.
|
|
|
|
doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async (c) => {
|
|
const eventId = c.req.param('eventId');
|
|
|
|
const event = await loadEvent(eventId);
|
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
|
|
|
const rows = await dbAll<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId))
|
|
);
|
|
|
|
// A booking id shared by more than one ticket marks a group booking, which is
|
|
// the usual reason an otherwise-confirmed attendee still shows as unpaid.
|
|
const bookingCounts = new Map<string, number>();
|
|
for (const t of rows) {
|
|
if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1);
|
|
}
|
|
const groupBookingIds = new Set(
|
|
[...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id)
|
|
);
|
|
|
|
const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId));
|
|
|
|
// Door tender per ticket, so a row already settled at the door shows how.
|
|
// Joined on the event rather than on a list of ticket ids: the id list would
|
|
// grow with the guest list and eventually blow the statement parameter limit.
|
|
const doorMethods = new Map<string, string>();
|
|
const doorPayments = await dbAll<any>(
|
|
(db as any)
|
|
.select({ ticketId: (payments as any).ticketId, method: (payments as any).method })
|
|
.from(payments)
|
|
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
|
.where(and(
|
|
eq((tickets as any).eventId, eventId),
|
|
eq((payments as any).source, 'door')
|
|
))
|
|
);
|
|
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
|
|
|
|
const attendees = rows
|
|
.map((t: any) => toDoorAttendee(t, {
|
|
price: event.price,
|
|
groupBookingIds,
|
|
adminNames,
|
|
doorMethod: doorMethods.get(t.id) || null,
|
|
}))
|
|
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
|
|
|
|
const checkedIn = attendees.filter((a) => a.checkedIn).length;
|
|
const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length;
|
|
|
|
return c.json({
|
|
event: {
|
|
id: event.id,
|
|
title: event.title,
|
|
price: event.price,
|
|
currency: event.currency,
|
|
capacity: event.capacity,
|
|
},
|
|
attendees,
|
|
stats: { checkedIn, totalActive, capacity: event.capacity },
|
|
});
|
|
});
|
|
|
|
// ==================== POST /:eventId/door-checkin ====================
|
|
|
|
const doorCheckinSchema = z.object({
|
|
// Existing ticket to check in (and optionally settle), or…
|
|
ticketId: z.string().optional(),
|
|
// …a walk-in to create. Only a first name is ever required.
|
|
attendee: z.object({
|
|
firstName: z.string().trim().min(1).max(255),
|
|
lastName: z.string().trim().max(255).optional().or(z.literal('')),
|
|
phone: z.string().trim().max(50).optional().or(z.literal('')),
|
|
email: z.string().trim().email().optional().or(z.literal('')),
|
|
ruc: z.string().trim().max(15).optional().or(z.literal('')),
|
|
}).optional(),
|
|
payment: z.object({
|
|
method: z.enum(DOOR_PAYMENT_METHODS),
|
|
// Omitted means "one ticket at event price"; a multiple covers someone
|
|
// paying for their whole group in one go.
|
|
amount: z.number().min(0).optional(),
|
|
}).optional(),
|
|
// How the attendee reached this action, for the session feed.
|
|
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
|
|
idempotencyKey: z.string().min(8).max(128),
|
|
}).refine((d) => !!d.ticketId || !!d.attendee, {
|
|
message: 'Either ticketId or attendee is required',
|
|
path: ['ticketId'],
|
|
});
|
|
|
|
/** Undo instructions recorded alongside each processed idempotency key. */
|
|
type UndoState =
|
|
| {
|
|
kind: 'created';
|
|
ticketId: string;
|
|
paymentId: string;
|
|
}
|
|
| {
|
|
kind: 'existing';
|
|
ticketId: string;
|
|
prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean };
|
|
createdPaymentId?: string;
|
|
prevPayment?: {
|
|
id: string; provider: string; amount: number; status: string; reference: string | null;
|
|
paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null;
|
|
};
|
|
};
|
|
|
|
/** A replay of a key we already processed returns the original response verbatim. */
|
|
async function findProcessedKey(key: string) {
|
|
return dbGet<any>(
|
|
(db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key))
|
|
);
|
|
}
|
|
|
|
doorRouter.post(
|
|
'/:eventId/door-checkin',
|
|
requireAuth([...STAFF_ROLES]),
|
|
zValidator('json', doorCheckinSchema),
|
|
async (c) => {
|
|
const eventId = c.req.param('eventId');
|
|
const data = c.req.valid('json');
|
|
const adminUser = (c as any).get('user');
|
|
|
|
const existingKey = await findProcessedKey(data.idempotencyKey);
|
|
if (existingKey) {
|
|
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
|
|
}
|
|
|
|
const event = await loadEvent(eventId);
|
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
|
|
|
const now = getNow();
|
|
const nowIso = new Date().toISOString();
|
|
const method = data.payment?.method as DoorPaymentMethod | undefined;
|
|
const requestedAmount = data.payment?.amount ?? event.price;
|
|
|
|
const ops: TxOp[] = [];
|
|
let undoState: UndoState;
|
|
let action: 'checkin' | 'walkin';
|
|
let ticketRow: any;
|
|
let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null;
|
|
let emailTicketId: string | null = null;
|
|
|
|
if (data.ticketId) {
|
|
// ---- Existing ticket: settle (optionally) and check in ----
|
|
const ticket = await dbGet<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId))
|
|
);
|
|
if (!ticket) return c.json({ error: 'Ticket not found' }, 404);
|
|
if (ticket.eventId !== eventId) {
|
|
return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400);
|
|
}
|
|
|
|
action = 'checkin';
|
|
const prevTicket = {
|
|
status: ticket.status,
|
|
checkinAt: iso(ticket.checkinAt),
|
|
checkedInByAdminId: ticket.checkedInByAdminId || null,
|
|
paymentStatus: ticket.paymentStatus,
|
|
isGuest: !!ticket.isGuest,
|
|
};
|
|
const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket };
|
|
|
|
const ticketUpdate: Record<string, any> = {};
|
|
|
|
if (method) {
|
|
const amount = amountForMethod(method, requestedAmount);
|
|
const tender = DOOR_TENDERS[method];
|
|
ticketUpdate.paymentStatus = paymentStatusForMethod(method);
|
|
if (method === 'guest') ticketUpdate.isGuest = toDbBool(true);
|
|
|
|
const existingPayment = await dbGet<any>(
|
|
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id))
|
|
);
|
|
|
|
if (existingPayment) {
|
|
undo.prevPayment = {
|
|
id: existingPayment.id,
|
|
provider: existingPayment.provider,
|
|
amount: num(existingPayment.amount),
|
|
status: existingPayment.status,
|
|
reference: existingPayment.reference || null,
|
|
paidAt: iso(existingPayment.paidAt),
|
|
paidByAdminId: existingPayment.paidByAdminId || null,
|
|
source: existingPayment.source || 'presale',
|
|
method: existingPayment.method || null,
|
|
};
|
|
ops.push(updateOp(payments, {
|
|
provider: tender.provider,
|
|
amount,
|
|
currency: event.currency,
|
|
status: 'paid',
|
|
reference: doorReference(method),
|
|
paidAt: now,
|
|
paidByAdminId: adminUser?.id || null,
|
|
source: 'door',
|
|
method,
|
|
updatedAt: now,
|
|
}, eq((payments as any).id, existingPayment.id)));
|
|
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency };
|
|
} else {
|
|
const paymentId = generateId();
|
|
undo.createdPaymentId = paymentId;
|
|
ops.push(insertOp(payments, {
|
|
id: paymentId,
|
|
ticketId: ticket.id,
|
|
provider: tender.provider,
|
|
amount,
|
|
currency: event.currency,
|
|
status: 'paid',
|
|
reference: doorReference(method),
|
|
paidAt: now,
|
|
paidByAdminId: adminUser?.id || null,
|
|
source: 'door',
|
|
method,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}));
|
|
paymentSummary = { id: paymentId, method, amount, currency: event.currency };
|
|
}
|
|
}
|
|
|
|
// Check in. An already-checked-in ticket keeps its original timestamp so
|
|
// staff can still tell the person when they actually entered.
|
|
if (ticket.status !== 'checked_in') {
|
|
ticketUpdate.status = 'checked_in';
|
|
ticketUpdate.checkinAt = now;
|
|
ticketUpdate.checkedInByAdminId = adminUser?.id || null;
|
|
}
|
|
|
|
if (Object.keys(ticketUpdate).length > 0) {
|
|
ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id)));
|
|
}
|
|
|
|
undoState = undo;
|
|
ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt };
|
|
} else {
|
|
// ---- Walk-in: born confirmed, settled and checked in, in one write ----
|
|
const attendee = data.attendee!;
|
|
action = 'walkin';
|
|
const tenderMethod: DoorPaymentMethod = method || 'cash';
|
|
const tender = DOOR_TENDERS[tenderMethod];
|
|
const amount = amountForMethod(tenderMethod, requestedAmount);
|
|
const hasEmail = !!(attendee.email && attendee.email.trim());
|
|
const firstNameValue = attendee.firstName.trim();
|
|
const lastNameValue = attendee.lastName?.trim() || null;
|
|
const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue;
|
|
|
|
// No email is the fast path; a placeholder keeps the users.email unique
|
|
// constraint satisfied without ever mailing anyone.
|
|
const accountEmail = hasEmail
|
|
? attendee.email!.trim()
|
|
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`;
|
|
|
|
let user = hasEmail
|
|
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
|
|
: null;
|
|
|
|
if (!user) {
|
|
const userId = generateId();
|
|
user = { id: userId, email: accountEmail };
|
|
ops.push(insertOp(users, {
|
|
id: userId,
|
|
email: accountEmail,
|
|
password: null,
|
|
name: displayName,
|
|
phone: attendee.phone?.trim() || null,
|
|
role: 'user',
|
|
languagePreference: null,
|
|
isClaimed: toDbBool(false),
|
|
accountStatus: 'unclaimed',
|
|
emailVerified: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}));
|
|
}
|
|
|
|
const ticketId = generateId();
|
|
const paymentId = generateId();
|
|
const newTicket = {
|
|
id: ticketId,
|
|
bookingId: null,
|
|
userId: user.id,
|
|
eventId,
|
|
attendeeFirstName: firstNameValue,
|
|
attendeeLastName: lastNameValue,
|
|
attendeeEmail: hasEmail ? attendee.email!.trim() : null,
|
|
attendeePhone: attendee.phone?.trim() || null,
|
|
attendeeRuc: attendee.ruc?.trim() || null,
|
|
preferredLanguage: null,
|
|
status: 'checked_in',
|
|
paymentStatus: paymentStatusForMethod(tenderMethod),
|
|
isGuest: toDbBool(tenderMethod === 'guest'),
|
|
qrCode: generateTicketCode(),
|
|
checkinAt: now,
|
|
checkedInByAdminId: adminUser?.id || null,
|
|
adminNote: null,
|
|
createdAt: now,
|
|
};
|
|
ops.push(insertOp(tickets, newTicket));
|
|
ops.push(insertOp(payments, {
|
|
id: paymentId,
|
|
ticketId,
|
|
provider: tender.provider,
|
|
amount,
|
|
currency: event.currency,
|
|
status: 'paid',
|
|
reference: doorReference(tenderMethod),
|
|
paidAt: now,
|
|
paidByAdminId: adminUser?.id || null,
|
|
source: 'door',
|
|
method: tenderMethod,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}));
|
|
|
|
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency };
|
|
undoState = { kind: 'created', ticketId, paymentId };
|
|
ticketRow = newTicket;
|
|
// Only mail people who actually gave an address; no QR for the rest.
|
|
if (hasEmail) emailTicketId = ticketId;
|
|
}
|
|
|
|
// Staff at the door is the authority: a full event is a warning, never a block.
|
|
const held = await seatsHeld(eventId);
|
|
const atCapacity = event.capacity > 0 && held >= event.capacity;
|
|
|
|
const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]);
|
|
const responseBody = {
|
|
ok: true,
|
|
action,
|
|
attendee: toDoorAttendee(ticketRow, {
|
|
price: event.price,
|
|
groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []),
|
|
adminNames,
|
|
doorMethod: paymentSummary?.method || null,
|
|
}),
|
|
payment: paymentSummary,
|
|
warnings: atCapacity ? ['at_capacity'] : [],
|
|
idempotencyKey: data.idempotencyKey,
|
|
processedAt: nowIso,
|
|
};
|
|
|
|
// The key row goes in with the writes, so two concurrent replays of the same
|
|
// key cannot both commit — the loser hits the primary-key conflict below.
|
|
ops.unshift(insertOp(idempotencyKeys, {
|
|
key: data.idempotencyKey,
|
|
scope: IDEMPOTENCY_SCOPE,
|
|
result: JSON.stringify(responseBody),
|
|
undoState: JSON.stringify(undoState),
|
|
undoneAt: null,
|
|
createdAt: now,
|
|
}));
|
|
|
|
try {
|
|
await runOps(ops);
|
|
} catch (err: any) {
|
|
const replay = await findProcessedKey(data.idempotencyKey);
|
|
if (replay) {
|
|
return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (emailTicketId) {
|
|
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
|
|
console.error('[Email] Failed to send door walk-in confirmation:', err);
|
|
});
|
|
}
|
|
|
|
return c.json(responseBody, 201);
|
|
}
|
|
);
|
|
|
|
// ==================== POST /:eventId/door-checkin/undo ====================
|
|
// Reverses exactly what the keyed action did — nothing more. This is what makes
|
|
// the door screen safe to run without a single confirm dialog.
|
|
|
|
doorRouter.post(
|
|
'/:eventId/door-checkin/undo',
|
|
requireAuth([...STAFF_ROLES]),
|
|
zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
|
|
async (c) => {
|
|
const { idempotencyKey } = c.req.valid('json');
|
|
|
|
const record = await findProcessedKey(idempotencyKey);
|
|
if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404);
|
|
if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true });
|
|
|
|
const undo = JSON.parse(record.undoState || 'null') as UndoState | null;
|
|
if (!undo) return c.json({ error: 'This action cannot be undone' }, 400);
|
|
|
|
const now = getNow();
|
|
const ops: TxOp[] = [];
|
|
|
|
if (undo.kind === 'created') {
|
|
// Walk-ins created here are cancelled, not deleted: the row stays as an
|
|
// audit trail and can be reactivated from the same screen.
|
|
ops.push(updateOp(tickets, {
|
|
status: 'cancelled',
|
|
checkinAt: null,
|
|
checkedInByAdminId: null,
|
|
}, eq((tickets as any).id, undo.ticketId)));
|
|
ops.push(updateOp(payments, {
|
|
status: 'cancelled',
|
|
paidAt: null,
|
|
updatedAt: now,
|
|
}, eq((payments as any).id, undo.paymentId)));
|
|
} else {
|
|
ops.push(updateOp(tickets, {
|
|
status: undo.prevTicket.status,
|
|
checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null,
|
|
checkedInByAdminId: undo.prevTicket.checkedInByAdminId,
|
|
paymentStatus: undo.prevTicket.paymentStatus,
|
|
isGuest: toDbBool(undo.prevTicket.isGuest),
|
|
}, eq((tickets as any).id, undo.ticketId)));
|
|
|
|
if (undo.createdPaymentId) {
|
|
ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId)));
|
|
} else if (undo.prevPayment) {
|
|
const prev = undo.prevPayment;
|
|
ops.push(updateOp(payments, {
|
|
provider: prev.provider,
|
|
amount: prev.amount,
|
|
status: prev.status,
|
|
reference: prev.reference,
|
|
paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null,
|
|
paidByAdminId: prev.paidByAdminId,
|
|
source: prev.source,
|
|
method: prev.method,
|
|
updatedAt: now,
|
|
}, eq((payments as any).id, prev.id)));
|
|
}
|
|
}
|
|
|
|
ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey)));
|
|
|
|
await runOps(ops);
|
|
|
|
return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind });
|
|
}
|
|
);
|
|
|
|
// ==================== GET /:eventId/door-summary ====================
|
|
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
|
// pre-sale/door split the event dashboard shows.
|
|
|
|
doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c) => {
|
|
const eventId = c.req.param('eventId');
|
|
|
|
const event = await loadEvent(eventId);
|
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
|
|
|
// Door payments settled for this event, with the attendee attached so the
|
|
// session feed can show who each line belongs to.
|
|
const rows = await dbAll<any>(
|
|
(db as any)
|
|
.select({
|
|
paymentId: (payments as any).id,
|
|
ticketId: (tickets as any).id,
|
|
method: (payments as any).method,
|
|
amount: (payments as any).amount,
|
|
paidAt: (payments as any).paidAt,
|
|
firstName: (tickets as any).attendeeFirstName,
|
|
lastName: (tickets as any).attendeeLastName,
|
|
ticketStatus: (tickets as any).status,
|
|
})
|
|
.from(payments)
|
|
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
|
.where(and(
|
|
eq((tickets as any).eventId, eventId),
|
|
eq((payments as any).source, 'door'),
|
|
eq((payments as any).status, 'paid')
|
|
))
|
|
);
|
|
|
|
const byMethod: Record<string, { count: number; total: number }> = {};
|
|
for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 };
|
|
|
|
let doorTotal = 0;
|
|
for (const r of rows) {
|
|
const key = (r.method && byMethod[r.method]) ? r.method : 'cash';
|
|
const amount = num(r.amount);
|
|
byMethod[key].count += 1;
|
|
byMethod[key].total += amount;
|
|
doorTotal += amount;
|
|
}
|
|
|
|
// Pre-sale revenue keeps the dashboard's existing definition — settled tickets
|
|
// at event price — minus anything that was actually taken at the door.
|
|
const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
|
|
const settled = await dbAll<any>(
|
|
(db as any)
|
|
.select({ id: (tickets as any).id })
|
|
.from(tickets)
|
|
.where(and(
|
|
eq((tickets as any).eventId, eventId),
|
|
eq((tickets as any).paymentStatus, 'paid'),
|
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
))
|
|
);
|
|
const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length;
|
|
const presaleTotal = presaleCount * event.price;
|
|
|
|
return c.json({
|
|
eventId,
|
|
currency: event.currency,
|
|
price: event.price,
|
|
door: {
|
|
count: rows.length,
|
|
total: doorTotal,
|
|
byMethod,
|
|
lines: rows
|
|
.map((r: any) => ({
|
|
paymentId: r.paymentId,
|
|
ticketId: r.ticketId,
|
|
name: `${r.firstName} ${r.lastName || ''}`.trim(),
|
|
method: r.method || 'cash',
|
|
amount: num(r.amount),
|
|
paidAt: iso(r.paidAt),
|
|
}))
|
|
.sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')),
|
|
},
|
|
presale: { count: presaleCount, total: presaleTotal },
|
|
total: presaleTotal + doorTotal,
|
|
});
|
|
});
|
|
|
|
export default doorRouter;
|