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:
Michilis
2026-09-27 23:10:50 +00:00
co-authored by Claude Opus 5.5
parent 515707c9e4
commit b51c1360e2
34 changed files with 1192 additions and 93 deletions
@@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext';
import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
interface EventFormData {
title: string;
@@ -25,6 +25,9 @@ interface EventFormData {
location: string;
locationUrl: string;
price: number;
// Kept as the raw input so "empty" (not set -> falls back to price) stays
// distinguishable from 0 (a free walk-in).
walkInPrice: string;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
@@ -47,12 +50,20 @@ const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */
function isValidWalkInPrice(value: string): boolean {
const trimmed = value.trim();
if (trimmed === '') return true;
const n = Number(trimmed);
return Number.isInteger(n) && n >= 0;
}
function isoToLocalDatetime(isoString: string): string {
const date = parseDate(isoString);
const parts = new Intl.DateTimeFormat('en-US', {
@@ -138,6 +149,7 @@ export default function EventFormModal({
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, locationUrl: event.locationUrl || '',
price: event.price, currency: event.currency, capacity: event.capacity,
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
status: event.status, bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '',
@@ -208,6 +220,11 @@ export default function EventFormModal({
setSaving(false);
return;
}
if (!isValidWalkInPrice(formData.walkInPrice)) {
toast.error(t('admin.events.form.walkInPriceInvalid'));
setSaving(false);
return;
}
const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
@@ -216,6 +233,8 @@ export default function EventFormModal({
endDatetime: formData.endDatetime || undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
// Empty means "not set" and must be saved as null, never 0
walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
@@ -342,17 +361,30 @@ export default function EventFormModal({
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
<div className="grid grid-cols-3 gap-4">
<Input label="Price" type="number" min="0" value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<div>
<label className="block text-sm font-medium mb-1">Currency</label>
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
<option value="PYG">PYG</option>
<option value="USD">USD</option>
</select>
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input label="Price" type="number" min="0" value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
value={formData.walkInPrice}
onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
placeholder={t('admin.events.form.walkInPricePlaceholder', {
price: formatPrice(formData.price || 0, formData.currency),
})}
error={isValidWalkInPrice(formData.walkInPrice) ? undefined : t('admin.events.form.walkInPriceInvalid')} />
<div>
<label className="block text-sm font-medium mb-1">Currency</label>
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
<option value="PYG">PYG</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">{t('admin.events.form.walkInPriceHelp')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input label="Capacity" type="number" min="1" value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
</div>