Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price): null falls back to the ticket price, 0 is a free walk-in. It is set in the Create/Edit Event modal (EN/ES) and carried through create, update and duplicate, but it is internal: public event responses and the attendee-facing ticket/dashboard endpoints drop it, and only admin, organizer and staff callers receive it. The door screen no longer trusts the amount the client sends. It sends a quantity and the server prices the charge from the event record: walk-ins pay the walk-in price, existing tickets the ticket price. A typed amount is only honoured with amountOverride from admin/organizer and is written to audit_logs in the same transaction. The charged amount stays snapshotted on the payment row. POS is a new door tender for the physical card terminal. Staff pick POS, see the amount to key in, charge the card, then confirm with "Mark as paid"; it is then recorded like any other door payment (provider and method 'pos'). It can be switched off globally or per event via payment options, and shows up in the payments filters, bookings, revenue summaries and door takings. PosChargePanel is where an automatic terminal push would go later; nothing calls a terminal today. tickets.booking_source (online | walk_in | admin) records how a booking was made. The migration backfills walk-ins from the door screen's idempotency records; everything else stays online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
515707c9e4
commit
b51c1360e2
@@ -9,6 +9,7 @@ import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
import { resolvePresaleClosure } from '../lib/presale.js';
|
||||
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -24,10 +25,14 @@ const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
// `settings` is the site_settings row; when given, the effective pre-sale
|
||||
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
|
||||
// booking API agree on when registration closes.
|
||||
function normalizeEvent(event: any, settings?: any) {
|
||||
// The walk-in (door) price is internal: it is dropped unless the caller is
|
||||
// admin/organizer/staff and `includeWalkInPrice` is set.
|
||||
function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: boolean } = {}) {
|
||||
if (!event) return event;
|
||||
const { walkInPrice, ...publicFields } = event;
|
||||
const normalized = {
|
||||
...event,
|
||||
...publicFields,
|
||||
...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}),
|
||||
// Convert price from string/decimal to clean number
|
||||
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
||||
// Convert capacity from string to number if needed
|
||||
@@ -114,6 +119,29 @@ const parsePrice = (val: unknown): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Walk-in price: empty/null means "not set" (fall back to price) and must stay
|
||||
// null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input
|
||||
// fails validation instead of silently becoming 0 the way parsePrice does.
|
||||
const walkInPriceSchema = z.union([z.number(), z.string(), z.null()])
|
||||
.transform((val) => {
|
||||
if (val === null) return null;
|
||||
if (typeof val === 'number') return val;
|
||||
const trimmed = val.trim();
|
||||
if (trimmed === '') return null;
|
||||
return Number(trimmed.replace(',', '.'));
|
||||
})
|
||||
.pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable())
|
||||
.optional();
|
||||
|
||||
// PYG has no minor unit, so a PYG walk-in price must be a whole number.
|
||||
function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null {
|
||||
if (walkInPrice == null) return null;
|
||||
if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) {
|
||||
return 'walkInPrice: Walk-in price must be a whole number for PYG';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to normalize boolean (handles true/false and 0/1)
|
||||
const normalizeBoolean = (val: unknown): boolean => {
|
||||
if (typeof val === 'boolean') return val;
|
||||
@@ -137,6 +165,7 @@ const baseEventSchema = z.object({
|
||||
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
// Accept price as number or string (handles "45000" and "41,44" formats)
|
||||
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
||||
walkInPrice: walkInPriceSchema,
|
||||
currency: z.string().default('PYG'),
|
||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||
@@ -219,6 +248,7 @@ eventsRouter.get('/', async (c) => {
|
||||
// any client-supplied status filter, so drafts cannot leak.
|
||||
const authUser: any = await getAuthUser(c);
|
||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||
const includeWalkInPrice = canSeeWalkInPrice(authUser?.role);
|
||||
|
||||
const conditions: any[] = [];
|
||||
|
||||
@@ -271,7 +301,7 @@ eventsRouter.get('/', async (c) => {
|
||||
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const eventsWithCounts = result.map((event: any) => {
|
||||
const normalized = normalizeEvent(event, siteSettingsRow);
|
||||
const normalized = normalizeEvent(event, siteSettingsRow, { includeWalkInPrice });
|
||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...normalized,
|
||||
@@ -295,16 +325,19 @@ eventsRouter.get('/:id', async (c) => {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const authUser: any = await getAuthUser(c);
|
||||
|
||||
// Draft events are only visible to privileged users (admin preview); hide from public.
|
||||
if ((event as any).status === 'draft') {
|
||||
const authUser: any = await getAuthUser(c);
|
||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||
if (!isPrivileged) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow());
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow(), {
|
||||
includeWalkInPrice: canSeeWalkInPrice(authUser?.role),
|
||||
});
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
return c.json({
|
||||
event: {
|
||||
@@ -459,6 +492,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const id = generateId();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
|
||||
const walkInError = walkInPriceError(data.walkInPrice, data.currency);
|
||||
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
@@ -483,7 +519,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
revalidateFrontendCache();
|
||||
|
||||
// Return normalized event data
|
||||
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201);
|
||||
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow, { includeWalkInPrice: true }) }, 201);
|
||||
});
|
||||
|
||||
// Update event (admin/organizer only)
|
||||
@@ -498,6 +534,14 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
if (data.walkInPrice !== undefined || data.currency !== undefined) {
|
||||
const walkInError = walkInPriceError(
|
||||
data.walkInPrice !== undefined ? data.walkInPrice : parseWalkInPrice(existing.walkInPrice),
|
||||
data.currency ?? existing.currency,
|
||||
);
|
||||
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
@@ -559,7 +603,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow) });
|
||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) });
|
||||
});
|
||||
|
||||
// Delete event (admin only)
|
||||
@@ -670,6 +714,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
location: existing.location,
|
||||
locationUrl: existing.locationUrl,
|
||||
price: existing.price,
|
||||
walkInPrice: existing.walkInPrice ?? null,
|
||||
currency: existing.currency,
|
||||
capacity: existing.capacity,
|
||||
status: 'draft',
|
||||
@@ -684,7 +729,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
|
||||
await (db as any).insert(events).values(duplicatedEvent);
|
||||
|
||||
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
|
||||
return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201);
|
||||
});
|
||||
|
||||
// List slug aliases for an event (admin/organizer only)
|
||||
|
||||
Reference in New Issue
Block a user