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
+29 -7
View File
@@ -1,6 +1,6 @@
// Door payment tenders.
//
// The door check-in screen offers four one-tap tenders. Each maps onto an
// The door check-in screen offers five tenders. Each maps onto an
// existing payments.provider so the rest of the app (capacity, sweeps, admin
// payment lists, receipts) keeps working unchanged, while payments.method
// records which tender was actually used for the end-of-night cash-up.
@@ -9,27 +9,49 @@
// already made — the same trust model as cash, no invoice generated. When a real
// Lightning flow lands it slots in here: the tender keeps its name and provider,
// only the settlement path in routes/door.ts changes.
//
// POS is the physical card terminal. Staff open the POS step, which shows the
// amount to key into the terminal, charge the card, then confirm "Mark as paid";
// only that confirmation reaches this API, recorded like any other tender. A
// future automatic amount push to the terminal belongs in the POS step itself
// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to
// the terminal today.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
interface DoorTender {
/** Existing payments.provider this tender is stored as. */
provider: 'cash' | 'lightning' | 'bank_transfer';
provider: 'cash' | 'lightning' | 'bank_transfer' | 'pos';
/** Human label used in payment references and toasts. */
label: string;
/** Comp tenders carry no revenue and always record a zero amount. */
isComp: boolean;
/**
* How the money is confirmed before the door screen records it:
* 'on_tap' staff already hold the money when they tap (cash, …)
* 'staff_confirm' staff charge an external device first, then confirm
*/
confirmation: 'on_tap' | 'staff_confirm';
}
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
cash: { provider: 'cash', label: 'cash', isComp: false },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
guest: { provider: 'cash', label: 'guest', isComp: true },
cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' },
pos: { provider: 'pos', label: 'POS', isComp: false, confirmation: 'staff_confirm' },
guest: { provider: 'cash', label: 'guest', isComp: true, confirmation: 'on_tap' },
};
/**
* Tenders that can be switched off per event through payment options. Only POS
* is configurable: the other door tenders are always available to staff.
*/
export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMethod[] {
return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled);
}
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
}
+2 -2
View File
@@ -74,8 +74,8 @@ export async function sendPaymentReceipt(paymentId: string): Promise<{ success:
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago', pos: 'POS' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
};
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
+4 -2
View File
@@ -5,8 +5,8 @@
// settlement) and the booking is auto-approved on success. No admin involved.
// Currently Lightning; future online gateways (e.g. Stripe) go here.
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
// card handled offline, cash at the door). These are never auto-confirmed
// and never auto-failed; an admin settles them by hand. Bank transfer and
// card handled offline, cash or the POS terminal at the door). These are
// never auto-confirmed and never auto-failed; an admin settles them by hand. Bank transfer and
// TPago additionally expose an online "I've paid" step that moves the
// payment to 'pending_approval'.
//
@@ -22,6 +22,8 @@ export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> =
bank_transfer: { kind: 'manual' },
card: { kind: 'manual' },
cash: { kind: 'manual' },
// Card on the physical POS terminal at the door; staff confirm it by hand
pos: { kind: 'manual' },
};
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { resolveWalkInPrice, omitWalkInPrice, parseWalkInPrice, canSeeWalkInPrice } from './walkInPrice.js';
describe('resolveWalkInPrice', () => {
it('falls back to the ticket price when no walk-in price is set', () => {
expect(resolveWalkInPrice({ price: 21000, walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
expect(resolveWalkInPrice({ price: 21000 })).toEqual({ unitPrice: 21000, source: 'ticket' });
});
it('treats 0 as a free walk-in, not as unset', () => {
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 0 })).toEqual({ unitPrice: 0, source: 'walk_in' });
});
it('uses a set walk-in price', () => {
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 25000 })).toEqual({ unitPrice: 25000, source: 'walk_in' });
});
it('reads Postgres decimal strings', () => {
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: '25000.00' })).toEqual({ unitPrice: 25000, source: 'walk_in' });
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
expect(parseWalkInPrice('0.00')).toBe(0);
expect(parseWalkInPrice('')).toBeNull();
});
});
describe('omitWalkInPrice', () => {
it('drops the walk-in price and keeps everything else', () => {
const event = { id: 'e1', price: 21000, walkInPrice: 25000, currency: 'PYG' };
const result = omitWalkInPrice(event);
expect(result).not.toHaveProperty('walkInPrice');
expect(result).toEqual({ id: 'e1', price: 21000, currency: 'PYG' });
expect(event.walkInPrice).toBe(25000);
});
it('passes null through', () => {
expect(omitWalkInPrice(null)).toBeNull();
});
});
describe('canSeeWalkInPrice', () => {
it('is limited to event managers and door staff', () => {
expect(canSeeWalkInPrice('admin')).toBe(true);
expect(canSeeWalkInPrice('organizer')).toBe(true);
expect(canSeeWalkInPrice('staff')).toBe(true);
expect(canSeeWalkInPrice('marketing')).toBe(false);
expect(canSeeWalkInPrice('user')).toBe(false);
expect(canSeeWalkInPrice(null)).toBe(false);
});
});
+44
View File
@@ -0,0 +1,44 @@
// Walk-in (door) pricing.
//
// An event may set a separate price for people who buy at the door. It is an
// internal number: staff charge it on the door screen, but it never appears on
// the public event page, listings or JSON-LD, so every public serializer must
// drop it (see omitWalkInPrice).
//
// walkInPrice null -> not set, walk-ins pay the regular ticket price
// walkInPrice 0 -> free walk-in (a real value, distinct from null)
// walkInPrice n -> walk-ins pay n, in the event's currency
export type WalkInPriceSource = 'walk_in' | 'ticket';
/** Roles that may see an event's walk-in price: event managers and door staff. */
export const WALK_IN_PRICE_ROLES = ['admin', 'organizer', 'staff'] as const;
export function canSeeWalkInPrice(role: string | null | undefined): boolean {
return !!role && (WALK_IN_PRICE_ROLES as readonly string[]).includes(role);
}
/** Postgres decimals arrive as strings; null/undefined/garbage stay null. */
export function parseWalkInPrice(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const n = typeof value === 'string' ? parseFloat(value) : Number(value);
return Number.isFinite(n) ? n : null;
}
/** The per-ticket price a walk-in is charged, and where it came from. */
export function resolveWalkInPrice(event: { price: unknown; walkInPrice?: unknown }): {
unitPrice: number;
source: WalkInPriceSource;
} {
const walkIn = parseWalkInPrice(event.walkInPrice);
if (walkIn !== null) return { unitPrice: walkIn, source: 'walk_in' };
const price = typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price);
return { unitPrice: Number.isFinite(price) ? price : 0, source: 'ticket' };
}
/** Copy of an event row without its walk-in price, for anything a non-staff caller can read. */
export function omitWalkInPrice<T extends Record<string, any> | null | undefined>(event: T): T {
if (!event) return event;
const { walkInPrice: _omitted, ...rest } = event as Record<string, any>;
return rest as T;
}