Dev #38

Merged
Michilis merged 3 commits from dev into main 2026-10-03 04:55:16 +00:00
34 changed files with 1192 additions and 93 deletions
Showing only changes of commit b51c1360e2 - Show all commits
+55
View File
@@ -117,6 +117,11 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`); await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN walk_in_price REAL`);
} catch (e) { /* column may already exist */ }
// Add short description columns to events // Add short description columns to events
try { try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`); await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
@@ -297,6 +302,21 @@ async function migrate() {
) )
`); `);
// Migration: tickets.booking_source (online | walk_in | admin). On first run,
// backfill walk-ins created on the door screen: each one left an idempotency
// record whose undo_state is {kind: 'created', ticketId}. Nothing else can be
// identified reliably, so all other existing tickets stay 'online'.
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_source TEXT NOT NULL DEFAULT 'online'`);
await (db as any).run(sql`
UPDATE tickets SET booking_source = 'walk_in'
WHERE id IN (
SELECT json_extract(undo_state, '$.ticketId') FROM idempotency_keys
WHERE scope = 'door-checkin' AND json_extract(undo_state, '$.kind') = 'created'
)
`);
} catch (e) { /* column may already exist */ }
// Invoices table // Invoices table
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS invoices ( CREATE TABLE IF NOT EXISTS invoices (
@@ -392,6 +412,14 @@ async function migrate() {
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
} }
// POS card terminal at the door: on globally by default, NULL override = inherit
try {
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS contacts ( CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -773,6 +801,11 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`); await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN walk_in_price DECIMAL(10, 2)`);
} catch (e) { /* column may already exist */ }
// Add short description columns to events // Add short description columns to events
try { try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`); await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
@@ -913,6 +946,20 @@ async function migrate() {
) )
`); `);
// Migration: tickets.booking_source (online | walk_in | admin), with the
// same one-time walk-in backfill as the sqlite branch.
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_source VARCHAR(20) NOT NULL DEFAULT 'online'`);
await (db as any).execute(sql`
UPDATE tickets SET booking_source = 'walk_in'
WHERE id::text IN (
SELECT undo_state::json->>'ticketId' FROM idempotency_keys
WHERE scope = 'door-checkin' AND undo_state IS NOT NULL
AND undo_state::json->>'kind' = 'created'
)
`);
} catch (e) { /* column may already exist */ }
// Invoices table // Invoices table
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS invoices ( CREATE TABLE IF NOT EXISTS invoices (
@@ -1006,6 +1053,14 @@ async function migrate() {
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
} }
// POS card terminal at the door: on globally by default, NULL override = inherit
try {
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS contacts ( CREATE TABLE IF NOT EXISTS contacts (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
+18 -2
View File
@@ -82,6 +82,9 @@ export const sqliteEvents = sqliteTable('events', {
location: text('location').notNull(), location: text('location').notNull(),
locationUrl: text('location_url'), locationUrl: text('location_url'),
price: real('price').notNull().default(0), price: real('price').notNull().default(0),
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
// Admin/staff only: never serialized to public event responses.
walkInPrice: real('walk_in_price'),
currency: text('currency').notNull().default('PYG'), currency: text('currency').notNull().default('PYG'),
capacity: integer('capacity').notNull().default(50), capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
@@ -121,13 +124,16 @@ export const sqliteTickets = sqliteTable('tickets', {
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false), isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'), paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
// How the booking was made: public checkout, a walk-in on the door screen, or
// added by an admin. Distinct from payments.source, which is where money was taken.
bookingSource: text('booking_source', { enum: ['online', 'walk_in', 'admin'] }).notNull().default('online'),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
}); });
export const sqlitePayments = sqliteTable('payments', { export const sqlitePayments = sqliteTable('payments', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id), ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(), provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago', 'pos'] }).notNull(),
amount: real('amount').notNull(), amount: real('amount').notNull(),
currency: text('currency').notNull().default('PYG'), currency: text('currency').notNull().default('PYG'),
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'), status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
@@ -146,7 +152,7 @@ export const sqlitePayments = sqliteTable('payments', {
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'), source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments. // Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue. // 'guest' is a zero-amount comp entry and carries no revenue.
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }), method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
@@ -194,6 +200,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true), cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
cashInstructions: text('cash_instructions'), cashInstructions: text('cash_instructions'),
cashInstructionsEs: text('cash_instructions_es'), cashInstructionsEs: text('cash_instructions_es'),
// POS card terminal at the door (Scanner only, never offered at online checkout)
posEnabled: integer('pos_enabled', { mode: 'boolean' }).notNull().default(true),
// Booking settings // Booking settings
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false), allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
// Metadata // Metadata
@@ -226,6 +234,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
cashEnabled: integer('cash_enabled', { mode: 'boolean' }), cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
cashInstructions: text('cash_instructions'), cashInstructions: text('cash_instructions'),
cashInstructionsEs: text('cash_instructions_es'), cashInstructionsEs: text('cash_instructions_es'),
posEnabled: integer('pos_enabled', { mode: 'boolean' }),
// Metadata // Metadata
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
@@ -476,6 +485,9 @@ export const pgEvents = pgTable('events', {
location: varchar('location', { length: 500 }).notNull(), location: varchar('location', { length: 500 }).notNull(),
locationUrl: varchar('location_url', { length: 500 }), locationUrl: varchar('location_url', { length: 500 }),
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'), price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
// Admin/staff only: never serialized to public event responses.
walkInPrice: decimal('walk_in_price', { precision: 10, scale: 2 }),
currency: varchar('currency', { length: 10 }).notNull().default('PYG'), currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
capacity: pgInteger('capacity').notNull().default(50), capacity: pgInteger('capacity').notNull().default(50),
status: varchar('status', { length: 20 }).notNull().default('draft'), status: varchar('status', { length: 20 }).notNull().default('draft'),
@@ -515,6 +527,8 @@ export const pgTickets = pgTable('tickets', {
isGuest: pgInteger('is_guest').notNull().default(0), isGuest: pgInteger('is_guest').notNull().default(0),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'), paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
// online | walk_in | admin — see sqliteTickets.bookingSource
bookingSource: varchar('booking_source', { length: 20 }).notNull().default('online'),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
}); });
@@ -578,6 +592,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
cashEnabled: pgInteger('cash_enabled').notNull().default(1), cashEnabled: pgInteger('cash_enabled').notNull().default(1),
cashInstructions: pgText('cash_instructions'), cashInstructions: pgText('cash_instructions'),
cashInstructionsEs: pgText('cash_instructions_es'), cashInstructionsEs: pgText('cash_instructions_es'),
posEnabled: pgInteger('pos_enabled').notNull().default(1),
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0), allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id), updatedBy: uuid('updated_by').references(() => pgUsers.id),
@@ -607,6 +622,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
cashEnabled: pgInteger('cash_enabled'), cashEnabled: pgInteger('cash_enabled'),
cashInstructions: pgText('cash_instructions'), cashInstructions: pgText('cash_instructions'),
cashInstructionsEs: pgText('cash_instructions_es'), cashInstructionsEs: pgText('cash_instructions_es'),
posEnabled: pgInteger('pos_enabled'),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
+7 -4
View File
@@ -788,7 +788,7 @@ const openApiSpec = {
post: { post: {
tags: ['Tickets'], tags: ['Tickets'],
summary: 'Check in, settle payment, or create a walk-in (atomic)', summary: 'Check in, settle payment, or create a walk-in (atomic)',
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. Idempotent on idempotencyKey: replays return the original response instead of writing again.', description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. The server prices the charge from the event: walk-ins pay the walk-in price (or the ticket price when none is set) x quantity, existing tickets the ticket price x quantity. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
parameters: [ parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
@@ -817,8 +817,10 @@ const openApiSpec = {
type: 'object', type: 'object',
required: ['method'], required: ['method'],
properties: { properties: {
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] }, method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] },
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' }, quantity: { type: 'integer', minimum: 1, maximum: 50, description: 'Tickets paid for in one go (defaults to 1). The server multiplies it by the resolved unit price.' },
amount: { type: 'number', description: 'Ignored unless amountOverride is true.' },
amountOverride: { type: 'boolean', description: 'Charge `amount` instead of the computed price. Admin/organizer only; written to audit_logs.' },
}, },
}, },
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] }, entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
@@ -831,7 +833,8 @@ const openApiSpec = {
responses: { responses: {
201: { description: 'Attendee checked in; warnings may contain at_capacity' }, 201: { description: 'Attendee checked in; warnings may contain at_capacity' },
200: { description: 'Replay of an already-processed idempotencyKey' }, 200: { description: 'Replay of an already-processed idempotencyKey' },
400: { description: 'Ticket belongs to a different event' }, 400: { description: 'Ticket belongs to a different event, or the payment method is not enabled for this event' },
403: { description: 'amountOverride sent by a role that may not override the door amount' },
404: { description: 'Event or ticket not found' }, 404: { description: 'Event or ticket not found' },
}, },
}, },
+29 -7
View File
@@ -1,6 +1,6 @@
// Door payment tenders. // 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 // existing payments.provider so the rest of the app (capacity, sweeps, admin
// payment lists, receipts) keeps working unchanged, while payments.method // payment lists, receipts) keeps working unchanged, while payments.method
// records which tender was actually used for the end-of-night cash-up. // 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 // 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, // Lightning flow lands it slots in here: the tender keeps its name and provider,
// only the settlement path in routes/door.ts changes. // 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]; export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
interface DoorTender { interface DoorTender {
/** Existing payments.provider this tender is stored as. */ /** 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. */ /** Human label used in payment references and toasts. */
label: string; label: string;
/** Comp tenders carry no revenue and always record a zero amount. */ /** Comp tenders carry no revenue and always record a zero amount. */
isComp: boolean; 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> = { export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
cash: { provider: 'cash', label: 'cash', isComp: false }, cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false }, bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false }, transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' },
guest: { provider: 'cash', label: 'guest', isComp: true }, 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 { export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value); 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 eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = { const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', 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' }, es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
}; };
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); 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. // settlement) and the booking is auto-approved on success. No admin involved.
// Currently Lightning; future online gateways (e.g. Stripe) go here. // Currently Lightning; future online gateways (e.g. Stripe) go here.
// - 'manual': a human must verify the money arrived (TPago, bank transfer, // - 'manual': a human must verify the money arrived (TPago, bank transfer,
// card handled offline, cash at the door). These are never auto-confirmed // card handled offline, cash or the POS terminal at the door). These are
// and never auto-failed; an admin settles them by hand. Bank transfer and // 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 // TPago additionally expose an online "I've paid" step that moves the
// payment to 'pending_approval'. // payment to 'pending_approval'.
// //
@@ -22,6 +22,8 @@ export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> =
bank_transfer: { kind: 'manual' }, bank_transfer: { kind: 'manual' },
card: { kind: 'manual' }, card: { kind: 'manual' },
cash: { 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( 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;
}
+1
View File
@@ -519,6 +519,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
pos: filteredPayments.filter((p: any) => p.provider === 'pos' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
}, },
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length, paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length, pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
+3 -2
View File
@@ -7,6 +7,7 @@ import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, t
import { auth } from '../lib/betterAuth.js'; import { auth } from '../lib/betterAuth.js';
import { authSessions, authAccounts } from '../db/auth-schema.js'; import { authSessions, authAccounts } from '../db/auth-schema.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
import { omitWalkInPrice } from '../lib/walkInPrice.js';
const dashboard = new Hono(); const dashboard = new Hono();
@@ -208,7 +209,7 @@ dashboard.get('/tickets/:id', async (c) => {
return c.json({ return c.json({
ticket: { ticket: {
...ticket, ...ticket,
event, event: omitWalkInPrice(event as any),
payment, payment,
invoice, invoice,
}, },
@@ -272,7 +273,7 @@ dashboard.get('/next-event', async (c) => {
return c.json({ return c.json({
nextEvent: { nextEvent: {
event: nextEvent, event: omitWalkInPrice(nextEvent),
ticket: nextTicket, ticket: nextTicket,
payment: nextPayment, payment: nextPayment,
}, },
+225 -1
View File
@@ -223,7 +223,7 @@ describe('door-checkin: existing ticket', () => {
it('takes a group payment at a multiple of the ticket price', async () => { it('takes a group payment at a multiple of the ticket price', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid-2', ticketId: 'tkt-unpaid-2',
payment: { method: 'transfer', amount: PRICE * 2 }, payment: { method: 'transfer', quantity: 2 },
idempotencyKey: 'key-unpaid-2-transfer', idempotencyKey: 'key-unpaid-2-transfer',
}); });
expect(body.payment.amount).toBe(PRICE * 2); expect(body.payment.amount).toBe(PRICE * 2);
@@ -441,3 +441,227 @@ describe('door-summary', () => {
expect(body.door.lines[0]).toHaveProperty('name'); expect(body.door.lines[0]).toHaveProperty('name');
}); });
}); });
// ==================== Walk-in price & POS ====================
// Separate events so these cannot drift into the door-summary totals above.
const WALKIN_EVENT_ID = 'evt-door-walkin';
const WALKIN_PRICE = 25000;
const WALKIN_TICKET_PRICE = 21000;
const FREE_WALKIN_EVENT_ID = 'evt-door-free-walkin';
const NO_POS_EVENT_ID = 'evt-door-no-pos';
describe('walk-in pricing', () => {
beforeAll(() => {
const now = new Date().toISOString();
const insertEvent = sqlite.prepare(
`INSERT INTO events (id, title, description, start_datetime, location, price, walk_in_price, currency, capacity, status, created_at, updated_at)
VALUES (?, ?, 'desc', ?, 'Asuncion', ?, ?, 'PYG', 100, 'published', ?, ?)`
);
insertEvent.run(WALKIN_EVENT_ID, 'Walk-in Night', now, WALKIN_TICKET_PRICE, WALKIN_PRICE, now, now);
insertEvent.run(FREE_WALKIN_EVENT_ID, 'Free Door Night', now, WALKIN_TICKET_PRICE, 0, now, now);
insertEvent.run(NO_POS_EVENT_ID, 'No POS Night', now, WALKIN_TICKET_PRICE, null, now, now);
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'admin', 1, 'active', ?, ?)`
)
.run(ADMIN.id, 'admin@test.py', ADMIN.name, now, now);
sqlite
.prepare(
`INSERT INTO event_payment_overrides (id, event_id, pos_enabled, created_at, updated_at)
VALUES ('ovr-no-pos', ?, 0, ?, ?)`
)
.run(NO_POS_EVENT_ID, now, now);
sqlite
.prepare(
`INSERT INTO tickets (id, user_id, event_id, attendee_first_name, status, payment_status, is_guest, qr_code, created_at)
VALUES ('tkt-walkin-event-unpaid', 'seed-user', ?, 'Pre', 'confirmed', 'unpaid', 0, 'QR-walkin-unpaid', ?)`
)
.run(WALKIN_EVENT_ID, now);
});
it('resolves the walk-in unit price on the server for the door screen', async () => {
const withPrice = await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`);
expect(withPrice.body.event).toMatchObject({
price: WALKIN_TICKET_PRICE,
walkInPrice: WALKIN_PRICE,
walkInUnitPrice: WALKIN_PRICE,
walkInPriceSource: 'walk_in',
});
const fallback = await get(`/api/events/${EVENT_ID}/door-attendees`);
expect(fallback.body.event).toMatchObject({
walkInPrice: null,
walkInUnitPrice: PRICE,
walkInPriceSource: 'ticket',
});
const free = await get(`/api/events/${FREE_WALKIN_EVENT_ID}/door-attendees`);
expect(free.body.event).toMatchObject({ walkInPrice: 0, walkInUnitPrice: 0, walkInPriceSource: 'walk_in' });
});
it('charges walk-ins the walk-in price and marks them as walk-in bookings', async () => {
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Door', lastName: 'Buyer' },
payment: { method: 'cash' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-price-cash',
});
expect(status).toBe(201);
expect(body.payment).toMatchObject({ method: 'cash', amount: WALKIN_PRICE, currency: 'PYG', amountOverridden: false });
const ticket = sqlite.prepare('SELECT booking_source FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.booking_source).toBe('walk_in');
const payment = sqlite.prepare('SELECT amount, currency, source FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment).toMatchObject({ amount: WALKIN_PRICE, currency: 'PYG', source: 'door' });
});
it('treats a walk-in price of 0 as a free walk-in, not as "unset"', async () => {
const { body } = await post(`/api/events/${FREE_WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Free' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-free',
});
expect(body.payment.amount).toBe(0);
});
it('multiplies the resolved price by quantity', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Group' },
payment: { method: 'cash', quantity: 3 },
idempotencyKey: 'key-walkin-price-group',
});
expect(body.payment.amount).toBe(WALKIN_PRICE * 3);
});
it('ignores a client-sent amount without the override flag', async () => {
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Cheap' },
payment: { method: 'cash', amount: 1 },
idempotencyKey: 'key-walkin-client-amount',
});
expect(status).toBe(201);
expect(body.payment.amount).toBe(WALKIN_PRICE);
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(WALKIN_PRICE);
});
it('refuses an amount override from door staff and writes nothing', async () => {
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Discount' },
payment: { method: 'cash', amount: 1000, amountOverride: true },
idempotencyKey: 'key-walkin-staff-override',
});
expect(status).toBe(403);
expect(body.code).toBe('OVERRIDE_FORBIDDEN');
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
});
it('lets an admin override the amount and records it in the audit log', async () => {
const { status, body } = await as(ADMIN, () => post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Friend' },
payment: { method: 'cash', amount: 10000, amountOverride: true },
idempotencyKey: 'key-walkin-admin-override',
}));
expect(status).toBe(201);
expect(body.payment).toMatchObject({ amount: 10000, amountOverridden: true });
const log = sqlite
.prepare(`SELECT * FROM audit_logs WHERE action = 'door_amount_override' AND target_id = ?`)
.get(body.payment.id);
expect(log.user_id).toBe(ADMIN.id);
expect(JSON.parse(log.details)).toMatchObject({
eventId: WALKIN_EVENT_ID,
computedAmount: WALKIN_PRICE,
chargedAmount: 10000,
currency: 'PYG',
});
});
it('settles an existing unpaid ticket at the ticket price, not the walk-in price', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
ticketId: 'tkt-walkin-event-unpaid',
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-event-existing',
});
expect(body.payment.amount).toBe(WALKIN_TICKET_PRICE);
});
it('keeps the charged amount when the walk-in price is edited afterwards', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Snapshot' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-snapshot',
});
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(99000, WALKIN_EVENT_ID);
try {
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(WALKIN_PRICE);
} finally {
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(WALKIN_PRICE, WALKIN_EVENT_ID);
}
});
});
describe('POS tender', () => {
it('is offered on the door screen unless switched off for the event', async () => {
expect((await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`)).body.doorMethods)
.toEqual(['cash', 'bitcoin', 'transfer', 'pos', 'guest']);
expect((await get(`/api/events/${NO_POS_EVENT_ID}/door-attendees`)).body.doorMethods)
.toEqual(['cash', 'bitcoin', 'transfer', 'guest']);
});
it('records a confirmed POS walk-in as a paid door payment at the walk-in price', async () => {
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Card', lastName: 'Payer' },
payment: { method: 'pos' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-pos',
});
expect(status).toBe(201);
expect(body.attendee).toMatchObject({ checkedIn: true, paymentStatus: 'paid', doorMethod: 'pos' });
expect(body.payment).toMatchObject({ method: 'pos', amount: WALKIN_PRICE });
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment).toMatchObject({
provider: 'pos',
method: 'pos',
source: 'door',
status: 'paid',
amount: WALKIN_PRICE,
paid_by_admin_id: STAFF.id,
reference: 'Door — paid by POS',
});
});
it('shows POS takings in the door summary', async () => {
const { body } = await as(ADMIN, () => get(`/api/events/${WALKIN_EVENT_ID}/door-summary`));
expect(body.door.byMethod.pos).toEqual({ count: 1, total: WALKIN_PRICE });
});
it('can be undone like any other walk-in', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Declined' },
payment: { method: 'pos' },
idempotencyKey: 'key-walkin-pos-undo',
});
await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-walkin-pos-undo' });
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.status).toBe('cancelled');
});
it('is rejected when POS is disabled for the event', async () => {
const { status, body } = await post(`/api/events/${NO_POS_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Nope' },
payment: { method: 'pos' },
idempotencyKey: 'key-walkin-pos-disabled',
});
expect(status).toBe(400);
expect(body.code).toBe('METHOD_DISABLED');
});
});
+93 -10
View File
@@ -21,7 +21,8 @@ import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { eq, and, inArray, sql } from 'drizzle-orm'; import { eq, and, inArray, sql } from 'drizzle-orm';
import { import {
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
paymentOptions, eventPaymentOverrides,
} from '../db/index.js'; } from '../db/index.js';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
@@ -29,8 +30,9 @@ import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js
import { seatHolderCountQuery } from '../lib/capacity.js'; import { seatHolderCountQuery } from '../lib/capacity.js';
import { import {
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference, DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
paymentStatusForMethod, type DoorPaymentMethod, paymentStatusForMethod, enabledDoorMethods, type DoorPaymentMethod,
} from '../lib/doorPayments.js'; } from '../lib/doorPayments.js';
import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
const doorRouter = new Hono(); const doorRouter = new Hono();
@@ -41,6 +43,11 @@ const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
// never see what the event took overall. Matches the existing convention for // never see what the event took overall. Matches the existing convention for
// revenue aggregates (admin/export/financial, admin/analytics). // revenue aggregates (admin/export/financial, admin/analytics).
const REVENUE_ROLES = ['admin', 'organizer'] as const; const REVENUE_ROLES = ['admin', 'organizer'] as const;
// The server prices every door charge from the event record. Only the roles the
// app treats as administrators may override that with a typed amount, and every
// override is written to audit_logs.
const AMOUNT_OVERRIDE_ROLES = ['admin', 'organizer'] as const;
const MAX_DOOR_QUANTITY = 50;
const IDEMPOTENCY_SCOPE = 'door-checkin'; const IDEMPOTENCY_SCOPE = 'door-checkin';
// ==================== Shared helpers ==================== // ==================== Shared helpers ====================
@@ -99,10 +106,24 @@ async function loadEvent(eventId: string | undefined) {
return { return {
...event, ...event,
price: num(event.price), price: num(event.price),
walkInPrice: parseWalkInPrice(event.walkInPrice),
capacity: Number(event.capacity), capacity: Number(event.capacity),
}; };
} }
/** Door tenders available for this event (POS can be switched off per event). */
async function loadDoorMethods(eventId: string): Promise<DoorPaymentMethod[]> {
const [globalOptions, overrides] = await Promise.all([
dbGet<any>((db as any).select().from(paymentOptions)),
dbGet<any>(
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
),
]);
// Override wins when set; POS defaults to on when nothing is configured.
const posEnabled = overrides?.posEnabled ?? globalOptions?.posEnabled ?? true;
return enabledDoorMethods({ posEnabled: posEnabled === true || posEnabled === 1 });
}
/** Names of the admins/staff referenced by the given check-in rows, in one query. */ /** Names of the admins/staff referenced by the given check-in rows, in one query. */
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> { async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
const unique = [...new Set(adminIds.filter(Boolean))]; const unique = [...new Set(adminIds.filter(Boolean))];
@@ -164,6 +185,9 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
); );
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method); for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
const enabledMethods = await loadDoorMethods(event.id);
const walkIn = resolveWalkInPrice(event);
const attendees = rows const attendees = rows
.map((t: any) => toDoorAttendee(t, { .map((t: any) => toDoorAttendee(t, {
price: event.price, price: event.price,
@@ -181,9 +205,15 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
id: event.id, id: event.id,
title: event.title, title: event.title,
price: event.price, price: event.price,
// What one walk-in ticket costs, resolved server-side (walk-in price, or
// the ticket price when none is set) so the screen shows what will be charged.
walkInPrice: event.walkInPrice,
walkInUnitPrice: walkIn.unitPrice,
walkInPriceSource: walkIn.source,
currency: event.currency, currency: event.currency,
capacity: event.capacity, capacity: event.capacity,
}, },
doorMethods: enabledMethods,
attendees, attendees,
stats: { checkedIn, totalActive, capacity: event.capacity }, stats: { checkedIn, totalActive, capacity: event.capacity },
}); });
@@ -204,9 +234,16 @@ const doorCheckinSchema = z.object({
}).optional(), }).optional(),
payment: z.object({ payment: z.object({
method: z.enum(DOOR_PAYMENT_METHODS), method: z.enum(DOOR_PAYMENT_METHODS),
// Omitted means "one ticket at event price"; a multiple covers someone // How many tickets' worth is being paid (someone paying for their group).
// paying for their whole group in one go. // The server prices it: walk-in price for walk-ins, ticket price otherwise.
quantity: z.number().int().min(1).max(MAX_DOOR_QUANTITY).optional(),
// A typed amount is ignored unless amountOverride is set, which only
// admin/organizer may do.
amount: z.number().min(0).optional(), amount: z.number().min(0).optional(),
amountOverride: z.boolean().optional(),
}).refine((p) => !p.amountOverride || typeof p.amount === 'number', {
message: 'amount is required when amountOverride is set',
path: ['amount'],
}).optional(), }).optional(),
// How the attendee reached this action, for the session feed. // How the attendee reached this action, for the session feed.
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(), entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
@@ -255,19 +292,35 @@ doorRouter.post(
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt }); return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
} }
const amountOverride = !!data.payment?.amountOverride;
if (amountOverride && !(AMOUNT_OVERRIDE_ROLES as readonly string[]).includes(adminUser?.role)) {
return c.json({ error: 'Only an admin can override the door amount', code: 'OVERRIDE_FORBIDDEN' }, 403);
}
const event = await loadEvent(eventId); const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404); if (!event) return c.json({ error: 'Event not found' }, 404);
const method = data.payment?.method as DoorPaymentMethod | undefined;
if (method && !(await loadDoorMethods(event.id)).includes(method)) {
return c.json({ error: `${DOOR_TENDERS[method].label} is not enabled for this event`, code: 'METHOD_DISABLED' }, 400);
}
const now = getNow(); const now = getNow();
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const method = data.payment?.method as DoorPaymentMethod | undefined; const quantity = data.payment?.quantity ?? 1;
const requestedAmount = data.payment?.amount ?? event.price; // Walk-ins pay the walk-in price (falling back to the ticket price); an
// existing ticket settles the balance at the price it was booked at.
const unitPrice = data.ticketId ? event.price : resolveWalkInPrice(event).unitPrice;
const computedAmount = unitPrice * quantity;
const requestedAmount = amountOverride ? data.payment!.amount! : computedAmount;
const ops: TxOp[] = []; const ops: TxOp[] = [];
let undoState: UndoState; let undoState: UndoState;
let action: 'checkin' | 'walkin'; let action: 'checkin' | 'walkin';
let ticketRow: any; let ticketRow: any;
let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null; let paymentSummary: {
id: string; method: DoorPaymentMethod; amount: number; currency: string; amountOverridden: boolean;
} | null = null;
let emailTicketId: string | null = null; let emailTicketId: string | null = null;
if (data.ticketId) { if (data.ticketId) {
@@ -326,7 +379,7 @@ doorRouter.post(
method, method,
updatedAt: now, updatedAt: now,
}, eq((payments as any).id, existingPayment.id))); }, eq((payments as any).id, existingPayment.id)));
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency }; paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
} else { } else {
const paymentId = generateId(); const paymentId = generateId();
undo.createdPaymentId = paymentId; undo.createdPaymentId = paymentId;
@@ -345,7 +398,7 @@ doorRouter.post(
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})); }));
paymentSummary = { id: paymentId, method, amount, currency: event.currency }; paymentSummary = { id: paymentId, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
} }
} }
@@ -429,6 +482,7 @@ doorRouter.post(
checkinAt: now, checkinAt: now,
checkedInByAdminId: adminUser?.id || null, checkedInByAdminId: adminUser?.id || null,
adminNote: null, adminNote: null,
bookingSource: 'walk_in',
createdAt: now, createdAt: now,
}; };
ops.push(insertOp(tickets, newTicket)); ops.push(insertOp(tickets, newTicket));
@@ -448,13 +502,35 @@ doorRouter.post(
updatedAt: now, updatedAt: now,
})); }));
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency }; paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
undoState = { kind: 'created', ticketId, paymentId }; undoState = { kind: 'created', ticketId, paymentId };
ticketRow = newTicket; ticketRow = newTicket;
// Only mail people who actually gave an address; no QR for the rest. // Only mail people who actually gave an address; no QR for the rest.
if (hasEmail) emailTicketId = ticketId; if (hasEmail) emailTicketId = ticketId;
} }
// Written in the same transaction as the payment, so the log can never
// disagree with what was recorded.
if (paymentSummary?.amountOverridden) {
ops.push(insertOp(auditLogs, {
id: generateId(),
userId: adminUser?.id || null,
action: 'door_amount_override',
target: 'payment',
targetId: paymentSummary.id,
details: JSON.stringify({
eventId,
ticketId: ticketRow.id,
method: paymentSummary.method,
quantity,
computedAmount,
chargedAmount: paymentSummary.amount,
currency: event.currency,
}),
timestamp: now,
}));
}
// Staff at the door is the authority: a full event is a warning, never a block. // Staff at the door is the authority: a full event is a warning, never a block.
const held = await seatsHeld(eventId); const held = await seatsHeld(eventId);
const atCapacity = event.capacity > 0 && held >= event.capacity; const atCapacity = event.capacity > 0 && held >= event.capacity;
@@ -496,6 +572,13 @@ doorRouter.post(
throw err; throw err;
} }
if (paymentSummary?.amountOverridden) {
console.info(
`[Door] Amount override by ${adminUser?.id} (${adminUser?.role}) on event ${eventId}: ` +
`${paymentSummary.amount} ${event.currency} instead of ${computedAmount} (${paymentSummary.method})`
);
}
if (emailTicketId) { if (emailTicketId) {
emailService.sendBookingConfirmation(emailTicketId).catch((err) => { emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
console.error('[Email] Failed to send door walk-in confirmation:', err); console.error('[Email] Failed to send door walk-in confirmation:', err);
+53 -8
View File
@@ -9,6 +9,7 @@ import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js'; import { revalidateFrontendCache } from '../lib/revalidate.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js'; import { eventSeatBreakdownQuery } from '../lib/capacity.js';
import { resolvePresaleClosure } from '../lib/presale.js'; import { resolvePresaleClosure } from '../lib/presale.js';
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
interface UserContext { interface UserContext {
id: string; 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 // `settings` is the site_settings row; when given, the effective pre-sale
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the // cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
// booking API agree on when registration closes. // 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; if (!event) return event;
const { walkInPrice, ...publicFields } = event;
const normalized = { const normalized = {
...event, ...publicFields,
...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}),
// Convert price from string/decimal to clean number // Convert price from string/decimal to clean number
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price), price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
// Convert capacity from string to number if needed // Convert capacity from string to number if needed
@@ -114,6 +119,29 @@ const parsePrice = (val: unknown): number => {
return 0; 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) // Helper to normalize boolean (handles true/false and 0/1)
const normalizeBoolean = (val: unknown): boolean => { const normalizeBoolean = (val: unknown): boolean => {
if (typeof val === 'boolean') return val; if (typeof val === 'boolean') return val;
@@ -137,6 +165,7 @@ const baseEventSchema = z.object({
locationUrl: z.string().url().optional().nullable().or(z.literal('')), locationUrl: z.string().url().optional().nullable().or(z.literal('')),
// Accept price as number or string (handles "45000" and "41,44" formats) // 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), price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
walkInPrice: walkInPriceSchema,
currency: z.string().default('PYG'), 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), 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'), 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. // any client-supplied status filter, so drafts cannot leak.
const authUser: any = await getAuthUser(c); const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
const includeWalkInPrice = canSeeWalkInPrice(authUser?.role);
const conditions: any[] = []; const conditions: any[] = [];
@@ -271,7 +301,7 @@ eventsRouter.get('/', async (c) => {
const siteSettingsRow = await getSiteSettingsRow(); const siteSettingsRow = await getSiteSettingsRow();
const eventsWithCounts = result.map((event: any) => { 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 }; const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return { return {
...normalized, ...normalized,
@@ -295,16 +325,19 @@ eventsRouter.get('/:id', async (c) => {
return c.json({ error: 'Event not found' }, 404); 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. // Draft events are only visible to privileged users (admin preview); hide from public.
if ((event as any).status === 'draft') { if ((event as any).status === 'draft') {
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
if (!isPrivileged) { if (!isPrivileged) {
return c.json({ error: 'Event not found' }, 404); 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); const counts = await getEventSeatCounts(event.id);
return c.json({ return c.json({
event: { event: {
@@ -460,6 +493,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
const siteSettingsRow = await getSiteSettingsRow(); const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow); const tz = siteTimezoneOf(siteSettingsRow);
const walkInError = walkInPriceError(data.walkInPrice, data.currency);
if (walkInError) return c.json({ error: walkInError }, 400);
// Convert data for database compatibility // Convert data for database compatibility
const dbData = convertBooleansForDb(data); const dbData = convertBooleansForDb(data);
@@ -483,7 +519,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
revalidateFrontendCache(); revalidateFrontendCache();
// Return normalized event data // 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) // 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); 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 now = getNow();
const siteSettingsRow = await getSiteSettingsRow(); const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow); 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) // Revalidate sitemap when an event is updated (status/dates may have changed)
revalidateFrontendCache(); revalidateFrontendCache();
return c.json({ event: normalizeEvent(updated, siteSettingsRow) }); return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) });
}); });
// Delete event (admin only) // Delete event (admin only)
@@ -670,6 +714,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
location: existing.location, location: existing.location,
locationUrl: existing.locationUrl, locationUrl: existing.locationUrl,
price: existing.price, price: existing.price,
walkInPrice: existing.walkInPrice ?? null,
currency: existing.currency, currency: existing.currency,
capacity: existing.capacity, capacity: existing.capacity,
status: 'draft', status: 'draft',
@@ -684,7 +729,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
await (db as any).insert(events).values(duplicatedEvent); 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) // List slug aliases for an event (admin/organizer only)
@@ -0,0 +1,180 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// The walk-in price is internal: admins set it, door staff charge it, and no
// public event response may carry it. These tests pin both halves.
// Env must be pinned before the db singleton is imported (dotenv never overrides).
const dir = mkdtempSync(join(tmpdir(), 'events-walkin-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'events-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
delete process.env.REVALIDATE_SECRET;
type TestUser = { id: string; name: string; role: string } | null;
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const MEMBER = { id: 'member-user-id', name: 'Member', role: 'user' };
// Anonymous by default, like the public site and its server-side fetches.
let currentUser: TestUser = null;
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (!currentUser) return c.json({ error: 'Unauthorized' }, 401);
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
c.set('user', currentUser);
await next();
},
getAuthUser: async () => currentUser,
}));
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
let app: any;
let sqlite: any;
async function request(method: string, path: string, body?: unknown) {
const res = await app.request(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
const baseEvent = {
title: 'Walk-in Test',
description: 'desc',
startDatetime: '2099-01-10T20:00',
location: 'Asuncion',
price: 21000,
currency: 'PYG',
capacity: 40,
status: 'published',
};
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
const eventsRoutes = (await import('./events.js')).default;
app = new Hono();
app.route('/api/events', eventsRoutes);
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
})();
}, 120_000);
describe('admin event form: walk-in price', () => {
it('saves an empty walk-in price as null, not 0', async () => {
const { status, body } = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Empty walk-in', walkInPrice: '' })
);
expect(status).toBe(201);
expect(body.event.walkInPrice).toBeNull();
expect(sqlite.prepare('SELECT walk_in_price FROM events WHERE id = ?').get(body.event.id).walk_in_price).toBeNull();
});
it('saves 0 as a free walk-in and a number as-is', async () => {
const free = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Free walk-in', walkInPrice: 0 })
);
expect(free.body.event.walkInPrice).toBe(0);
const priced = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Priced walk-in', walkInPrice: '25000' })
);
expect(priced.body.event.walkInPrice).toBe(25000);
});
it('rejects negative, non-numeric and fractional PYG walk-in prices', async () => {
for (const walkInPrice of [-1, 'abc', 25000.5]) {
const { status } = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Bad walk-in', walkInPrice })
);
expect(status, `walkInPrice ${walkInPrice}`).toBe(400);
}
});
it('updates and clears the walk-in price, and duplicates carry it over', async () => {
const created = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Editable walk-in', walkInPrice: 25000 })
);
const id = created.body.event.id;
const updated = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 30000 }));
expect(updated.body.event.walkInPrice).toBe(30000);
const copy = await as(ADMIN, () => request('POST', `/api/events/${id}/duplicate`));
expect(copy.status).toBe(201);
expect(copy.body.event.walkInPrice).toBe(30000);
const cleared = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: null }));
expect(cleared.body.event.walkInPrice).toBeNull();
// Omitting the field leaves it untouched.
await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 0 }));
const untouched = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { capacity: 45 }));
expect(untouched.body.event.walkInPrice).toBe(0);
});
});
describe('public event responses', () => {
let slug: string;
let id: string;
beforeAll(async () => {
const { body } = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Secret Door Price', walkInPrice: 25000 })
);
slug = body.event.slug;
id = body.event.id;
});
it('never include the walk-in price for anonymous or regular users', async () => {
for (const user of [null, MEMBER]) {
await as(user, async () => {
const single = await request('GET', `/api/events/${slug}`);
expect(single.status).toBe(200);
expect(single.body.event).not.toHaveProperty('walkInPrice');
expect(single.body.event.price).toBe(21000);
const list = await request('GET', '/api/events');
const listed = list.body.events.find((e: any) => e.id === id);
expect(listed).toBeTruthy();
expect(listed).not.toHaveProperty('walkInPrice');
const next = await request('GET', '/api/events/next/upcoming');
expect(next.body.event).not.toHaveProperty('walkInPrice');
// Belt and braces: the value must not appear anywhere in the payload.
expect(JSON.stringify(single.body)).not.toContain('25000');
expect(JSON.stringify(list.body)).not.toContain('25000');
});
}
});
it('includes it for admin and door staff', async () => {
for (const user of [ADMIN, STAFF]) {
const single = await as(user, () => request('GET', `/api/events/${id}`));
expect(single.body.event.walkInPrice).toBe(25000);
}
});
});
+6
View File
@@ -36,6 +36,8 @@ const updatePaymentOptionsSchema = z.object({
cashEnabled: booleanOrNumber.optional(), cashEnabled: booleanOrNumber.optional(),
cashInstructions: z.string().optional().nullable(), cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(),
// POS terminal on the door screen
posEnabled: booleanOrNumber.optional(),
// Booking settings // Booking settings
allowDuplicateBookings: booleanOrNumber.optional(), allowDuplicateBookings: booleanOrNumber.optional(),
}); });
@@ -79,6 +81,7 @@ const updateEventOverridesSchema = z.object({
cashEnabled: booleanOrNumber.optional().nullable(), cashEnabled: booleanOrNumber.optional().nullable(),
cashInstructions: z.string().optional().nullable(), cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(),
posEnabled: booleanOrNumber.optional().nullable(),
}); });
// Get global payment options // Get global payment options
@@ -111,6 +114,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
cashEnabled: true, cashEnabled: true,
cashInstructions: null, cashInstructions: null,
cashInstructionsEs: null, cashInstructionsEs: null,
posEnabled: true,
allowDuplicateBookings: false, allowDuplicateBookings: false,
}, },
}); });
@@ -219,6 +223,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
cashEnabled: true, cashEnabled: true,
cashInstructions: null, cashInstructions: null,
cashInstructionsEs: null, cashInstructionsEs: null,
posEnabled: true,
}; };
const global = globalOptions || defaults; const global = globalOptions || defaults;
@@ -245,6 +250,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled, cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions, cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs, cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true,
}; };
// Full bank/TPago credentials are only returned when the caller proves they hold // Full bank/TPago credentials are only returned when the caller proves they hold
+2
View File
@@ -1508,6 +1508,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
qrCode, qrCode,
checkinAt: data.autoCheckin ? now : null, checkinAt: data.autoCheckin ? now : null,
adminNote: data.adminNote || null, adminNote: data.adminNote || null,
bookingSource: 'admin',
createdAt: now, createdAt: now,
}; };
@@ -1663,6 +1664,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
checkinAt: data.checkinNow ? now : null, checkinAt: data.checkinNow ? now : null,
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null, checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
adminNote: data.adminNote || null, adminNote: data.adminNote || null,
bookingSource: 'admin',
createdAt: now, createdAt: now,
}; };
+2 -1
View File
@@ -6,6 +6,7 @@ import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { authSessions } from '../db/auth-schema.js'; import { authSessions } from '../db/auth-schema.js';
import { getNow, toDbDate } from '../lib/utils.js'; import { getNow, toDbDate } from '../lib/utils.js';
import { omitWalkInPrice } from '../lib/walkInPrice.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -249,7 +250,7 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
return { return {
...ticket, ...ticket,
event, event: omitWalkInPrice(event as any),
}; };
}) })
); );
@@ -44,6 +44,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' }, lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
cash: { en: 'Cash', es: 'Efectivo' }, cash: { en: 'Cash', es: 'Efectivo' },
bancard: { en: 'Card', es: 'Tarjeta' }, bancard: { en: 'Card', es: 'Tarjeta' },
pos: { en: 'POS', es: 'POS' },
}; };
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider; return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
}; };
+5 -1
View File
@@ -177,6 +177,7 @@ export default function AdminBookingsPage() {
lightning: 'Lightning', lightning: 'Lightning',
tpago: 'TPago', tpago: 'TPago',
bancard: 'Bancard', bancard: 'Bancard',
pos: 'POS',
}; };
return labels[provider] || provider; return labels[provider] || provider;
}; };
@@ -443,7 +444,10 @@ export default function AdminBookingsPage() {
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}> <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
{ticket.payment?.status || 'pending'} {ticket.payment?.status || 'pending'}
</span> </span>
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p> <p className="text-xs text-gray-500 mt-0.5">
{getPaymentMethodLabel(getDisplayProvider(ticket))}
{ticket.bookingSource === 'walk_in' && (locale === 'es' ? ' · En puerta' : ' · Walk-in')}
</p>
{ticket.payment && ( {ticket.payment && (
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p> <p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
)} )}
@@ -25,6 +25,7 @@ const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }>
cash: { en: 'Cash', es: 'Efectivo' }, cash: { en: 'Cash', es: 'Efectivo' },
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' }, bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
transfer: { en: 'Transfer', es: 'Transferencia' }, transfer: { en: 'Transfer', es: 'Transferencia' },
pos: { en: 'POS', es: 'POS' },
guest: { en: 'Guests', es: 'Invitados' }, guest: { en: 'Guests', es: 'Invitados' },
}; };
@@ -103,6 +104,10 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
handleSavePaymentOptions, handleSavePaymentOptions,
} = payments; } = payments;
// Stored as 0/1 on Postgres; POS is on unless explicitly switched off.
const posEnabled = !!(getEffectivePaymentOption('posEnabled') ?? true);
const posDisabledGlobally = !!globalPaymentOptions && !(globalPaymentOptions.posEnabled ?? true);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{loadingPayments ? ( {loadingPayments ? (
@@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
</div> </div>
</Card> </Card>
{/* POS terminal at the door */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-violet-100 rounded-full flex items-center justify-center flex-shrink-0">
<CreditCardIcon className="w-4 h-4 text-violet-600" />
</div>
<div>
<h4 className="font-semibold text-sm">POS</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{posDisabledGlobally && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('posEnabled', !posEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
posEnabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</div>
</Card>
{/* Summary */} {/* Summary */}
<Card> <Card>
<div className="p-4 md:p-5"> <div className="p-4 md:p-5">
@@ -453,6 +494,7 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') }, { label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') }, { label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') }, { label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
{ label: 'POS', enabled: posEnabled },
].map((method) => ( ].map((method) => (
<div key={method.label} className="flex items-center gap-1.5"> <div key={method.label} className="flex items-center gap-1.5">
{method.enabled ? ( {method.enabled ? (
@@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils'; import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
interface EventFormData { interface EventFormData {
title: string; title: string;
@@ -25,6 +25,9 @@ interface EventFormData {
location: string; location: string;
locationUrl: string; locationUrl: string;
price: number; 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; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
@@ -47,12 +50,20 @@ const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '', title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '', shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', 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: '', bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled, presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore, 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 { function isoToLocalDatetime(isoString: string): string {
const date = parseDate(isoString); const date = parseDate(isoString);
const parts = new Intl.DateTimeFormat('en-US', { const parts = new Intl.DateTimeFormat('en-US', {
@@ -138,6 +149,7 @@ export default function EventFormModal({
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, locationUrl: event.locationUrl || '', location: event.location, locationUrl: event.locationUrl || '',
price: event.price, currency: event.currency, capacity: event.capacity, price: event.price, currency: event.currency, capacity: event.capacity,
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
status: event.status, bannerUrl: event.bannerUrl || '', status: event.status, bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false, externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '', externalBookingUrl: event.externalBookingUrl || '',
@@ -208,6 +220,11 @@ export default function EventFormModal({
setSaving(false); setSaving(false);
return; return;
} }
if (!isValidWalkInPrice(formData.walkInPrice)) {
toast.error(t('admin.events.form.walkInPriceInvalid'));
setSaving(false);
return;
}
const eventData: Partial<Event> = { const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined, title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined,
@@ -216,6 +233,8 @@ export default function EventFormModal({
endDatetime: formData.endDatetime || undefined, endDatetime: formData.endDatetime || undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined, location: formData.location, locationUrl: formData.locationUrl || undefined,
price: formData.price, currency: formData.currency, capacity: formData.capacity, 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, status: formData.status, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled, externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, 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} <Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} /> onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
<div className="grid grid-cols-3 gap-4"> <div>
<Input label="Price" type="number" min="0" value={formData.price} <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} /> <Input label="Price" type="number" min="0" value={formData.price}
<div> onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<label className="block text-sm font-medium mb-1">Currency</label> <Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })} value={formData.walkInPrice}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"> onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
<option value="PYG">PYG</option> placeholder={t('admin.events.form.walkInPricePlaceholder', {
<option value="USD">USD</option> price: formatPrice(formData.price || 0, formData.currency),
</select> })}
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> </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} <Input label="Capacity" type="number" min="1" value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} /> onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
</div> </div>
@@ -42,6 +42,7 @@ export default function PaymentOptionsPage() {
bankNotesEs: null, bankNotesEs: null,
lightningEnabled: true, lightningEnabled: true,
cashEnabled: true, cashEnabled: true,
posEnabled: true,
cashInstructions: null, cashInstructions: null,
cashInstructionsEs: null, cashInstructionsEs: null,
allowDuplicateBookings: false, allowDuplicateBookings: false,
@@ -83,6 +84,9 @@ export default function PaymentOptionsPage() {
setOptions((prev) => ({ ...prev, [key]: value })); setOptions((prev) => ({ ...prev, [key]: value }));
}; };
// Stored as 0/1 on Postgres; on unless explicitly switched off.
const posEnabled = !!(options.posEnabled ?? true);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex items-center justify-center min-h-[400px]">
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
</div> </div>
</Card> </Card>
{/* POS terminal at the door */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-violet-100 rounded-full flex items-center justify-center">
<CreditCardIcon className="w-5 h-5 text-violet-600" />
</div>
<div>
<h3 className="font-semibold text-lg">POS</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Terminal de tarjetas en la puerta. Solo en el Escáner; el personal confirma el pago manualmente.'
: 'Card terminal at the door. Scanner only; staff confirm the payment by hand.'}
</p>
</div>
</div>
<button
onClick={() => updateOption('posEnabled', !posEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
posEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</Card>
{/* Booking Settings */} {/* Booking Settings */}
<Card className="mb-6"> <Card className="mb-6">
<div className="p-6"> <div className="p-6">
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() {
{locale === 'es' ? 'Efectivo' : 'Cash'} {locale === 'es' ? 'Efectivo' : 'Cash'}
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{posEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={posEnabled ? 'text-gray-900' : 'text-gray-400'}>
POS
</span>
</div>
</div> </div>
</div> </div>
</Card> </Card>
+9 -1
View File
@@ -313,6 +313,7 @@ export default function AdminPaymentsPage() {
bank_transfer: BuildingLibraryIcon, bank_transfer: BuildingLibraryIcon,
tpago: CreditCardIcon, tpago: CreditCardIcon,
bancard: CreditCardIcon, bancard: CreditCardIcon,
pos: CreditCardIcon,
}; };
const Icon = icons[provider] || CreditCardIcon; const Icon = icons[provider] || CreditCardIcon;
return <Icon className="w-4 h-4" />; return <Icon className="w-4 h-4" />;
@@ -325,6 +326,7 @@ export default function AdminPaymentsPage() {
lightning: 'Lightning', lightning: 'Lightning',
tpago: 'TPago', tpago: 'TPago',
bancard: 'Bancard', bancard: 'Bancard',
pos: 'POS',
}; };
return labels[provider] || provider; return labels[provider] || provider;
}; };
@@ -736,7 +738,7 @@ export default function AdminPaymentsPage() {
{/* By Provider */} {/* By Provider */}
<div className="bg-secondary-gray rounded-btn p-4"> <div className="bg-secondary-gray rounded-btn p-4">
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3> <h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div> <div>
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p> <p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p> <p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
@@ -757,6 +759,10 @@ export default function AdminPaymentsPage() {
<p className="text-gray-500">Bancard</p> <p className="text-gray-500">Bancard</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p> <p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
</div> </div>
<div>
<p className="text-gray-500">POS</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG</p>
</div>
</div> </div>
</div> </div>
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() {
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option> <option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option> <option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option> <option value="tpago">TPago</option>
<option value="pos">POS</option>
</select> </select>
</div> </div>
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
@@ -1292,6 +1299,7 @@ export default function AdminPaymentsPage() {
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option> <option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option> <option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option> <option value="tpago">TPago</option>
<option value="pos">POS</option>
</select> </select>
</div> </div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
@@ -8,7 +8,7 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api'; import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { PaymentButtons } from './PaymentButtons'; import { PaymentButtons, type DoorCharge } from './PaymentButtons';
function checkinTime(checkinAt: string | null): string { function checkinTime(checkinAt: string | null): string {
if (!checkinAt) return ''; if (!checkinAt) return '';
@@ -23,6 +23,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'cash', cash: 'cash',
bitcoin: 'bitcoin', bitcoin: 'bitcoin',
transfer: 'transfer', transfer: 'transfer',
pos: 'POS',
guest: 'guest', guest: 'guest',
}; };
@@ -49,6 +50,8 @@ export function AttendeeRow({
attendee, attendee,
currency, currency,
price, price,
methods,
canOverrideAmount,
expanded, expanded,
flashing, flashing,
busy, busy,
@@ -58,11 +61,13 @@ export function AttendeeRow({
attendee: DoorAttendee; attendee: DoorAttendee;
currency: string; currency: string;
price: number; price: number;
methods: readonly DoorPaymentMethod[];
canOverrideAmount: boolean;
expanded: boolean; expanded: boolean;
flashing: boolean; flashing: boolean;
busy: boolean; busy: boolean;
onTap: () => void; onTap: () => void;
onPay: (method: DoorPaymentMethod, amount: number) => void; onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
}) { }) {
const isCancelled = attendee.status === 'cancelled'; const isCancelled = attendee.status === 'cancelled';
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp'; const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
@@ -141,7 +146,14 @@ export function AttendeeRow({
Reactivate as a walk-in — pick how they are paying. Reactivate as a walk-in — pick how they are paying.
</p> </p>
)} )}
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} /> <PaymentButtons
price={price}
currency={currency}
methods={methods}
canOverrideAmount={canOverrideAmount}
onPay={onPay}
disabled={busy}
/>
</div> </div>
)} )}
@@ -6,14 +6,29 @@ import {
BanknotesIcon, BanknotesIcon,
BoltIcon, BoltIcon,
BuildingLibraryIcon, BuildingLibraryIcon,
CreditCardIcon,
GiftIcon, GiftIcon,
ChevronDownIcon, ChevronDownIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { DoorPaymentMethod } from '@/lib/api'; import type { DoorPaymentMethod } from '@/lib/api';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { PosChargePanel } from './PosChargePanel';
// The four tenders staff can take at the door. One tap settles and checks in; // The tenders staff can take at the door. One tap settles and checks in;
// long-press (or the chevron) opens multiples for someone paying for their group. // long-press (or the chevron) opens multiples for someone paying for their group.
// POS is the exception: it opens a confirm step, because the card is charged on
// the terminal first (see PosChargePanel).
/**
* What staff chose to charge. The server prices it from quantity; `amount` is
* what the screen expects that to come to, and is only sent as a charge when
* `override` is set (admin/organizer "Custom").
*/
export interface DoorCharge {
quantity: number;
amount: number;
override?: boolean;
}
const TENDERS: { const TENDERS: {
method: DoorPaymentMethod; method: DoorPaymentMethod;
@@ -24,6 +39,7 @@ const TENDERS: {
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' }, { method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' }, { method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' }, { method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
{ method: 'pos', label: 'POS', icon: CreditCardIcon, className: 'bg-violet-600 active:bg-violet-700' },
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' }, { method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
]; ];
@@ -32,21 +48,32 @@ const LONG_PRESS_MS = 450;
export function PaymentButtons({ export function PaymentButtons({
price, price,
currency, currency,
methods,
canOverrideAmount,
onPay, onPay,
disabled, disabled,
}: { }: {
/** Unit price for one ticket, as the server will charge it. */
price: number; price: number;
currency: string; currency: string;
onPay: (method: DoorPaymentMethod, amount: number) => void; /** Tenders enabled for this event. */
methods: readonly DoorPaymentMethod[];
/** Admin/organizer may type a custom amount; the server enforces the same rule. */
canOverrideAmount: boolean;
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
disabled?: boolean; disabled?: boolean;
}) { }) {
// Which tender has its quick-amounts open. Guest is always free, so it never opens one. // Which tender has its quick-amounts open. Guest is always free, so it never opens one.
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null); const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
const [customOpen, setCustomOpen] = useState(false); const [customOpen, setCustomOpen] = useState(false);
const [customValue, setCustomValue] = useState(''); const [customValue, setCustomValue] = useState('');
// POS charge waiting for staff to confirm the terminal approved it.
const [posCharge, setPosCharge] = useState<DoorCharge | null>(null);
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null); const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const [longPressed, setLongPressed] = useState(false); const [longPressed, setLongPressed] = useState(false);
const tenders = TENDERS.filter((t) => methods.includes(t.method));
const openAmounts = (method: DoorPaymentMethod) => { const openAmounts = (method: DoorPaymentMethod) => {
if (method === 'guest') return; if (method === 'guest') return;
setAmountsFor(method); setAmountsFor(method);
@@ -54,6 +81,15 @@ export function PaymentButtons({
setCustomValue(''); setCustomValue('');
}; };
// Every tender settles immediately except POS, which stops at its confirm step.
const charge = (method: DoorPaymentMethod, next: DoorCharge) => {
if (method === 'pos') {
setPosCharge(next);
return;
}
onPay(method, next);
};
const startPress = (method: DoorPaymentMethod) => { const startPress = (method: DoorPaymentMethod) => {
setLongPressed(false); setLongPressed(false);
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -72,7 +108,7 @@ export function PaymentButtons({
return; return;
} }
if (disabled) return; if (disabled) return;
onPay(method, method === 'guest' ? 0 : price); charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price });
}; };
const cancelPress = () => { const cancelPress = () => {
@@ -81,6 +117,19 @@ export function PaymentButtons({
setLongPressed(false); setLongPressed(false);
}; };
if (posCharge) {
return (
<PosChargePanel
charge={posCharge}
unitPrice={price}
currency={currency}
disabled={disabled}
onConfirm={() => onPay('pos', posCharge)}
onBack={() => setPosCharge(null)}
/>
);
}
if (amountsFor) { if (amountsFor) {
const tender = TENDERS.find((t) => t.method === amountsFor)!; const tender = TENDERS.find((t) => t.method === amountsFor)!;
return ( return (
@@ -94,12 +143,12 @@ export function PaymentButtons({
Back Back
</button> </button>
</div> </div>
<div className="grid grid-cols-4 gap-2"> <div className={clsx('grid gap-2', canOverrideAmount ? 'grid-cols-4' : 'grid-cols-3')}>
{[1, 2, 3].map((qty) => ( {[1, 2, 3].map((qty) => (
<button <button
key={qty} key={qty}
disabled={disabled} disabled={disabled}
onClick={() => onPay(tender.method, price * qty)} onClick={() => charge(tender.method, { quantity: qty, amount: price * qty })}
className={clsx( className={clsx(
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform', 'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className, tender.className,
@@ -111,15 +160,17 @@ export function PaymentButtons({
</span> </span>
</button> </button>
))} ))}
<button {canOverrideAmount && (
disabled={disabled} <button
onClick={() => setCustomOpen((open) => !open)} disabled={disabled}
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform" onClick={() => setCustomOpen((open) => !open)}
> className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
Custom >
</button> Custom
</button>
)}
</div> </div>
{customOpen && ( {canOverrideAmount && customOpen && (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="number" type="number"
@@ -132,7 +183,7 @@ export function PaymentButtons({
/> />
<button <button
disabled={disabled || !customValue || Number(customValue) < 0} disabled={disabled || !customValue || Number(customValue) < 0}
onClick={() => onPay(tender.method, Number(customValue))} onClick={() => charge(tender.method, { quantity: 1, amount: Number(customValue), override: true })}
className={clsx( className={clsx(
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform', 'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className, tender.className,
@@ -147,8 +198,11 @@ export function PaymentButtons({
} }
return ( return (
<div className="grid grid-cols-4 gap-2"> <div
{TENDERS.map((tender) => ( className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${tenders.length}, minmax(0, 1fr))` }}
>
{tenders.map((tender) => (
<button <button
key={tender.method} key={tender.method}
disabled={disabled} disabled={disabled}
@@ -0,0 +1,70 @@
'use client';
import { CreditCardIcon } from '@heroicons/react/24/outline';
import { formatCurrency } from '@/lib/utils';
import type { DoorCharge } from './PaymentButtons';
// The POS step of the door screen.
//
// Unlike cash, the money is not in staff's hand when they pick POS: they first
// key the amount into the physical card terminal, wait for approval, and only
// then confirm here. So POS is two taps: pick POS (this panel opens with the
// amount to type), then "Mark as paid", which records it through the same
// door-checkin call as every other tender.
//
// Nothing talks to the terminal today. If an automatic amount push is added
// later, it starts when this panel opens and its approval replaces (or pre-fills)
// the manual confirmation below; the rest of the door flow stays unchanged.
export function PosChargePanel({
charge,
unitPrice,
currency,
disabled,
onConfirm,
onBack,
}: {
charge: DoorCharge;
unitPrice: number;
currency: string;
disabled?: boolean;
onConfirm: () => void;
onBack: () => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<p className="text-sm font-semibold text-white flex items-center gap-1.5">
<CreditCardIcon className="w-5 h-5" />
POS — charge on the terminal
</p>
<button onClick={onBack} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
Back
</button>
</div>
<div className="rounded-2xl bg-gray-900 border border-violet-500/40 px-4 py-3 text-center">
<p className="text-[11px] uppercase tracking-wide text-gray-400">Amount to charge</p>
<p className="text-3xl font-bold text-white tabular-nums">{formatCurrency(charge.amount, currency)}</p>
{charge.override ? (
<p className="text-xs text-amber-400 mt-0.5">Custom amount</p>
) : charge.quantity > 1 ? (
<p className="text-xs text-gray-400 mt-0.5">
{charge.quantity} × {formatCurrency(unitPrice, currency)}
</p>
) : null}
</div>
<button
disabled={disabled}
onClick={onConfirm}
className="w-full min-h-[56px] rounded-2xl bg-violet-600 active:bg-violet-700 text-white text-lg font-bold disabled:opacity-50 active:scale-[0.98] transition-transform"
>
Mark as paid
</button>
<p className="text-xs text-gray-500 text-center px-2">
Only tap once the terminal has approved the card.
</p>
</div>
);
}
@@ -43,6 +43,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'Cash', cash: 'Cash',
bitcoin: 'Bitcoin', bitcoin: 'Bitcoin',
transfer: 'Transfer', transfer: 'Transfer',
pos: 'POS',
guest: 'Guest', guest: 'Guest',
}; };
@@ -52,6 +53,7 @@ function sessionTotals(entries: SessionEntry[]) {
cash: { count: 0, total: 0 }, cash: { count: 0, total: 0 },
bitcoin: { count: 0, total: 0 }, bitcoin: { count: 0, total: 0 },
transfer: { count: 0, total: 0 }, transfer: { count: 0, total: 0 },
pos: { count: 0, total: 0 },
guest: { count: 0, total: 0 }, guest: { count: 0, total: 0 },
}; };
let grand = 0; let grand = 0;
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import type { DoorPaymentMethod } from '@/lib/api'; import type { DoorPaymentMethod } from '@/lib/api';
import { PaymentButtons } from './PaymentButtons'; import { formatCurrency } from '@/lib/utils';
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
export interface WalkInDraft { export interface WalkInDraft {
firstName: string; firstName: string;
@@ -22,9 +23,14 @@ export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
ruc: '', ruc: '',
}); });
const PRICE_SOURCE_LABELS: Record<'walk_in' | 'ticket', string> = {
walk_in: 'Walk-in price',
ticket: 'Ticket price (no walk-in price set)',
};
/** /**
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first * The pinned bottom row. Collapsed it is a single tap; expanded it is a first
* name and four tenders. Email, phone and RUC live behind "Add details" so the * name, the walk-in price and the tenders. Email, phone and RUC live behind "Add details" so the
* rare person who wants a receipt never slows down the queue behind them. * rare person who wants a receipt never slows down the queue behind them.
*/ */
export function WalkInRow({ export function WalkInRow({
@@ -32,7 +38,10 @@ export function WalkInRow({
expanded, expanded,
draft, draft,
price, price,
priceSource,
currency, currency,
methods,
canOverrideAmount,
busy, busy,
onExpand, onExpand,
onChange, onChange,
@@ -42,12 +51,16 @@ export function WalkInRow({
typedText: string; typedText: string;
expanded: boolean; expanded: boolean;
draft: WalkInDraft; draft: WalkInDraft;
/** Walk-in unit price as resolved by the server. */
price: number; price: number;
priceSource: 'walk_in' | 'ticket';
currency: string; currency: string;
methods: readonly DoorPaymentMethod[];
canOverrideAmount: boolean;
busy: boolean; busy: boolean;
onExpand: () => void; onExpand: () => void;
onChange: (draft: WalkInDraft) => void; onChange: (draft: WalkInDraft) => void;
onPay: (method: DoorPaymentMethod, amount: number) => void; onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
@@ -146,9 +159,16 @@ export function WalkInRow({
</div> </div>
)} )}
<div className="px-1 pt-1">
<p className="text-2xl font-bold text-white tabular-nums">{formatCurrency(price, currency)}</p>
<p className="text-xs text-gray-400">{PRICE_SOURCE_LABELS[priceSource]}</p>
</div>
<PaymentButtons <PaymentButtons
price={price} price={price}
currency={currency} currency={currency}
methods={methods}
canOverrideAmount={canOverrideAmount}
onPay={onPay} onPay={onPay}
disabled={busy || !draft.firstName.trim()} disabled={busy || !draft.firstName.trim()}
/> />
+40 -10
View File
@@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext';
import { import {
eventsApi, eventsApi,
doorApi, doorApi,
DOOR_PAYMENT_METHODS,
type Event, type Event,
type DoorAttendee, type DoorAttendee,
type DoorAttendeesResponse, type DoorAttendeesResponse,
@@ -30,6 +31,7 @@ import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/
import { QRScannerOverlay } from './_components/QRScannerOverlay'; import { QRScannerOverlay } from './_components/QRScannerOverlay';
import { AttendeeRow } from './_components/AttendeeRow'; import { AttendeeRow } from './_components/AttendeeRow';
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow'; import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
import type { DoorCharge } from './_components/PaymentButtons';
import { SessionSheet, type SessionEntry } from './_components/SessionSheet'; import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
import { ResultScreen, type DoorResult } from './_components/ResultScreen'; import { ResultScreen, type DoorResult } from './_components/ResultScreen';
@@ -64,6 +66,7 @@ const METHOD_PAST_TENSE: Record<DoorPaymentMethod, string> = {
cash: 'paid cash', cash: 'paid cash',
bitcoin: 'paid bitcoin', bitcoin: 'paid bitcoin',
transfer: 'paid by transfer', transfer: 'paid by transfer',
pos: 'paid by POS',
guest: 'in as guest', guest: 'in as guest',
}; };
@@ -75,6 +78,9 @@ export default function AdminDoorPage() {
// own shift from the session feed below, which is local to this device; the // own shift from the session feed below, which is local to this device; the
// API enforces the same split (see REVENUE_ROLES in routes/door.ts). // API enforces the same split (see REVENUE_ROLES in routes/door.ts).
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer'; const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
// Typing a custom door amount is an admin decision; the API rejects it for
// anyone else (AMOUNT_OVERRIDE_ROLES in routes/door.ts).
const canOverrideAmount = user?.role === 'admin' || user?.role === 'organizer';
// ── Events ── // ── Events ──
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
@@ -122,6 +128,10 @@ export default function AdminDoorPage() {
const eventMeta = data?.event; const eventMeta = data?.event;
const price = eventMeta?.price ?? 0; const price = eventMeta?.price ?? 0;
// Walk-ins pay the walk-in price when the event sets one; the server resolves it.
const walkInPrice = eventMeta?.walkInUnitPrice ?? price;
const walkInPriceSource = eventMeta?.walkInPriceSource ?? 'ticket';
const doorMethods = data?.doorMethods ?? DOOR_PAYMENT_METHODS;
const currency = eventMeta?.currency ?? 'PYG'; const currency = eventMeta?.currency ?? 'PYG';
const capacity = eventMeta?.capacity ?? 0; const capacity = eventMeta?.capacity ?? 0;
@@ -351,7 +361,7 @@ export default function AdminDoorPage() {
async (opts: { async (opts: {
ticketId?: string; ticketId?: string;
attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string }; attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string };
payment?: { method: DoorPaymentMethod; amount: number }; payment?: { method: DoorPaymentMethod; charge: DoorCharge };
entry: DoorEntryMethod; entry: DoorEntryMethod;
displayName: string; displayName: string;
/** Ticket row to flash and optimistically mark as in. */ /** Ticket row to flash and optimistically mark as in. */
@@ -373,7 +383,7 @@ export default function AdminDoorPage() {
at: clockTime(now), at: clockTime(now),
entry: opts.entry, entry: opts.entry,
method: opts.payment?.method ?? null, method: opts.payment?.method ?? null,
amount: opts.payment?.amount ?? 0, amount: opts.payment?.charge.amount ?? 0,
startedAt: now.getTime(), startedAt: now.getTime(),
undone: false, undone: false,
failed: false, failed: false,
@@ -424,10 +434,19 @@ export default function AdminDoorPage() {
// ── The write itself: background, retried, never blocking the queue ── // ── The write itself: background, retried, never blocking the queue ──
try { try {
const { method, charge } = opts.payment ?? {};
const res = await submitDoorAction(eventId, { const res = await submitDoorAction(eventId, {
ticketId: opts.ticketId, ticketId: opts.ticketId,
attendee: opts.attendee, attendee: opts.attendee,
payment: opts.payment, // The server prices the charge from quantity; a typed amount only
// travels as an explicit admin override.
payment: method && charge
? {
method,
quantity: charge.quantity,
...(charge.override ? { amount: charge.amount, amountOverride: true } : {}),
}
: undefined,
entryMethod: opts.entry, entryMethod: opts.entry,
idempotencyKey, idempotencyKey,
}); });
@@ -435,7 +454,13 @@ export default function AdminDoorPage() {
setSessionEntries((prev) => setSessionEntries((prev) =>
prev.map((e) => prev.map((e) =>
e.idempotencyKey === idempotencyKey e.idempotencyKey === idempotencyKey
? { ...e, ticketId: res.attendee.ticketId, name: res.attendee.fullName } ? {
...e,
ticketId: res.attendee.ticketId,
name: res.attendee.fullName,
// What was actually recorded is the server's number, not ours.
amount: res.payment?.amount ?? e.amount,
}
: e, : e,
), ),
); );
@@ -508,10 +533,10 @@ export default function AdminDoorPage() {
); );
const handleRowPay = useCallback( const handleRowPay = useCallback(
(attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => { (attendee: DoorAttendee, method: DoorPaymentMethod, charge: DoorCharge) => {
runAction({ runAction({
ticketId: attendee.ticketId, ticketId: attendee.ticketId,
payment: { method, amount }, payment: { method, charge },
entry: 'search', entry: 'search',
displayName: attendee.fullName, displayName: attendee.fullName,
optimisticTicketId: attendee.ticketId, optimisticTicketId: attendee.ticketId,
@@ -522,7 +547,7 @@ export default function AdminDoorPage() {
); );
const handleWalkInPay = useCallback( const handleWalkInPay = useCallback(
(method: DoorPaymentMethod, amount: number) => { (method: DoorPaymentMethod, charge: DoorCharge) => {
const firstName = walkInDraft.firstName.trim(); const firstName = walkInDraft.firstName.trim();
if (!firstName) return; if (!firstName) return;
const displayName = walkInDraft.lastName.trim() const displayName = walkInDraft.lastName.trim()
@@ -536,7 +561,7 @@ export default function AdminDoorPage() {
email: walkInDraft.email.trim() || undefined, email: walkInDraft.email.trim() || undefined,
ruc: walkInDraft.ruc.trim() || undefined, ruc: walkInDraft.ruc.trim() || undefined,
}, },
payment: { method, amount }, payment: { method, charge },
entry: 'walkin', entry: 'walkin',
displayName, displayName,
busyKey: 'walk-in', busyKey: 'walk-in',
@@ -764,11 +789,13 @@ export default function AdminDoorPage() {
attendee={attendee} attendee={attendee}
currency={currency} currency={currency}
price={price} price={price}
methods={doorMethods}
canOverrideAmount={canOverrideAmount}
expanded={expandedId === attendee.ticketId} expanded={expandedId === attendee.ticketId}
flashing={flashId === attendee.ticketId} flashing={flashId === attendee.ticketId}
busy={busyId === attendee.ticketId} busy={busyId === attendee.ticketId}
onTap={() => handleRowTap(attendee)} onTap={() => handleRowTap(attendee)}
onPay={(method, amount) => handleRowPay(attendee, method, amount)} onPay={(method, charge) => handleRowPay(attendee, method, charge)}
/> />
))} ))}
@@ -778,8 +805,11 @@ export default function AdminDoorPage() {
typedText={trimmedQuery} typedText={trimmedQuery}
expanded={walkInOpen} expanded={walkInOpen}
draft={walkInDraft} draft={walkInDraft}
price={price} price={walkInPrice}
priceSource={walkInPriceSource}
currency={currency} currency={currency}
methods={doorMethods}
canOverrideAmount={canOverrideAmount}
busy={busyId === 'walk-in'} busy={busyId === 'walk-in'}
onExpand={() => { onExpand={() => {
setWalkInDraft(emptyWalkIn(trimmedQuery)); setWalkInDraft(emptyWalkIn(trimmedQuery));
+7 -1
View File
@@ -294,7 +294,13 @@
"edit": "Edit Event", "edit": "Edit Event",
"delete": "Delete Event", "delete": "Delete Event",
"publish": "Publish", "publish": "Publish",
"unpublish": "Unpublish" "unpublish": "Unpublish",
"form": {
"walkInPrice": "Walk-in price",
"walkInPricePlaceholder": "Same as ticket price ({price})",
"walkInPriceHelp": "Charged at the door via the Scanner. Not shown on the public event page.",
"walkInPriceInvalid": "Walk-in price must be empty or a whole number of 0 or more"
}
}, },
"tickets": { "tickets": {
"title": "Manage Tickets", "title": "Manage Tickets",
+7 -1
View File
@@ -294,7 +294,13 @@
"edit": "Editar Evento", "edit": "Editar Evento",
"delete": "Eliminar Evento", "delete": "Eliminar Evento",
"publish": "Publicar", "publish": "Publicar",
"unpublish": "Despublicar" "unpublish": "Despublicar",
"form": {
"walkInPrice": "Precio en puerta",
"walkInPricePlaceholder": "Igual al precio de la entrada ({price})",
"walkInPriceHelp": "Se cobra en la puerta desde el Escáner. No se muestra en la página pública del evento.",
"walkInPriceInvalid": "El precio en puerta debe quedar vacío o ser un número entero de 0 o más"
}
}, },
"tickets": { "tickets": {
"title": "Gestionar Tickets", "title": "Gestionar Tickets",
+27 -4
View File
@@ -5,7 +5,7 @@ import { fetchApi } from './client';
// fire actions optimistically and retry on flaky venue wifi without ever // fire actions optimistically and retry on flaky venue wifi without ever
// creating a duplicate ticket, payment or check-in. // creating a duplicate ticket, payment or check-in.
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]; export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
export type DoorEntryMethod = 'scan' | 'search' | 'walkin'; export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
@@ -32,7 +32,20 @@ export interface DoorAttendee {
} }
export interface DoorAttendeesResponse { export interface DoorAttendeesResponse {
event: { id: string; title: string; price: number; currency: string; capacity: number }; event: {
id: string;
title: string;
price: number;
/** The event's walk-in price as configured; null = not set. */
walkInPrice: number | null;
/** What one walk-in ticket costs, resolved by the server. */
walkInUnitPrice: number;
walkInPriceSource: 'walk_in' | 'ticket';
currency: string;
capacity: number;
};
/** Tenders enabled for this event (POS can be switched off in payment options). */
doorMethods: DoorPaymentMethod[];
attendees: DoorAttendee[]; attendees: DoorAttendee[];
stats: { checkedIn: number; totalActive: number; capacity: number }; stats: { checkedIn: number; totalActive: number; capacity: number };
} }
@@ -46,7 +59,11 @@ export interface DoorCheckinRequest {
email?: string; email?: string;
ruc?: string; ruc?: string;
}; };
payment?: { method: DoorPaymentMethod; amount?: number }; /**
* The server prices the charge (walk-in or ticket price x quantity). `amount`
* is only honoured with `amountOverride`, which is admin/organizer only.
*/
payment?: { method: DoorPaymentMethod; quantity?: number; amount?: number; amountOverride?: boolean };
entryMethod?: DoorEntryMethod; entryMethod?: DoorEntryMethod;
idempotencyKey: string; idempotencyKey: string;
} }
@@ -55,7 +72,13 @@ export interface DoorCheckinResponse {
ok: true; ok: true;
action: 'checkin' | 'walkin'; action: 'checkin' | 'walkin';
attendee: DoorAttendee; attendee: DoorAttendee;
payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null; payment: {
id: string;
method: DoorPaymentMethod;
amount: number;
currency: string;
amountOverridden: boolean;
} | null;
/** 'at_capacity' — the event is full; the attendee was added anyway. */ /** 'at_capacity' — the event is full; the attendee was added anyway. */
warnings: string[]; warnings: string[];
idempotencyKey: string; idempotencyKey: string;
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Payment, PaymentWithDetails } from './types';
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to // Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
// verify the money arrived; automatic ones (lightning) confirm themselves. // verify the money arrived; automatic ones (lightning) confirm themselves.
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash']; export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash', 'pos'];
export function isManualProvider(provider: string): boolean { export function isManualProvider(provider: string): boolean {
return MANUAL_PAYMENT_PROVIDERS.includes(provider); return MANUAL_PAYMENT_PROVIDERS.includes(provider);
+9 -1
View File
@@ -12,6 +12,9 @@ export interface Event {
location: string; location: string;
locationUrl?: string; locationUrl?: string;
price: number; price: number;
// Door price for walk-ins. Only returned to admin/organizer/staff; null = not
// set (walk-ins pay `price`), 0 = free walk-in.
walkInPrice?: number | null;
currency: string; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
@@ -57,6 +60,8 @@ export interface Ticket {
adminNote?: string; adminNote?: string;
isGuest?: boolean; isGuest?: boolean;
paymentStatus?: 'paid' | 'unpaid' | 'comp'; paymentStatus?: 'paid' | 'unpaid' | 'comp';
// How the booking was made: public checkout, a door walk-in, or added by an admin
bookingSource?: 'online' | 'walk_in' | 'admin';
createdAt: string; createdAt: string;
event?: Event; event?: Event;
payment?: Payment; payment?: Payment;
@@ -124,7 +129,7 @@ export interface LiveSearchResult {
export interface Payment { export interface Payment {
id: string; id: string;
ticketId: string; ticketId: string;
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago' | 'pos';
amount: number; amount: number;
currency: string; currency: string;
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold'; status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
@@ -178,6 +183,8 @@ export interface PaymentOptionsConfig {
cashEnabled: boolean; cashEnabled: boolean;
cashInstructions?: string | null; cashInstructions?: string | null;
cashInstructionsEs?: string | null; cashInstructionsEs?: string | null;
// POS card terminal on the door screen (never offered at online checkout)
posEnabled?: boolean;
// Booking settings // Booking settings
allowDuplicateBookings?: boolean; allowDuplicateBookings?: boolean;
} }
@@ -362,6 +369,7 @@ export interface FinancialSummary {
cash: number; cash: number;
bank_transfer: number; bank_transfer: number;
tpago: number; tpago: number;
pos?: number;
}; };
paidCount: number; paidCount: number;
pendingCount: number; pendingCount: number;