diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 78d2622..dc15ab2 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -117,6 +117,11 @@ async function migrate() { await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`); } 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 try { 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 await (db as any).run(sql` CREATE TABLE IF NOT EXISTS invoices ( @@ -392,6 +412,14 @@ async function migrate() { } 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` CREATE TABLE IF NOT EXISTS contacts ( 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`); } 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 try { 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 await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS invoices ( @@ -1006,6 +1053,14 @@ async function migrate() { } 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` CREATE TABLE IF NOT EXISTS contacts ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 05a87d6..af72576 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -82,6 +82,9 @@ export const sqliteEvents = sqliteTable('events', { location: text('location').notNull(), locationUrl: text('location_url'), 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'), capacity: integer('capacity').notNull().default(50), 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), // 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'), + // 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(), }); export const sqlitePayments = sqliteTable('payments', { id: text('id').primaryKey(), 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(), currency: text('currency').notNull().default('PYG'), 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'), // 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. - method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }), + method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }), createdAt: text('created_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), cashInstructions: text('cash_instructions'), 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 allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false), // Metadata @@ -226,6 +234,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides' cashEnabled: integer('cash_enabled', { mode: 'boolean' }), cashInstructions: text('cash_instructions'), cashInstructionsEs: text('cash_instructions_es'), + posEnabled: integer('pos_enabled', { mode: 'boolean' }), // Metadata createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), @@ -476,6 +485,9 @@ export const pgEvents = pgTable('events', { location: varchar('location', { length: 500 }).notNull(), locationUrl: varchar('location_url', { length: 500 }), 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'), capacity: pgInteger('capacity').notNull().default(50), status: varchar('status', { length: 20 }).notNull().default('draft'), @@ -515,6 +527,8 @@ export const pgTickets = pgTable('tickets', { isGuest: pgInteger('is_guest').notNull().default(0), // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue 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(), }); @@ -578,6 +592,7 @@ export const pgPaymentOptions = pgTable('payment_options', { cashEnabled: pgInteger('cash_enabled').notNull().default(1), cashInstructions: pgText('cash_instructions'), cashInstructionsEs: pgText('cash_instructions_es'), + posEnabled: pgInteger('pos_enabled').notNull().default(1), allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0), updatedAt: timestamp('updated_at').notNull(), updatedBy: uuid('updated_by').references(() => pgUsers.id), @@ -607,6 +622,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', { cashEnabled: pgInteger('cash_enabled'), cashInstructions: pgText('cash_instructions'), cashInstructionsEs: pgText('cash_instructions_es'), + posEnabled: pgInteger('pos_enabled'), createdAt: timestamp('created_at').notNull(), updatedAt: timestamp('updated_at').notNull(), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index f954361..8ff5f02 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -788,7 +788,7 @@ const openApiSpec = { post: { tags: ['Tickets'], 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: [] }], parameters: [ { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, @@ -817,8 +817,10 @@ const openApiSpec = { type: 'object', required: ['method'], properties: { - method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] }, - amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' }, + method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }, + 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'] }, @@ -831,7 +833,8 @@ const openApiSpec = { responses: { 201: { description: 'Attendee checked in; warnings may contain at_capacity' }, 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' }, }, }, diff --git a/backend/src/lib/doorPayments.ts b/backend/src/lib/doorPayments.ts index 397736e..b0008b6 100644 --- a/backend/src/lib/doorPayments.ts +++ b/backend/src/lib/doorPayments.ts @@ -1,6 +1,6 @@ // Door payment tenders. // -// The door check-in screen offers four one-tap tenders. Each maps onto an +// The door check-in screen offers five tenders. Each maps onto an // existing payments.provider so the rest of the app (capacity, sweeps, admin // payment lists, receipts) keeps working unchanged, while payments.method // records which tender was actually used for the end-of-night cash-up. @@ -9,27 +9,49 @@ // already made — the same trust model as cash, no invoice generated. When a real // Lightning flow lands it slots in here: the tender keeps its name and provider, // only the settlement path in routes/door.ts changes. +// +// POS is the physical card terminal. Staff open the POS step, which shows the +// amount to key into the terminal, charge the card, then confirm "Mark as paid"; +// only that confirmation reaches this API, recorded like any other tender. A +// future automatic amount push to the terminal belongs in the POS step itself +// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to +// the terminal today. -export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const; +export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const; export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number]; interface DoorTender { /** Existing payments.provider this tender is stored as. */ - provider: 'cash' | 'lightning' | 'bank_transfer'; + provider: 'cash' | 'lightning' | 'bank_transfer' | 'pos'; /** Human label used in payment references and toasts. */ label: string; /** Comp tenders carry no revenue and always record a zero amount. */ isComp: boolean; + /** + * How the money is confirmed before the door screen records it: + * 'on_tap' staff already hold the money when they tap (cash, …) + * 'staff_confirm' staff charge an external device first, then confirm + */ + confirmation: 'on_tap' | 'staff_confirm'; } export const DOOR_TENDERS: Record = { - cash: { provider: 'cash', label: 'cash', isComp: false }, - bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false }, - transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false }, - guest: { provider: 'cash', label: 'guest', isComp: true }, + cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' }, + bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' }, + transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' }, + pos: { provider: 'pos', label: 'POS', isComp: false, confirmation: 'staff_confirm' }, + guest: { provider: 'cash', label: 'guest', isComp: true, confirmation: 'on_tap' }, }; +/** + * Tenders that can be switched off per event through payment options. Only POS + * is configurable: the other door tenders are always available to staff. + */ +export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMethod[] { + return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled); +} + export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod { return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value); } diff --git a/backend/src/lib/email/paymentEmails.ts b/backend/src/lib/email/paymentEmails.ts index 3654431..f166995 100644 --- a/backend/src/lib/email/paymentEmails.ts +++ b/backend/src/lib/email/paymentEmails.ts @@ -74,8 +74,8 @@ export async function sendPaymentReceipt(paymentId: string): Promise<{ success: const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; const paymentMethodNames: Record> = { - en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' }, - es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' }, + en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago', pos: 'POS' }, + es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' }, }; const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); diff --git a/backend/src/lib/paymentProviders.ts b/backend/src/lib/paymentProviders.ts index 83ad086..aa61dc7 100644 --- a/backend/src/lib/paymentProviders.ts +++ b/backend/src/lib/paymentProviders.ts @@ -5,8 +5,8 @@ // settlement) and the booking is auto-approved on success. No admin involved. // Currently Lightning; future online gateways (e.g. Stripe) go here. // - 'manual': a human must verify the money arrived (TPago, bank transfer, -// card handled offline, cash at the door). These are never auto-confirmed -// and never auto-failed; an admin settles them by hand. Bank transfer and +// card handled offline, cash or the POS terminal at the door). These are +// never auto-confirmed and never auto-failed; an admin settles them by hand. Bank transfer and // TPago additionally expose an online "I've paid" step that moves the // payment to 'pending_approval'. // @@ -22,6 +22,8 @@ export const PAYMENT_PROVIDERS: Record = bank_transfer: { kind: 'manual' }, card: { kind: 'manual' }, cash: { kind: 'manual' }, + // Card on the physical POS terminal at the door; staff confirm it by hand + pos: { kind: 'manual' }, }; export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter( diff --git a/backend/src/lib/walkInPrice.test.ts b/backend/src/lib/walkInPrice.test.ts new file mode 100644 index 0000000..c380808 --- /dev/null +++ b/backend/src/lib/walkInPrice.test.ts @@ -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); + }); +}); diff --git a/backend/src/lib/walkInPrice.ts b/backend/src/lib/walkInPrice.ts new file mode 100644 index 0000000..b6ba6f9 --- /dev/null +++ b/backend/src/lib/walkInPrice.ts @@ -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 | null | undefined>(event: T): T { + if (!event) return event; + const { walkInPrice: _omitted, ...rest } = event as Record; + return rest as T; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 609713f..06fb721 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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), 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), + 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, pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length, diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 7ca73ee..8ae52f3 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -7,6 +7,7 @@ import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, t import { auth } from '../lib/betterAuth.js'; import { authSessions, authAccounts } from '../db/auth-schema.js'; import { getNow } from '../lib/utils.js'; +import { omitWalkInPrice } from '../lib/walkInPrice.js'; const dashboard = new Hono(); @@ -208,7 +209,7 @@ dashboard.get('/tickets/:id', async (c) => { return c.json({ ticket: { ...ticket, - event, + event: omitWalkInPrice(event as any), payment, invoice, }, @@ -272,7 +273,7 @@ dashboard.get('/next-event', async (c) => { return c.json({ nextEvent: { - event: nextEvent, + event: omitWalkInPrice(nextEvent), ticket: nextTicket, payment: nextPayment, }, diff --git a/backend/src/routes/door.integration.test.ts b/backend/src/routes/door.integration.test.ts index 7846676..6abff81 100644 --- a/backend/src/routes/door.integration.test.ts +++ b/backend/src/routes/door.integration.test.ts @@ -223,7 +223,7 @@ describe('door-checkin: existing ticket', () => { it('takes a group payment at a multiple of the ticket price', async () => { const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { ticketId: 'tkt-unpaid-2', - payment: { method: 'transfer', amount: PRICE * 2 }, + payment: { method: 'transfer', quantity: 2 }, idempotencyKey: 'key-unpaid-2-transfer', }); expect(body.payment.amount).toBe(PRICE * 2); @@ -441,3 +441,227 @@ describe('door-summary', () => { 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'); + }); +}); diff --git a/backend/src/routes/door.ts b/backend/src/routes/door.ts index d532a84..032ea8d 100644 --- a/backend/src/routes/door.ts +++ b/backend/src/routes/door.ts @@ -21,7 +21,8 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, inArray, sql } from 'drizzle-orm'; import { - db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, + db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs, + paymentOptions, eventPaymentOverrides, } from '../db/index.js'; import { requireAuth } from '../lib/auth.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 { DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference, - paymentStatusForMethod, type DoorPaymentMethod, + paymentStatusForMethod, enabledDoorMethods, type DoorPaymentMethod, } from '../lib/doorPayments.js'; +import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js'; import emailService from '../lib/email.js'; 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 // revenue aggregates (admin/export/financial, admin/analytics). 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'; // ==================== Shared helpers ==================== @@ -99,10 +106,24 @@ async function loadEvent(eventId: string | undefined) { return { ...event, price: num(event.price), + walkInPrice: parseWalkInPrice(event.walkInPrice), capacity: Number(event.capacity), }; } +/** Door tenders available for this event (POS can be switched off per event). */ +async function loadDoorMethods(eventId: string): Promise { + const [globalOptions, overrides] = await Promise.all([ + dbGet((db as any).select().from(paymentOptions)), + dbGet( + (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. */ async function loadAdminNames(adminIds: string[]): Promise> { 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); + const enabledMethods = await loadDoorMethods(event.id); + const walkIn = resolveWalkInPrice(event); + const attendees = rows .map((t: any) => toDoorAttendee(t, { price: event.price, @@ -181,9 +205,15 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async id: event.id, title: event.title, 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, capacity: event.capacity, }, + doorMethods: enabledMethods, attendees, stats: { checkedIn, totalActive, capacity: event.capacity }, }); @@ -204,9 +234,16 @@ const doorCheckinSchema = z.object({ }).optional(), payment: z.object({ method: z.enum(DOOR_PAYMENT_METHODS), - // Omitted means "one ticket at event price"; a multiple covers someone - // paying for their whole group in one go. + // How many tickets' worth is being paid (someone paying for their group). + // 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(), + amountOverride: z.boolean().optional(), + }).refine((p) => !p.amountOverride || typeof p.amount === 'number', { + message: 'amount is required when amountOverride is set', + path: ['amount'], }).optional(), // How the attendee reached this action, for the session feed. 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 }); } + 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); 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 nowIso = new Date().toISOString(); - const method = data.payment?.method as DoorPaymentMethod | undefined; - const requestedAmount = data.payment?.amount ?? event.price; + const quantity = data.payment?.quantity ?? 1; + // 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[] = []; let undoState: UndoState; let action: 'checkin' | 'walkin'; 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; if (data.ticketId) { @@ -326,7 +379,7 @@ doorRouter.post( method, updatedAt: now, }, 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 { const paymentId = generateId(); undo.createdPaymentId = paymentId; @@ -345,7 +398,7 @@ doorRouter.post( createdAt: 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, checkedInByAdminId: adminUser?.id || null, adminNote: null, + bookingSource: 'walk_in', createdAt: now, }; ops.push(insertOp(tickets, newTicket)); @@ -448,13 +502,35 @@ doorRouter.post( 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 }; ticketRow = newTicket; // Only mail people who actually gave an address; no QR for the rest. 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. const held = await seatsHeld(eventId); const atCapacity = event.capacity > 0 && held >= event.capacity; @@ -496,6 +572,13 @@ doorRouter.post( 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) { emailService.sendBookingConfirmation(emailTicketId).catch((err) => { console.error('[Email] Failed to send door walk-in confirmation:', err); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 1c2ba86..e1d7d43 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -9,6 +9,7 @@ import { slugify, uniqueSlug } from '../lib/slugify.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; import { eventSeatBreakdownQuery } from '../lib/capacity.js'; import { resolvePresaleClosure } from '../lib/presale.js'; +import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js'; interface UserContext { id: string; @@ -24,10 +25,14 @@ const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); // `settings` is the site_settings row; when given, the effective pre-sale // cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the // booking API agree on when registration closes. -function normalizeEvent(event: any, settings?: any) { +// The walk-in (door) price is internal: it is dropped unless the caller is +// admin/organizer/staff and `includeWalkInPrice` is set. +function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: boolean } = {}) { if (!event) return event; + const { walkInPrice, ...publicFields } = event; const normalized = { - ...event, + ...publicFields, + ...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}), // Convert price from string/decimal to clean number price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price), // Convert capacity from string to number if needed @@ -114,6 +119,29 @@ const parsePrice = (val: unknown): number => { return 0; }; +// Walk-in price: empty/null means "not set" (fall back to price) and must stay +// null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input +// fails validation instead of silently becoming 0 the way parsePrice does. +const walkInPriceSchema = z.union([z.number(), z.string(), z.null()]) + .transform((val) => { + if (val === null) return null; + if (typeof val === 'number') return val; + const trimmed = val.trim(); + if (trimmed === '') return null; + return Number(trimmed.replace(',', '.')); + }) + .pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable()) + .optional(); + +// PYG has no minor unit, so a PYG walk-in price must be a whole number. +function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null { + if (walkInPrice == null) return null; + if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) { + return 'walkInPrice: Walk-in price must be a whole number for PYG'; + } + return null; +} + // Helper to normalize boolean (handles true/false and 0/1) const normalizeBoolean = (val: unknown): boolean => { if (typeof val === 'boolean') return val; @@ -137,6 +165,7 @@ const baseEventSchema = z.object({ locationUrl: z.string().url().optional().nullable().or(z.literal('')), // Accept price as number or string (handles "45000" and "41,44" formats) price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0), + walkInPrice: walkInPriceSchema, currency: z.string().default('PYG'), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50), status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'), @@ -219,6 +248,7 @@ eventsRouter.get('/', async (c) => { // any client-supplied status filter, so drafts cannot leak. const authUser: any = await getAuthUser(c); const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); + const includeWalkInPrice = canSeeWalkInPrice(authUser?.role); const conditions: any[] = []; @@ -271,7 +301,7 @@ eventsRouter.get('/', async (c) => { const siteSettingsRow = await getSiteSettingsRow(); const eventsWithCounts = result.map((event: any) => { - const normalized = normalizeEvent(event, siteSettingsRow); + const normalized = normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }); const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 }; return { ...normalized, @@ -295,16 +325,19 @@ eventsRouter.get('/:id', async (c) => { return c.json({ error: 'Event not found' }, 404); } + const authUser: any = await getAuthUser(c); + // Draft events are only visible to privileged users (admin preview); hide from public. if ((event as any).status === 'draft') { - const authUser: any = await getAuthUser(c); const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); if (!isPrivileged) { return c.json({ error: 'Event not found' }, 404); } } - const normalized = normalizeEvent(event, await getSiteSettingsRow()); + const normalized = normalizeEvent(event, await getSiteSettingsRow(), { + includeWalkInPrice: canSeeWalkInPrice(authUser?.role), + }); const counts = await getEventSeatCounts(event.id); return c.json({ event: { @@ -459,6 +492,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c const id = generateId(); const siteSettingsRow = await getSiteSettingsRow(); const tz = siteTimezoneOf(siteSettingsRow); + + const walkInError = walkInPriceError(data.walkInPrice, data.currency); + if (walkInError) return c.json({ error: walkInError }, 400); // Convert data for database compatibility const dbData = convertBooleansForDb(data); @@ -483,7 +519,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c revalidateFrontendCache(); // Return normalized event data - return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201); + return c.json({ event: normalizeEvent(newEvent, siteSettingsRow, { includeWalkInPrice: true }) }, 201); }); // Update event (admin/organizer only) @@ -498,6 +534,14 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', return c.json({ error: 'Event not found' }, 404); } + if (data.walkInPrice !== undefined || data.currency !== undefined) { + const walkInError = walkInPriceError( + data.walkInPrice !== undefined ? data.walkInPrice : parseWalkInPrice(existing.walkInPrice), + data.currency ?? existing.currency, + ); + if (walkInError) return c.json({ error: walkInError }, 400); + } + const now = getNow(); const siteSettingsRow = await getSiteSettingsRow(); const tz = siteTimezoneOf(siteSettingsRow); @@ -559,7 +603,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', // Revalidate sitemap when an event is updated (status/dates may have changed) revalidateFrontendCache(); - return c.json({ event: normalizeEvent(updated, siteSettingsRow) }); + return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) }); }); // Delete event (admin only) @@ -670,6 +714,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( location: existing.location, locationUrl: existing.locationUrl, price: existing.price, + walkInPrice: existing.walkInPrice ?? null, currency: existing.currency, capacity: existing.capacity, status: 'draft', @@ -684,7 +729,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( await (db as any).insert(events).values(duplicatedEvent); - return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201); + return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201); }); // List slug aliases for an event (admin/organizer only) diff --git a/backend/src/routes/events.walkin.integration.test.ts b/backend/src/routes/events.walkin.integration.test.ts new file mode 100644 index 0000000..26f7b18 --- /dev/null +++ b/backend/src/routes/events.walkin.integration.test.ts @@ -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(user: TestUser, fn: () => Promise): Promise { + 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); + } + }); +}); diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index 27b4496..fd108d5 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -36,6 +36,8 @@ const updatePaymentOptionsSchema = z.object({ cashEnabled: booleanOrNumber.optional(), cashInstructions: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(), + // POS terminal on the door screen + posEnabled: booleanOrNumber.optional(), // Booking settings allowDuplicateBookings: booleanOrNumber.optional(), }); @@ -79,6 +81,7 @@ const updateEventOverridesSchema = z.object({ cashEnabled: booleanOrNumber.optional().nullable(), cashInstructions: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(), + posEnabled: booleanOrNumber.optional().nullable(), }); // Get global payment options @@ -111,6 +114,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { cashEnabled: true, cashInstructions: null, cashInstructionsEs: null, + posEnabled: true, allowDuplicateBookings: false, }, }); @@ -219,6 +223,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { cashEnabled: true, cashInstructions: null, cashInstructionsEs: null, + posEnabled: true, }; const global = globalOptions || defaults; @@ -245,6 +250,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { cashEnabled: overrides?.cashEnabled ?? global.cashEnabled, cashInstructions: overrides?.cashInstructions ?? global.cashInstructions, cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs, + posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true, }; // Full bank/TPago credentials are only returned when the caller proves they hold diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 9590cfe..2791492 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1508,6 +1508,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) qrCode, checkinAt: data.autoCheckin ? now : null, adminNote: data.adminNote || null, + bookingSource: 'admin', createdAt: now, }; @@ -1663,6 +1664,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z checkinAt: data.checkinNow ? now : null, checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null, adminNote: data.adminNote || null, + bookingSource: 'admin', createdAt: now, }; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index c23e73a..3e9f087 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -6,6 +6,7 @@ import { eq, desc, sql, and, gte, lte } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { authSessions } from '../db/auth-schema.js'; import { getNow, toDbDate } from '../lib/utils.js'; +import { omitWalkInPrice } from '../lib/walkInPrice.js'; interface UserContext { id: string; @@ -249,7 +250,7 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar return { ...ticket, - event, + event: omitWalkInPrice(event as any), }; }) ); diff --git a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx index 5d497c7..91277f4 100644 --- a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx @@ -44,6 +44,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' }, cash: { en: 'Cash', es: 'Efectivo' }, bancard: { en: 'Card', es: 'Tarjeta' }, + pos: { en: 'POS', es: 'POS' }, }; return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider; }; diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index 0de3de6..0f040a0 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -177,6 +177,7 @@ export default function AdminBookingsPage() { lightning: 'Lightning', tpago: 'TPago', bancard: 'Bancard', + pos: 'POS', }; return labels[provider] || provider; }; @@ -443,7 +444,10 @@ export default function AdminBookingsPage() { {ticket.payment?.status || 'pending'} -

{getPaymentMethodLabel(getDisplayProvider(ticket))}

+

+ {getPaymentMethodLabel(getDisplayProvider(ticket))} + {ticket.bookingSource === 'walk_in' && (locale === 'es' ? ' · En puerta' : ' · Walk-in')} +

{ticket.payment && (

{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}

)} diff --git a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx index 75056dd..2e493d5 100644 --- a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx +++ b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx @@ -25,6 +25,7 @@ const DOOR_METHOD_LABELS: Record cash: { en: 'Cash', es: 'Efectivo' }, bitcoin: { en: 'Bitcoin', es: 'Bitcoin' }, transfer: { en: 'Transfer', es: 'Transferencia' }, + pos: { en: 'POS', es: 'POS' }, guest: { en: 'Guests', es: 'Invitados' }, }; @@ -103,6 +104,10 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) handleSavePaymentOptions, } = 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 (
{loadingPayments ? ( @@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
+ {/* POS terminal at the door */} + +
+
+
+
+ +
+
+

POS

+

+ {locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'} +

+
+
+
+ {posDisabledGlobally && ( + + {locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'} + + )} + +
+
+
+
+ {/* Summary */}
@@ -453,6 +494,7 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) { label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') }, { label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') }, { label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') }, + { label: 'POS', enabled: posEnabled }, ].map((method) => (
{method.enabled ? ( diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx index c8c49c5..92d717d 100644 --- a/frontend/src/app/admin/events/_components/EventFormModal.tsx +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput'; import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import { useLanguage } from '@/context/LanguageContext'; -import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils'; +import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils'; interface EventFormData { title: string; @@ -25,6 +25,9 @@ interface EventFormData { location: string; locationUrl: string; price: number; + // Kept as the raw input so "empty" (not set -> falls back to price) stays + // distinguishable from 0 (a free walk-in). + walkInPrice: string; currency: string; capacity: number; status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; @@ -47,12 +50,20 @@ const EMPTY_FORM: EventFormData = { title: '', titleEs: '', slug: '', description: '', descriptionEs: '', shortDescription: '', shortDescriptionEs: '', startDatetime: '', endDatetime: '', location: '', locationUrl: '', - price: 0, currency: 'PYG', capacity: 50, status: 'draft', + price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft', bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled, presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore, }; +/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */ +function isValidWalkInPrice(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === '') return true; + const n = Number(trimmed); + return Number.isInteger(n) && n >= 0; +} + function isoToLocalDatetime(isoString: string): string { const date = parseDate(isoString); const parts = new Intl.DateTimeFormat('en-US', { @@ -138,6 +149,7 @@ export default function EventFormModal({ endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', location: event.location, locationUrl: event.locationUrl || '', price: event.price, currency: event.currency, capacity: event.capacity, + walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice), status: event.status, bannerUrl: event.bannerUrl || '', externalBookingEnabled: event.externalBookingEnabled || false, externalBookingUrl: event.externalBookingUrl || '', @@ -208,6 +220,11 @@ export default function EventFormModal({ setSaving(false); return; } + if (!isValidWalkInPrice(formData.walkInPrice)) { + toast.error(t('admin.events.form.walkInPriceInvalid')); + setSaving(false); + return; + } const eventData: Partial = { title: formData.title, titleEs: formData.titleEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined, @@ -216,6 +233,8 @@ export default function EventFormModal({ endDatetime: formData.endDatetime || undefined, location: formData.location, locationUrl: formData.locationUrl || undefined, price: formData.price, currency: formData.currency, capacity: formData.capacity, + // Empty means "not set" and must be saved as null, never 0 + walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice), status: formData.status, bannerUrl: formData.bannerUrl || undefined, externalBookingEnabled: formData.externalBookingEnabled, externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, @@ -342,17 +361,30 @@ export default function EventFormModal({ setFormData({ ...formData, locationUrl: e.target.value })} /> -
- setFormData({ ...formData, price: Number(e.target.value) })} /> -
- - +
+
+ setFormData({ ...formData, price: Number(e.target.value) })} /> + setFormData({ ...formData, walkInPrice: e.target.value })} + placeholder={t('admin.events.form.walkInPricePlaceholder', { + price: formatPrice(formData.price || 0, formData.currency), + })} + error={isValidWalkInPrice(formData.walkInPrice) ? undefined : t('admin.events.form.walkInPriceInvalid')} /> +
+ + +
+

{t('admin.events.form.walkInPriceHelp')}

+
+ +
setFormData({ ...formData, capacity: Number(e.target.value) })} />
diff --git a/frontend/src/app/admin/payment-options/page.tsx b/frontend/src/app/admin/payment-options/page.tsx index fc7b9de..6cb3543 100644 --- a/frontend/src/app/admin/payment-options/page.tsx +++ b/frontend/src/app/admin/payment-options/page.tsx @@ -42,6 +42,7 @@ export default function PaymentOptionsPage() { bankNotesEs: null, lightningEnabled: true, cashEnabled: true, + posEnabled: true, cashInstructions: null, cashInstructionsEs: null, allowDuplicateBookings: false, @@ -83,6 +84,9 @@ export default function PaymentOptionsPage() { setOptions((prev) => ({ ...prev, [key]: value })); }; + // Stored as 0/1 on Postgres; on unless explicitly switched off. + const posEnabled = !!(options.posEnabled ?? true); + if (loading) { return (
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
+ {/* POS terminal at the door */} + +
+
+
+
+ +
+
+

POS

+

+ {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.'} +

+
+
+ +
+
+
+ {/* Booking Settings */}
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() { {locale === 'es' ? 'Efectivo' : 'Cash'}
+
+ {posEnabled ? ( + + ) : ( + + )} + + POS + +
diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 4e5ed84..555d3ce 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -313,6 +313,7 @@ export default function AdminPaymentsPage() { bank_transfer: BuildingLibraryIcon, tpago: CreditCardIcon, bancard: CreditCardIcon, + pos: CreditCardIcon, }; const Icon = icons[provider] || CreditCardIcon; return ; @@ -325,6 +326,7 @@ export default function AdminPaymentsPage() { lightning: 'Lightning', tpago: 'TPago', bancard: 'Bancard', + pos: 'POS', }; return labels[provider] || provider; }; @@ -736,7 +738,7 @@ export default function AdminPaymentsPage() { {/* By Provider */}

{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}

-
+

{locale === 'es' ? 'Efectivo' : 'Cash'}

{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG

@@ -757,6 +759,10 @@ export default function AdminPaymentsPage() {

Bancard

{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG

+
+

POS

+

{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG

+
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() { +
@@ -1292,6 +1299,7 @@ export default function AdminPaymentsPage() { +
diff --git a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx index 0381803..0acd497 100644 --- a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx +++ b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx @@ -8,7 +8,7 @@ import { } from '@heroicons/react/24/outline'; import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api'; import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils'; -import { PaymentButtons } from './PaymentButtons'; +import { PaymentButtons, type DoorCharge } from './PaymentButtons'; function checkinTime(checkinAt: string | null): string { if (!checkinAt) return ''; @@ -23,6 +23,7 @@ const METHOD_LABELS: Record = { cash: 'cash', bitcoin: 'bitcoin', transfer: 'transfer', + pos: 'POS', guest: 'guest', }; @@ -49,6 +50,8 @@ export function AttendeeRow({ attendee, currency, price, + methods, + canOverrideAmount, expanded, flashing, busy, @@ -58,11 +61,13 @@ export function AttendeeRow({ attendee: DoorAttendee; currency: string; price: number; + methods: readonly DoorPaymentMethod[]; + canOverrideAmount: boolean; expanded: boolean; flashing: boolean; busy: boolean; onTap: () => void; - onPay: (method: DoorPaymentMethod, amount: number) => void; + onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void; }) { const isCancelled = attendee.status === 'cancelled'; 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.

)} - +
)} diff --git a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx index 7c849a2..3a28f44 100644 --- a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx +++ b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx @@ -6,14 +6,29 @@ import { BanknotesIcon, BoltIcon, BuildingLibraryIcon, + CreditCardIcon, GiftIcon, ChevronDownIcon, } from '@heroicons/react/24/outline'; import type { DoorPaymentMethod } from '@/lib/api'; 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. +// 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: { method: DoorPaymentMethod; @@ -24,6 +39,7 @@ const TENDERS: { { 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: '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' }, ]; @@ -32,21 +48,32 @@ const LONG_PRESS_MS = 450; export function PaymentButtons({ price, currency, + methods, + canOverrideAmount, onPay, disabled, }: { + /** Unit price for one ticket, as the server will charge it. */ price: number; 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; }) { // Which tender has its quick-amounts open. Guest is always free, so it never opens one. const [amountsFor, setAmountsFor] = useState(null); const [customOpen, setCustomOpen] = useState(false); const [customValue, setCustomValue] = useState(''); + // POS charge waiting for staff to confirm the terminal approved it. + const [posCharge, setPosCharge] = useState(null); const [pressTimer, setPressTimer] = useState | null>(null); const [longPressed, setLongPressed] = useState(false); + const tenders = TENDERS.filter((t) => methods.includes(t.method)); + const openAmounts = (method: DoorPaymentMethod) => { if (method === 'guest') return; setAmountsFor(method); @@ -54,6 +81,15 @@ export function PaymentButtons({ 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) => { setLongPressed(false); const timer = setTimeout(() => { @@ -72,7 +108,7 @@ export function PaymentButtons({ return; } if (disabled) return; - onPay(method, method === 'guest' ? 0 : price); + charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price }); }; const cancelPress = () => { @@ -81,6 +117,19 @@ export function PaymentButtons({ setLongPressed(false); }; + if (posCharge) { + return ( + onPay('pos', posCharge)} + onBack={() => setPosCharge(null)} + /> + ); + } + if (amountsFor) { const tender = TENDERS.find((t) => t.method === amountsFor)!; return ( @@ -94,12 +143,12 @@ export function PaymentButtons({ Back
-
+
{[1, 2, 3].map((qty) => ( ))} - + {canOverrideAmount && ( + + )}
- {customOpen && ( + {canOverrideAmount && customOpen && (
+
+ +
+

Amount to charge

+

{formatCurrency(charge.amount, currency)}

+ {charge.override ? ( +

Custom amount

+ ) : charge.quantity > 1 ? ( +

+ {charge.quantity} × {formatCurrency(unitPrice, currency)} +

+ ) : null} +
+ + +

+ Only tap once the terminal has approved the card. +

+
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx index 0916074..5a6248d 100644 --- a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx +++ b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx @@ -43,6 +43,7 @@ const METHOD_LABELS: Record = { cash: 'Cash', bitcoin: 'Bitcoin', transfer: 'Transfer', + pos: 'POS', guest: 'Guest', }; @@ -52,6 +53,7 @@ function sessionTotals(entries: SessionEntry[]) { cash: { count: 0, total: 0 }, bitcoin: { count: 0, total: 0 }, transfer: { count: 0, total: 0 }, + pos: { count: 0, total: 0 }, guest: { count: 0, total: 0 }, }; let grand = 0; diff --git a/frontend/src/app/admin/scanner/_components/WalkInRow.tsx b/frontend/src/app/admin/scanner/_components/WalkInRow.tsx index e282463..9d45e57 100644 --- a/frontend/src/app/admin/scanner/_components/WalkInRow.tsx +++ b/frontend/src/app/admin/scanner/_components/WalkInRow.tsx @@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react'; import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import type { DoorPaymentMethod } from '@/lib/api'; -import { PaymentButtons } from './PaymentButtons'; +import { formatCurrency } from '@/lib/utils'; +import { PaymentButtons, type DoorCharge } from './PaymentButtons'; export interface WalkInDraft { firstName: string; @@ -22,9 +23,14 @@ export const emptyWalkIn = (firstName = ''): WalkInDraft => ({ 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 - * 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. */ export function WalkInRow({ @@ -32,7 +38,10 @@ export function WalkInRow({ expanded, draft, price, + priceSource, currency, + methods, + canOverrideAmount, busy, onExpand, onChange, @@ -42,12 +51,16 @@ export function WalkInRow({ typedText: string; expanded: boolean; draft: WalkInDraft; + /** Walk-in unit price as resolved by the server. */ price: number; + priceSource: 'walk_in' | 'ticket'; currency: string; + methods: readonly DoorPaymentMethod[]; + canOverrideAmount: boolean; busy: boolean; onExpand: () => void; onChange: (draft: WalkInDraft) => void; - onPay: (method: DoorPaymentMethod, amount: number) => void; + onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void; onCancel: () => void; }) { const [detailsOpen, setDetailsOpen] = useState(false); @@ -146,9 +159,16 @@ export function WalkInRow({
)} +
+

{formatCurrency(price, currency)}

+

{PRICE_SOURCE_LABELS[priceSource]}

+
+ diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index 29c057d..3307561 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext'; import { eventsApi, doorApi, + DOOR_PAYMENT_METHODS, type Event, type DoorAttendee, type DoorAttendeesResponse, @@ -30,6 +31,7 @@ import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/ import { QRScannerOverlay } from './_components/QRScannerOverlay'; import { AttendeeRow } from './_components/AttendeeRow'; import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow'; +import type { DoorCharge } from './_components/PaymentButtons'; import { SessionSheet, type SessionEntry } from './_components/SessionSheet'; import { ResultScreen, type DoorResult } from './_components/ResultScreen'; @@ -64,6 +66,7 @@ const METHOD_PAST_TENSE: Record = { cash: 'paid cash', bitcoin: 'paid bitcoin', transfer: 'paid by transfer', + pos: 'paid by POS', 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 // API enforces the same split (see REVENUE_ROLES in routes/door.ts). 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 ── const [events, setEvents] = useState([]); @@ -122,6 +128,10 @@ export default function AdminDoorPage() { const eventMeta = data?.event; 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 capacity = eventMeta?.capacity ?? 0; @@ -351,7 +361,7 @@ export default function AdminDoorPage() { async (opts: { ticketId?: string; attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string }; - payment?: { method: DoorPaymentMethod; amount: number }; + payment?: { method: DoorPaymentMethod; charge: DoorCharge }; entry: DoorEntryMethod; displayName: string; /** Ticket row to flash and optimistically mark as in. */ @@ -373,7 +383,7 @@ export default function AdminDoorPage() { at: clockTime(now), entry: opts.entry, method: opts.payment?.method ?? null, - amount: opts.payment?.amount ?? 0, + amount: opts.payment?.charge.amount ?? 0, startedAt: now.getTime(), undone: false, failed: false, @@ -424,10 +434,19 @@ export default function AdminDoorPage() { // ── The write itself: background, retried, never blocking the queue ── try { + const { method, charge } = opts.payment ?? {}; const res = await submitDoorAction(eventId, { ticketId: opts.ticketId, 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, idempotencyKey, }); @@ -435,7 +454,13 @@ export default function AdminDoorPage() { setSessionEntries((prev) => prev.map((e) => 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, ), ); @@ -508,10 +533,10 @@ export default function AdminDoorPage() { ); const handleRowPay = useCallback( - (attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => { + (attendee: DoorAttendee, method: DoorPaymentMethod, charge: DoorCharge) => { runAction({ ticketId: attendee.ticketId, - payment: { method, amount }, + payment: { method, charge }, entry: 'search', displayName: attendee.fullName, optimisticTicketId: attendee.ticketId, @@ -522,7 +547,7 @@ export default function AdminDoorPage() { ); const handleWalkInPay = useCallback( - (method: DoorPaymentMethod, amount: number) => { + (method: DoorPaymentMethod, charge: DoorCharge) => { const firstName = walkInDraft.firstName.trim(); if (!firstName) return; const displayName = walkInDraft.lastName.trim() @@ -536,7 +561,7 @@ export default function AdminDoorPage() { email: walkInDraft.email.trim() || undefined, ruc: walkInDraft.ruc.trim() || undefined, }, - payment: { method, amount }, + payment: { method, charge }, entry: 'walkin', displayName, busyKey: 'walk-in', @@ -764,11 +789,13 @@ export default function AdminDoorPage() { attendee={attendee} currency={currency} price={price} + methods={doorMethods} + canOverrideAmount={canOverrideAmount} expanded={expandedId === attendee.ticketId} flashing={flashId === attendee.ticketId} busy={busyId === attendee.ticketId} 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} expanded={walkInOpen} draft={walkInDraft} - price={price} + price={walkInPrice} + priceSource={walkInPriceSource} currency={currency} + methods={doorMethods} + canOverrideAmount={canOverrideAmount} busy={busyId === 'walk-in'} onExpand={() => { setWalkInDraft(emptyWalkIn(trimmedQuery)); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b982f97..920a6f3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -294,7 +294,13 @@ "edit": "Edit Event", "delete": "Delete Event", "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": { "title": "Manage Tickets", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 5771049..36fcd11 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -294,7 +294,13 @@ "edit": "Editar Evento", "delete": "Eliminar Evento", "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": { "title": "Gestionar Tickets", diff --git a/frontend/src/lib/api/door.ts b/frontend/src/lib/api/door.ts index e97a955..60464b2 100644 --- a/frontend/src/lib/api/door.ts +++ b/frontend/src/lib/api/door.ts @@ -5,7 +5,7 @@ import { fetchApi } from './client'; // fire actions optimistically and retry on flaky venue wifi without ever // 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 DoorEntryMethod = 'scan' | 'search' | 'walkin'; @@ -32,7 +32,20 @@ export interface DoorAttendee { } 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[]; stats: { checkedIn: number; totalActive: number; capacity: number }; } @@ -46,7 +59,11 @@ export interface DoorCheckinRequest { email?: 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; idempotencyKey: string; } @@ -55,7 +72,13 @@ export interface DoorCheckinResponse { ok: true; action: 'checkin' | 'walkin'; 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. */ warnings: string[]; idempotencyKey: string; diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts index 2da9d9d..740c14a 100644 --- a/frontend/src/lib/api/payments.ts +++ b/frontend/src/lib/api/payments.ts @@ -3,7 +3,7 @@ import type { Payment, PaymentWithDetails } from './types'; // Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to // 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 { return MANUAL_PAYMENT_PROVIDERS.includes(provider); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index af0f304..5d9801f 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -12,6 +12,9 @@ export interface Event { location: string; locationUrl?: string; 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; capacity: number; status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; @@ -57,6 +60,8 @@ export interface Ticket { adminNote?: string; isGuest?: boolean; 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; event?: Event; payment?: Payment; @@ -124,7 +129,7 @@ export interface LiveSearchResult { export interface Payment { id: string; ticketId: string; - provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; + provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago' | 'pos'; amount: number; currency: string; status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold'; @@ -178,6 +183,8 @@ export interface PaymentOptionsConfig { cashEnabled: boolean; cashInstructions?: string | null; cashInstructionsEs?: string | null; + // POS card terminal on the door screen (never offered at online checkout) + posEnabled?: boolean; // Booking settings allowDuplicateBookings?: boolean; } @@ -362,6 +369,7 @@ export interface FinancialSummary { cash: number; bank_transfer: number; tpago: number; + pos?: number; }; paidCount: number; pendingCount: number;