From e296e80e4802953ddd8ba997373b1646d57f74ff Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 22 Aug 2026 06:09:55 +0000 Subject: [PATCH] Rebuild the Scanner page into a unified door check-in screen. Most attendees arrive without their QR open, and taking money at the door meant leaving the scanner for the event dashboard, where the Add Ticket modal demanded an email and recorded no payment method. The screen now leads with manual name search, keeps the camera one tap away behind a fullscreen overlay, and creates and charges walk-ins inline. Check-in and payment are one action: anything done here is born confirmed, paid (or comp) and checked in through a single endpoint, POST /api/events/:eventId/door-checkin. There are no confirm dialogs anywhere, because they stall the queue; a ten-second Undo replaces them, reversing exactly what the action changed via the undo state recorded alongside its idempotency key. Writes fire in the background with retries, so venue wifi never blocks the person at the door, and a capacity limit only warns, since staff at the door are the authority. Every write carries a client-generated idempotency key, inserted in the same transaction as the writes it guards, so a double tap or a retry after a timeout cannot produce a second ticket, payment or check-in. Search runs entirely in memory over one preloaded list: names are matched accent- and case-insensitively in both directions, per word, prefix before substring, with a mostly-numeric query searching phone digits so two people with the same name can be told apart. Door money is recorded as payments.source 'door' plus payments.method (cash, bitcoin, transfer or guest) while provider keeps its existing value, so capacity counting, the stale-booking sweeps and the admin payment lists are unaffected and revenue can still be split pre-sale versus door. Bitcoin records the payment as made, on the same trust model as cash, with no invoice generated; lib/doorPayments.ts is where a real Lightning flow slots in later. Also fixes the SQLite tickets DDL, which still created the pre-split attendee_name column with NOT NULL email and phone. Only fresh databases were affected -- existing ones were relaxed by later ALTERs -- but on those, door walk-ins (and any other ticket) could not be inserted at all. Co-Authored-By: Claude Opus 5 --- backend/src/db/migrate.ts | 54 +- backend/src/db/schema.ts | 39 + backend/src/index.ts | 114 ++ backend/src/lib/doorPayments.ts | 51 + backend/src/lib/txOps.ts | 42 + backend/src/routes/door.integration.test.ts | 388 ++++ backend/src/routes/door.ts | 651 +++++++ .../events/[id]/_hooks/useEventDetailData.ts | 14 +- .../admin/events/[id]/_tabs/PaymentsTab.tsx | 81 +- frontend/src/app/admin/events/[id]/page.tsx | 37 +- .../admin/scanner/_components/AttendeeRow.tsx | 159 ++ .../scanner/_components/PaymentButtons.tsx | 185 ++ .../scanner/_components/QRScannerOverlay.tsx | 179 ++ .../scanner/_components/SessionSheet.tsx | 232 +++ .../admin/scanner/_components/WalkInRow.tsx | 157 ++ .../src/app/admin/scanner/_lib/doorActions.ts | 56 + .../src/app/admin/scanner/_lib/feedback.ts | 41 + frontend/src/app/admin/scanner/_lib/search.ts | 170 ++ frontend/src/app/admin/scanner/page.tsx | 1555 +++++++---------- frontend/src/lib/api/client.ts | 4 +- frontend/src/lib/api/door.ts | 112 ++ frontend/src/lib/api/index.ts | 11 + 22 files changed, 3419 insertions(+), 913 deletions(-) create mode 100644 backend/src/lib/doorPayments.ts create mode 100644 backend/src/lib/txOps.ts create mode 100644 backend/src/routes/door.integration.test.ts create mode 100644 backend/src/routes/door.ts create mode 100644 frontend/src/app/admin/scanner/_components/AttendeeRow.tsx create mode 100644 frontend/src/app/admin/scanner/_components/PaymentButtons.tsx create mode 100644 frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx create mode 100644 frontend/src/app/admin/scanner/_components/SessionSheet.tsx create mode 100644 frontend/src/app/admin/scanner/_components/WalkInRow.tsx create mode 100644 frontend/src/app/admin/scanner/_lib/doorActions.ts create mode 100644 frontend/src/app/admin/scanner/_lib/feedback.ts create mode 100644 frontend/src/app/admin/scanner/_lib/search.ts create mode 100644 frontend/src/lib/api/door.ts diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 344fa30..2f0060b 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -133,17 +133,25 @@ async function migrate() { `); await (db as any).run(sql` + -- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone + -- shape only survives in databases created before the split into + -- first/last name, where the ALTERs below relaxed it; a fresh database + -- must not recreate constraints the app no longer satisfies (door + -- walk-ins have neither an email nor a phone). CREATE TABLE IF NOT EXISTS tickets ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), event_id TEXT NOT NULL REFERENCES events(id), - attendee_name TEXT NOT NULL, - attendee_email TEXT NOT NULL, - attendee_phone TEXT NOT NULL, + attendee_first_name TEXT NOT NULL, + attendee_last_name TEXT, + attendee_email TEXT, + attendee_phone TEXT, + attendee_ruc TEXT, preferred_language TEXT, status TEXT NOT NULL DEFAULT 'pending', checkin_at TEXT, qr_code TEXT, + admin_note TEXT, created_at TEXT NOT NULL ) `); @@ -259,6 +267,25 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); } catch (e) { /* column may already exist */ } + // Door check-in screen: split pre-sale vs door revenue and record the tender + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`); + } catch (e) { /* column may already exist */ } + + // Idempotency records for door check-in actions (retries / double taps) + await (db as any).run(sql` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT PRIMARY KEY, + scope TEXT NOT NULL, + result TEXT NOT NULL, + undo_state TEXT, + undone_at TEXT, + created_at TEXT NOT NULL + ) + `); // Invoices table await (db as any).run(sql` @@ -836,6 +863,25 @@ async function migrate() { try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); } catch (e) { /* column may already exist */ } + // Door check-in screen: split pre-sale vs door revenue and record the tender + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`); + } catch (e) { /* column may already exist */ } + + // Idempotency records for door check-in actions (retries / double taps) + await (db as any).execute(sql` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key VARCHAR(128) PRIMARY KEY, + scope VARCHAR(64) NOT NULL, + result TEXT NOT NULL, + undo_state TEXT, + undone_at TIMESTAMP, + created_at TIMESTAMP NOT NULL + ) + `); // Invoices table await (db as any).execute(sql` @@ -1203,6 +1249,8 @@ async function migrate() { `CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`, `CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`, `CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`, + `CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`, + `CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`, `CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`, `CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`, `CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index cfdda72..abae95f 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -138,10 +138,32 @@ export const sqlitePayments = sqliteTable('payments', { paidByAdminId: text('paid_by_admin_id'), adminNote: text('admin_note'), // Internal admin notes reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent + // Where the money was taken: 'presale' (online/admin, the default) or 'door' + // (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue. + 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'] }), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), }); +// Idempotency records for door check-in actions. +// +// The door screen fires check-ins / walk-in creations optimistically and retries +// on flaky venue wifi, so every action carries a client-generated key. The first +// request stores its response here; replays return that stored response instead +// of creating a second ticket, payment or check-in. `undoState` holds exactly +// what the action changed so the 10-second Undo can reverse it precisely. +export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', { + key: text('key').primaryKey(), + scope: text('scope').notNull(), + result: text('result').notNull(), // JSON response body of the original request + undoState: text('undo_state'), // JSON describing how to reverse the action + undoneAt: text('undone_at'), + createdAt: text('created_at').notNull(), +}); + // Payment Options Configuration Table (global settings) export const sqlitePaymentOptions = sqliteTable('payment_options', { id: text('id').primaryKey(), @@ -504,10 +526,26 @@ export const pgPayments = pgTable('payments', { paidByAdminId: uuid('paid_by_admin_id'), adminNote: pgText('admin_note'), reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent + // Where the money was taken: 'presale' (online/admin, the default) or 'door' + // (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue. + source: varchar('source', { length: 20 }).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: varchar('method', { length: 20 }), createdAt: timestamp('created_at').notNull(), updatedAt: timestamp('updated_at').notNull(), }); +// Idempotency records for door check-in actions (see sqliteIdempotencyKeys). +export const pgIdempotencyKeys = pgTable('idempotency_keys', { + key: varchar('key', { length: 128 }).primaryKey(), + scope: varchar('scope', { length: 64 }).notNull(), + result: pgText('result').notNull(), + undoState: pgText('undo_state'), + undoneAt: timestamp('undone_at'), + createdAt: timestamp('created_at').notNull(), +}); + // Payment Options Configuration Table (global settings) export const pgPaymentOptions = pgTable('payment_options', { id: uuid('id').primaryKey(), @@ -734,6 +772,7 @@ export const events = dbType === 'postgres' ? pgEvents : sqliteEvents; export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases; export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets; export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments; +export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys; export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts; export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers; export const media = dbType === 'postgres' ? pgMedia : sqliteMedia; diff --git a/backend/src/index.ts b/backend/src/index.ts index c3b05d9..f954361 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ import authExtRoutes from './routes/authExt.js'; import { getClientIp } from './lib/rateLimit.js'; import eventsRoutes from './routes/events.js'; import ticketsRoutes from './routes/tickets.js'; +import doorRoutes from './routes/door.js'; import usersRoutes from './routes/users.js'; import contactsRoutes from './routes/contacts.js'; import paymentsRoutes from './routes/payments.js'; @@ -767,6 +768,116 @@ const openApiSpec = { }, }, }, + // ==================== Door Check-in Screen ==================== + '/api/events/{eventId}/door-attendees': { + get: { + tags: ['Tickets'], + summary: 'Full attendee list for the door check-in screen', + description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + 200: { description: 'Event, attendees and check-in stats' }, + 404: { description: 'Event not found' }, + }, + }, + }, + '/api/events/{eventId}/door-checkin': { + 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.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['idempotencyKey'], + properties: { + ticketId: { type: 'string' }, + attendee: { + type: 'object', + required: ['firstName'], + properties: { + firstName: { type: 'string' }, + lastName: { type: 'string' }, + phone: { type: 'string' }, + email: { type: 'string', format: 'email' }, + ruc: { type: 'string' }, + }, + }, + payment: { + 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.' }, + }, + }, + entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] }, + idempotencyKey: { type: 'string' }, + }, + }, + }, + }, + }, + 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' }, + 404: { description: 'Event or ticket not found' }, + }, + }, + }, + '/api/events/{eventId}/door-checkin/undo': { + post: { + tags: ['Tickets'], + summary: 'Reverse one door check-in action', + description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['idempotencyKey'], + properties: { idempotencyKey: { type: 'string' } }, + }, + }, + }, + }, + responses: { + 200: { description: 'Action reversed (or already undone)' }, + 404: { description: 'No action recorded for this key' }, + }, + }, + }, + '/api/events/{eventId}/door-summary': { + get: { + tags: ['Payments'], + summary: 'Door cash-up and pre-sale/door revenue split', + description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + 200: { description: 'Door totals by method, door lines, and pre-sale totals' }, + 404: { description: 'Event not found' }, + }, + }, + }, '/api/tickets/{id}/checkin': { post: { tags: ['Tickets'], @@ -1908,6 +2019,9 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => { ); }); app.route('/api/auth-ext', authExtRoutes); +// Door check-in screen endpoints live under /api/events/:eventId/door-*. +// Mounted first so the generic /:id routes below can never shadow them. +app.route('/api/events', doorRoutes); app.route('/api/events', eventsRoutes); app.route('/api/tickets', ticketsRoutes); app.route('/api/users', usersRoutes); diff --git a/backend/src/lib/doorPayments.ts b/backend/src/lib/doorPayments.ts new file mode 100644 index 0000000..397736e --- /dev/null +++ b/backend/src/lib/doorPayments.ts @@ -0,0 +1,51 @@ +// Door payment tenders. +// +// The door check-in screen offers four one-tap 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. +// +// Bitcoin currently maps to the 'lightning' provider but records the payment as +// 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. + +export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', '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'; + /** Human label used in payment references and toasts. */ + label: string; + /** Comp tenders carry no revenue and always record a zero amount. */ + isComp: boolean; +} + +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 }, +}; + +export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod { + return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value); +} + +/** Ticket paymentStatus a tender settles to: comps are 'comp', everything else 'paid'. */ +export function paymentStatusForMethod(method: DoorPaymentMethod): 'paid' | 'comp' { + return DOOR_TENDERS[method].isComp ? 'comp' : 'paid'; +} + +/** Amount actually recorded: comps are always zero regardless of what was requested. */ +export function amountForMethod(method: DoorPaymentMethod, requested: number): number { + return DOOR_TENDERS[method].isComp ? 0 : Math.max(0, requested); +} + +export function doorReference(method: DoorPaymentMethod): string { + return DOOR_TENDERS[method].isComp + ? 'Door — guest (comp)' + : `Door — paid by ${DOOR_TENDERS[method].label}`; +} diff --git a/backend/src/lib/txOps.ts b/backend/src/lib/txOps.ts new file mode 100644 index 0000000..2196a6a --- /dev/null +++ b/backend/src/lib/txOps.ts @@ -0,0 +1,42 @@ +// Engine-neutral transactional writes. +// +// better-sqlite3 transactions take a *synchronous* callback (awaiting inside one +// silently breaks atomicity), while node-postgres takes an async one. Rather than +// fork every multi-write route into two near-identical branches, callers build a +// plain list of operations and hand it here: the business logic stays in one +// place and only the six lines below know which driver is underneath. + +import { db, isSqlite } from '../db/index.js'; + +export type TxOp = + | { kind: 'insert'; table: any; values: any } + | { kind: 'update'; table: any; values: any; where: any } + | { kind: 'delete'; table: any; where: any }; + +export const insertOp = (table: any, values: any): TxOp => ({ kind: 'insert', table, values }); +export const updateOp = (table: any, values: any, where: any): TxOp => ({ kind: 'update', table, values, where }); +export const deleteOp = (table: any, where: any): TxOp => ({ kind: 'delete', table, where }); + +/** Apply every op inside a single transaction; any throw rolls back all of them. */ +export async function runOps(ops: TxOp[]): Promise { + if (ops.length === 0) return; + + if (isSqlite()) { + (db as any).transaction((tx: any) => { + for (const op of ops) { + if (op.kind === 'insert') tx.insert(op.table).values(op.values).run(); + else if (op.kind === 'update') tx.update(op.table).set(op.values).where(op.where).run(); + else tx.delete(op.table).where(op.where).run(); + } + }); + return; + } + + await (db as any).transaction(async (tx: any) => { + for (const op of ops) { + if (op.kind === 'insert') await tx.insert(op.table).values(op.values); + else if (op.kind === 'update') await tx.update(op.table).set(op.values).where(op.where); + else await tx.delete(op.table).where(op.where); + } + }); +} diff --git a/backend/src/routes/door.integration.test.ts b/backend/src/routes/door.integration.test.ts new file mode 100644 index 0000000..929b557 --- /dev/null +++ b/backend/src/routes/door.integration.test.ts @@ -0,0 +1,388 @@ +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'; + +// Env must be pinned before the db singleton is imported (dotenv never overrides). +const dir = mkdtempSync(join(tmpdir(), 'door-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 = 'door-test-secret-0123456789abcdef'; +delete process.env.REDIS_URL; + +const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' }; + +// The door endpoints are behind staff auth; the flows under test are the writes, +// not Better Auth, which has its own integration suite. +vi.mock('../lib/auth.js', () => ({ + requireAuth: () => async (c: any, next: any) => { + c.set('user', STAFF); + await next(); + }, + getAuthUser: async () => STAFF, +})); + +// Walk-ins with an email trigger a confirmation send; keep it out of the test. +vi.mock('../lib/email.js', () => ({ + default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) }, +})); + +let app: any; +let sqlite: any; + +const EVENT_ID = 'evt-door-1'; +const PRICE = 60000; + +/** POST helper that mirrors how the door screen calls the API. */ +async function post(path: string, body: unknown) { + const res = await app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +async function get(path: string) { + const res = await app.request(path); + return { status: res.status, body: await res.json() }; +} + +function seedTicket(row: { + id: string; + first: string; + last?: string | null; + status: string; + paymentStatus: string; + phone?: string | null; + bookingId?: string | null; + qr?: string; +}) { + sqlite + .prepare( + `INSERT INTO tickets (id, booking_id, user_id, event_id, attendee_first_name, attendee_last_name, + attendee_email, attendee_phone, status, payment_status, is_guest, qr_code, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)` + ) + .run( + row.id, + row.bookingId ?? null, + 'seed-user', + EVENT_ID, + row.first, + row.last ?? null, + `${row.id}@test.py`, + row.phone ?? null, + row.status, + row.paymentStatus, + row.qr ?? `QR-${row.id}`, + new Date().toISOString() + ); +} + +beforeAll(() => { + execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' }); + + return (async () => { + const { Hono } = await import('hono'); + const doorRoutes = (await import('./door.js')).default; + app = new Hono(); + app.route('/api/events', doorRoutes); + + const Database = (await import('better-sqlite3')).default; + sqlite = new Database(dbPath); + + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at) + VALUES (?, ?, ?, 'user', 0, 'unclaimed', ?, ?)` + ) + .run('seed-user', 'seed@test.py', 'Seed User', now, now); + sqlite + .prepare( + `INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at) + VALUES (?, ?, ?, 'staff', 1, 'active', ?, ?)` + ) + .run(STAFF.id, 'staff@test.py', STAFF.name, now, now); + sqlite + .prepare( + `INSERT INTO events (id, title, description, start_datetime, location, price, currency, capacity, status, created_at, updated_at) + VALUES (?, 'Door Night', 'desc', ?, 'Asuncion', ?, 'PYG', 2, 'published', ?, ?)` + ) + .run(EVENT_ID, now, PRICE, now, now); + + seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567' }); + seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' }); + seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' }); + seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' }); + })(); +}, 120_000); + +describe('door-attendees', () => { + it('returns everyone including cancelled, with group bookings flagged', async () => { + const { status, body } = await get(`/api/events/${EVENT_ID}/door-attendees`); + expect(status).toBe(200); + expect(body.event.price).toBe(PRICE); + expect(body.attendees).toHaveLength(4); + + const cancelled = body.attendees.find((a: any) => a.ticketId === 'tkt-cancelled'); + expect(cancelled.status).toBe('cancelled'); + + const grouped = body.attendees.find((a: any) => a.ticketId === 'tkt-unpaid'); + expect(grouped.isGroupBooking).toBe(true); + expect(grouped.amountDue).toBe(PRICE); + + const solo = body.attendees.find((a: any) => a.ticketId === 'tkt-paid'); + expect(solo.isGroupBooking).toBe(false); + expect(solo.amountDue).toBe(0); + }); + + it('is sorted alphabetically so an empty search is scrollable', async () => { + const { body } = await get(`/api/events/${EVENT_ID}/door-attendees`); + const names = body.attendees.map((a: any) => a.fullName); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))); + }); +}); + +describe('door-checkin: existing ticket', () => { + it('checks in a paid attendee with no payment record touched', async () => { + const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + ticketId: 'tkt-paid', + entryMethod: 'search', + idempotencyKey: 'key-paid-checkin', + }); + expect(status).toBe(201); + expect(body.attendee.checkedIn).toBe(true); + expect(body.attendee.checkinAt).toBeTruthy(); + expect(body.attendee.checkedInBy).toBe(STAFF.name); + expect(body.payment).toBeNull(); + + const row = sqlite.prepare('SELECT status, checked_in_by_admin_id FROM tickets WHERE id = ?').get('tkt-paid'); + expect(row.status).toBe('checked_in'); + expect(row.checked_in_by_admin_id).toBe(STAFF.id); + }); + + it('replays an already-processed key instead of checking in twice', async () => { + const before = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at; + + const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + ticketId: 'tkt-paid', + idempotencyKey: 'key-paid-checkin', + }); + expect(status).toBe(200); + expect(body.replayed).toBe(true); + + const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at; + expect(after).toBe(before); + expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-paid').n).toBe(0); + }); + + it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => { + const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + ticketId: 'tkt-unpaid', + payment: { method: 'cash', amount: PRICE }, + entryMethod: 'search', + idempotencyKey: 'key-unpaid-cash', + }); + expect(status).toBe(201); + expect(body.attendee.paymentStatus).toBe('paid'); + expect(body.attendee.checkedIn).toBe(true); + expect(body.payment).toMatchObject({ method: 'cash', amount: PRICE }); + + const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid'); + expect(payment.source).toBe('door'); + expect(payment.method).toBe('cash'); + expect(payment.provider).toBe('cash'); + expect(payment.status).toBe('paid'); + expect(payment.paid_by_admin_id).toBe(STAFF.id); + }); + + 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 }, + idempotencyKey: 'key-unpaid-2-transfer', + }); + expect(body.payment.amount).toBe(PRICE * 2); + const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid-2'); + expect(payment.provider).toBe('bank_transfer'); + expect(payment.method).toBe('transfer'); + expect(payment.amount).toBe(PRICE * 2); + }); + + it('reactivates a cancelled ticket through the same payment flow', async () => { + const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + ticketId: 'tkt-cancelled', + payment: { method: 'bitcoin' }, + idempotencyKey: 'key-cancelled-reactivate', + }); + expect(body.attendee.status).toBe('checked_in'); + expect(body.attendee.paymentStatus).toBe('paid'); + const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-cancelled'); + // Bitcoin is recorded as already-paid Lightning: same trust model as cash, + // no invoice generated (see lib/doorPayments.ts). + expect(payment.provider).toBe('lightning'); + expect(payment.method).toBe('bitcoin'); + expect(payment.amount).toBe(PRICE); + }); + + it('rejects a ticket from another event', async () => { + const { status, body } = await post('/api/events/other-event/door-checkin', { + ticketId: 'tkt-paid', + idempotencyKey: 'key-wrong-event', + }); + expect(status).toBe(404); + expect(body.error).toMatch(/Event not found/); + }); +}); + +describe('door-checkin: walk-ins', () => { + it('creates a cash walk-in confirmed, paid and checked in with no email', async () => { + const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Walk' }, + payment: { method: 'cash' }, + entryMethod: 'walkin', + idempotencyKey: 'key-walkin-cash', + }); + expect(status).toBe(201); + expect(body.action).toBe('walkin'); + expect(body.attendee.fullName).toBe('Walk'); + expect(body.attendee.checkedIn).toBe(true); + expect(body.attendee.paymentStatus).toBe('paid'); + expect(body.attendee.email).toBeNull(); + + const ticket = sqlite.prepare('SELECT * FROM tickets WHERE id = ?').get(body.attendee.ticketId); + expect(ticket.status).toBe('checked_in'); + expect(ticket.qr_code).toBeTruthy(); + // A placeholder account keeps users.email unique without mailing anyone. + const account = sqlite.prepare('SELECT email FROM users WHERE id = ?').get(ticket.user_id); + expect(account.email).toMatch(/@doorentry\.local$/); + }); + + it('records a guest walk-in as a zero-amount comp', async () => { + const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Free', lastName: 'Guest' }, + payment: { method: 'guest', amount: PRICE }, + entryMethod: 'walkin', + idempotencyKey: 'key-walkin-guest', + }); + expect(body.attendee.paymentStatus).toBe('comp'); + expect(body.attendee.isGuest).toBe(true); + expect(body.payment.amount).toBe(0); + const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId); + expect(payment.amount).toBe(0); + expect(payment.method).toBe('guest'); + }); + + it('does not create a second ticket when the same walk-in key is retried', async () => { + const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n; + const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Walk' }, + payment: { method: 'cash' }, + idempotencyKey: 'key-walkin-cash', + }); + expect(status).toBe(200); + expect(body.replayed).toBe(true); + expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before); + }); + + it('warns rather than blocks once the event is over capacity', async () => { + // Capacity is 2 and several tickets already hold seats. + const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Overflow' }, + payment: { method: 'cash' }, + idempotencyKey: 'key-walkin-overflow', + }); + expect(body.ok).toBe(true); + expect(body.warnings).toContain('at_capacity'); + }); +}); + +describe('undo', () => { + it('reverts a plain check-in to its previous state', async () => { + seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid' }); + await post(`/api/events/${EVENT_ID}/door-checkin`, { + ticketId: 'tkt-undo', + idempotencyKey: 'key-undo-checkin', + }); + expect(sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get('tkt-undo').status).toBe('checked_in'); + + const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { + idempotencyKey: 'key-undo-checkin', + }); + expect(status).toBe(200); + expect(body.reverted).toBe('existing'); + + const row = sqlite.prepare('SELECT status, checkin_at FROM tickets WHERE id = ?').get('tkt-undo'); + expect(row.status).toBe('confirmed'); + expect(row.checkin_at).toBeNull(); + }); + + it('removes the payment it created and restores the unpaid balance', async () => { + seedTicket({ id: 'tkt-undo-pay', first: 'Undo', last: 'Pay', status: 'confirmed', paymentStatus: 'unpaid' }); + await post(`/api/events/${EVENT_ID}/door-checkin`, { + ticketId: 'tkt-undo-pay', + payment: { method: 'cash' }, + idempotencyKey: 'key-undo-pay', + }); + expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(1); + + await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-pay' }); + + const row = sqlite.prepare('SELECT status, payment_status FROM tickets WHERE id = ?').get('tkt-undo-pay'); + expect(row.status).toBe('confirmed'); + expect(row.payment_status).toBe('unpaid'); + expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(0); + }); + + it('cancels a walk-in it created', async () => { + const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Mistake' }, + payment: { method: 'cash' }, + idempotencyKey: 'key-undo-walkin', + }); + await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' }); + + const ticket = sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get(body.attendee.ticketId); + expect(ticket.status).toBe('cancelled'); + const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId); + expect(payment.status).toBe('cancelled'); + }); + + it('is safe to call twice and rejects an unknown key', async () => { + const repeat = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' }); + expect(repeat.body.alreadyUndone).toBe(true); + + const unknown = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'never-happened' }); + expect(unknown.status).toBe(404); + }); +}); + +describe('door-summary', () => { + it('totals door takings by tender and splits them from pre-sale', async () => { + const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`); + expect(status).toBe(200); + + // Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are + // cancelled and no longer count). + expect(body.door.byMethod.cash.count).toBe(3); + expect(body.door.byMethod.cash.total).toBe(PRICE * 3); + expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 }); + expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE }); + expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 }); + expect(body.door.total).toBe(PRICE * 6); + + // Settled tickets with no door payment against them: tkt-paid, plus tkt-undo, + // whose door check-in was undone and which is a pre-paid ticket again. + expect(body.presale.count).toBe(2); + expect(body.presale.total).toBe(PRICE * 2); + expect(body.total).toBe(PRICE * 8); + + expect(body.door.lines.length).toBe(body.door.count); + expect(body.door.lines[0]).toHaveProperty('name'); + }); +}); diff --git a/backend/src/routes/door.ts b/backend/src/routes/door.ts new file mode 100644 index 0000000..cea7ad2 --- /dev/null +++ b/backend/src/routes/door.ts @@ -0,0 +1,651 @@ +// Door check-in screen (admin/scanner) API. +// +// At the door, check-in and ticket creation are the same action, so everything +// here is written for one-tap speed on a phone with unreliable venue wifi: +// +// GET /:eventId/door-attendees full attendee list, fetched once and searched +// client-side so typing never hits the network +// POST /:eventId/door-checkin the single write endpoint — checks in, settles +// payment, or creates a walk-in, atomically +// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did +// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue split +// +// Every write carries a client-generated idempotencyKey. The key is inserted in +// the same transaction as the writes, so a double tap or a retry after a timeout +// can never produce a second ticket, a second payment or a double check-in — the +// replay returns the original response instead. + +import { Hono } from 'hono'; +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, +} from '../db/index.js'; +import { requireAuth } from '../lib/auth.js'; +import { generateId, generateTicketCode, getNow, toDbBool, toDbDate } from '../lib/utils.js'; +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, +} from '../lib/doorPayments.js'; +import emailService from '../lib/email.js'; + +const doorRouter = new Hono(); + +const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const; +const IDEMPOTENCY_SCOPE = 'door-checkin'; + +// ==================== Shared helpers ==================== + +const num = (v: any): number => { + const n = typeof v === 'string' ? parseFloat(v) : Number(v); + return Number.isFinite(n) ? n : 0; +}; + +const iso = (v: any): string | null => { + if (!v) return null; + return v instanceof Date ? v.toISOString() : String(v); +}; + +function fullName(ticket: any): string { + return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); +} + +/** + * The row shape the door screen renders. Returned both by the preload list and + * by every write, so the client can splice an updated attendee straight back + * into its in-memory list without a refetch. + */ +function toDoorAttendee( + ticket: any, + opts: { price: number; groupBookingIds: Set; adminNames: Map; doorMethod?: string | null } , +) { + return { + ticketId: ticket.id, + firstName: ticket.attendeeFirstName, + lastName: ticket.attendeeLastName || null, + fullName: fullName(ticket), + email: ticket.attendeeEmail || null, + phone: ticket.attendeePhone || null, + status: ticket.status, + paymentStatus: ticket.paymentStatus, + isGuest: !!ticket.isGuest, + checkedIn: ticket.status === 'checked_in', + checkinAt: iso(ticket.checkinAt), + checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null, + bookingId: ticket.bookingId || null, + isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)), + amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0, + doorMethod: opts.doorMethod ?? null, + qrCode: ticket.qrCode || null, + createdAt: iso(ticket.createdAt), + }; +} + +async function loadEvent(eventId: string) { + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, eventId)) + ); + if (!event) return null; + return { + ...event, + price: num(event.price), + capacity: Number(event.capacity), + }; +} + +/** 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))]; + if (unique.length === 0) return new Map(); + const rows = await dbAll( + (db as any) + .select({ id: (users as any).id, name: (users as any).name }) + .from(users) + .where(inArray((users as any).id, unique)) + ); + return new Map(rows.map((r: any) => [r.id, r.name])); +} + +/** Seats currently held for an event, used only to warn (never to block) at the door. */ +async function seatsHeld(eventId: string): Promise { + const row = await dbGet(seatHolderCountQuery(db, eventId)); + return Number(row?.count || 0); +} + +// ==================== GET /:eventId/door-attendees ==================== +// One payload, fetched on load and refreshed every ~30s by the client. Cancelled +// tickets are included on purpose: staff must be able to see and reactivate them. + +doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async (c) => { + const eventId = c.req.param('eventId'); + + const event = await loadEvent(eventId); + if (!event) return c.json({ error: 'Event not found' }, 404); + + const rows = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId)) + ); + + // A booking id shared by more than one ticket marks a group booking, which is + // the usual reason an otherwise-confirmed attendee still shows as unpaid. + const bookingCounts = new Map(); + for (const t of rows) { + if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1); + } + const groupBookingIds = new Set( + [...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id) + ); + + const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId)); + + // Door tender per ticket, so a row already settled at the door shows how. + // Joined on the event rather than on a list of ticket ids: the id list would + // grow with the guest list and eventually blow the statement parameter limit. + const doorMethods = new Map(); + const doorPayments = await dbAll( + (db as any) + .select({ ticketId: (payments as any).ticketId, method: (payments as any).method }) + .from(payments) + .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) + .where(and( + eq((tickets as any).eventId, eventId), + eq((payments as any).source, 'door') + )) + ); + for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method); + + const attendees = rows + .map((t: any) => toDoorAttendee(t, { + price: event.price, + groupBookingIds, + adminNames, + doorMethod: doorMethods.get(t.id) || null, + })) + .sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' })); + + const checkedIn = attendees.filter((a) => a.checkedIn).length; + const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length; + + return c.json({ + event: { + id: event.id, + title: event.title, + price: event.price, + currency: event.currency, + capacity: event.capacity, + }, + attendees, + stats: { checkedIn, totalActive, capacity: event.capacity }, + }); +}); + +// ==================== POST /:eventId/door-checkin ==================== + +const doorCheckinSchema = z.object({ + // Existing ticket to check in (and optionally settle), or… + ticketId: z.string().optional(), + // …a walk-in to create. Only a first name is ever required. + attendee: z.object({ + firstName: z.string().trim().min(1).max(255), + lastName: z.string().trim().max(255).optional().or(z.literal('')), + phone: z.string().trim().max(50).optional().or(z.literal('')), + email: z.string().trim().email().optional().or(z.literal('')), + ruc: z.string().trim().max(15).optional().or(z.literal('')), + }).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. + amount: z.number().min(0).optional(), + }).optional(), + // How the attendee reached this action, for the session feed. + entryMethod: z.enum(['scan', 'search', 'walkin']).optional(), + idempotencyKey: z.string().min(8).max(128), +}).refine((d) => !!d.ticketId || !!d.attendee, { + message: 'Either ticketId or attendee is required', + path: ['ticketId'], +}); + +/** Undo instructions recorded alongside each processed idempotency key. */ +type UndoState = + | { + kind: 'created'; + ticketId: string; + paymentId: string; + } + | { + kind: 'existing'; + ticketId: string; + prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean }; + createdPaymentId?: string; + prevPayment?: { + id: string; provider: string; amount: number; status: string; reference: string | null; + paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null; + }; + }; + +/** A replay of a key we already processed returns the original response verbatim. */ +async function findProcessedKey(key: string) { + return dbGet( + (db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key)) + ); +} + +doorRouter.post( + '/:eventId/door-checkin', + requireAuth([...STAFF_ROLES]), + zValidator('json', doorCheckinSchema), + async (c) => { + const eventId = c.req.param('eventId'); + const data = c.req.valid('json'); + const adminUser = (c as any).get('user'); + + const existingKey = await findProcessedKey(data.idempotencyKey); + if (existingKey) { + return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt }); + } + + const event = await loadEvent(eventId); + if (!event) return c.json({ error: 'Event not found' }, 404); + + const now = getNow(); + const nowIso = new Date().toISOString(); + const method = data.payment?.method as DoorPaymentMethod | undefined; + const requestedAmount = data.payment?.amount ?? event.price; + + 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 emailTicketId: string | null = null; + + if (data.ticketId) { + // ---- Existing ticket: settle (optionally) and check in ---- + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId)) + ); + if (!ticket) return c.json({ error: 'Ticket not found' }, 404); + if (ticket.eventId !== eventId) { + return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400); + } + + action = 'checkin'; + const prevTicket = { + status: ticket.status, + checkinAt: iso(ticket.checkinAt), + checkedInByAdminId: ticket.checkedInByAdminId || null, + paymentStatus: ticket.paymentStatus, + isGuest: !!ticket.isGuest, + }; + const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket }; + + const ticketUpdate: Record = {}; + + if (method) { + const amount = amountForMethod(method, requestedAmount); + const tender = DOOR_TENDERS[method]; + ticketUpdate.paymentStatus = paymentStatusForMethod(method); + if (method === 'guest') ticketUpdate.isGuest = toDbBool(true); + + const existingPayment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id)) + ); + + if (existingPayment) { + undo.prevPayment = { + id: existingPayment.id, + provider: existingPayment.provider, + amount: num(existingPayment.amount), + status: existingPayment.status, + reference: existingPayment.reference || null, + paidAt: iso(existingPayment.paidAt), + paidByAdminId: existingPayment.paidByAdminId || null, + source: existingPayment.source || 'presale', + method: existingPayment.method || null, + }; + ops.push(updateOp(payments, { + provider: tender.provider, + amount, + currency: event.currency, + status: 'paid', + reference: doorReference(method), + paidAt: now, + paidByAdminId: adminUser?.id || null, + source: 'door', + method, + updatedAt: now, + }, eq((payments as any).id, existingPayment.id))); + paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency }; + } else { + const paymentId = generateId(); + undo.createdPaymentId = paymentId; + ops.push(insertOp(payments, { + id: paymentId, + ticketId: ticket.id, + provider: tender.provider, + amount, + currency: event.currency, + status: 'paid', + reference: doorReference(method), + paidAt: now, + paidByAdminId: adminUser?.id || null, + source: 'door', + method, + createdAt: now, + updatedAt: now, + })); + paymentSummary = { id: paymentId, method, amount, currency: event.currency }; + } + } + + // Check in. An already-checked-in ticket keeps its original timestamp so + // staff can still tell the person when they actually entered. + if (ticket.status !== 'checked_in') { + ticketUpdate.status = 'checked_in'; + ticketUpdate.checkinAt = now; + ticketUpdate.checkedInByAdminId = adminUser?.id || null; + } + + if (Object.keys(ticketUpdate).length > 0) { + ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id))); + } + + undoState = undo; + ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt }; + } else { + // ---- Walk-in: born confirmed, settled and checked in, in one write ---- + const attendee = data.attendee!; + action = 'walkin'; + const tenderMethod: DoorPaymentMethod = method || 'cash'; + const tender = DOOR_TENDERS[tenderMethod]; + const amount = amountForMethod(tenderMethod, requestedAmount); + const hasEmail = !!(attendee.email && attendee.email.trim()); + const firstNameValue = attendee.firstName.trim(); + const lastNameValue = attendee.lastName?.trim() || null; + const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue; + + // No email is the fast path; a placeholder keeps the users.email unique + // constraint satisfied without ever mailing anyone. + const accountEmail = hasEmail + ? attendee.email!.trim() + : `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`; + + let user = hasEmail + ? await dbGet((db as any).select().from(users).where(eq((users as any).email, accountEmail))) + : null; + + if (!user) { + const userId = generateId(); + user = { id: userId, email: accountEmail }; + ops.push(insertOp(users, { + id: userId, + email: accountEmail, + password: null, + name: displayName, + phone: attendee.phone?.trim() || null, + role: 'user', + languagePreference: null, + isClaimed: toDbBool(false), + accountStatus: 'unclaimed', + emailVerified: false, + createdAt: now, + updatedAt: now, + })); + } + + const ticketId = generateId(); + const paymentId = generateId(); + const newTicket = { + id: ticketId, + bookingId: null, + userId: user.id, + eventId, + attendeeFirstName: firstNameValue, + attendeeLastName: lastNameValue, + attendeeEmail: hasEmail ? attendee.email!.trim() : null, + attendeePhone: attendee.phone?.trim() || null, + attendeeRuc: attendee.ruc?.trim() || null, + preferredLanguage: null, + status: 'checked_in', + paymentStatus: paymentStatusForMethod(tenderMethod), + isGuest: toDbBool(tenderMethod === 'guest'), + qrCode: generateTicketCode(), + checkinAt: now, + checkedInByAdminId: adminUser?.id || null, + adminNote: null, + createdAt: now, + }; + ops.push(insertOp(tickets, newTicket)); + ops.push(insertOp(payments, { + id: paymentId, + ticketId, + provider: tender.provider, + amount, + currency: event.currency, + status: 'paid', + reference: doorReference(tenderMethod), + paidAt: now, + paidByAdminId: adminUser?.id || null, + source: 'door', + method: tenderMethod, + createdAt: now, + updatedAt: now, + })); + + paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency }; + undoState = { kind: 'created', ticketId, paymentId }; + ticketRow = newTicket; + // Only mail people who actually gave an address; no QR for the rest. + if (hasEmail) emailTicketId = ticketId; + } + + // 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; + + const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]); + const responseBody = { + ok: true, + action, + attendee: toDoorAttendee(ticketRow, { + price: event.price, + groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []), + adminNames, + doorMethod: paymentSummary?.method || null, + }), + payment: paymentSummary, + warnings: atCapacity ? ['at_capacity'] : [], + idempotencyKey: data.idempotencyKey, + processedAt: nowIso, + }; + + // The key row goes in with the writes, so two concurrent replays of the same + // key cannot both commit — the loser hits the primary-key conflict below. + ops.unshift(insertOp(idempotencyKeys, { + key: data.idempotencyKey, + scope: IDEMPOTENCY_SCOPE, + result: JSON.stringify(responseBody), + undoState: JSON.stringify(undoState), + undoneAt: null, + createdAt: now, + })); + + try { + await runOps(ops); + } catch (err: any) { + const replay = await findProcessedKey(data.idempotencyKey); + if (replay) { + return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt }); + } + throw err; + } + + if (emailTicketId) { + emailService.sendBookingConfirmation(emailTicketId).catch((err) => { + console.error('[Email] Failed to send door walk-in confirmation:', err); + }); + } + + return c.json(responseBody, 201); + } +); + +// ==================== POST /:eventId/door-checkin/undo ==================== +// Reverses exactly what the keyed action did — nothing more. This is what makes +// the door screen safe to run without a single confirm dialog. + +doorRouter.post( + '/:eventId/door-checkin/undo', + requireAuth([...STAFF_ROLES]), + zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })), + async (c) => { + const { idempotencyKey } = c.req.valid('json'); + + const record = await findProcessedKey(idempotencyKey); + if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404); + if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true }); + + const undo = JSON.parse(record.undoState || 'null') as UndoState | null; + if (!undo) return c.json({ error: 'This action cannot be undone' }, 400); + + const now = getNow(); + const ops: TxOp[] = []; + + if (undo.kind === 'created') { + // Walk-ins created here are cancelled, not deleted: the row stays as an + // audit trail and can be reactivated from the same screen. + ops.push(updateOp(tickets, { + status: 'cancelled', + checkinAt: null, + checkedInByAdminId: null, + }, eq((tickets as any).id, undo.ticketId))); + ops.push(updateOp(payments, { + status: 'cancelled', + paidAt: null, + updatedAt: now, + }, eq((payments as any).id, undo.paymentId))); + } else { + ops.push(updateOp(tickets, { + status: undo.prevTicket.status, + checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null, + checkedInByAdminId: undo.prevTicket.checkedInByAdminId, + paymentStatus: undo.prevTicket.paymentStatus, + isGuest: toDbBool(undo.prevTicket.isGuest), + }, eq((tickets as any).id, undo.ticketId))); + + if (undo.createdPaymentId) { + ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId))); + } else if (undo.prevPayment) { + const prev = undo.prevPayment; + ops.push(updateOp(payments, { + provider: prev.provider, + amount: prev.amount, + status: prev.status, + reference: prev.reference, + paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null, + paidByAdminId: prev.paidByAdminId, + source: prev.source, + method: prev.method, + updatedAt: now, + }, eq((payments as any).id, prev.id))); + } + } + + ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey))); + + await runOps(ops); + + return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind }); + } +); + +// ==================== GET /:eventId/door-summary ==================== +// End-of-night reconciliation: what was taken at the door, by tender, plus the +// pre-sale/door split the event dashboard shows. + +doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c) => { + const eventId = c.req.param('eventId'); + + const event = await loadEvent(eventId); + if (!event) return c.json({ error: 'Event not found' }, 404); + + // Door payments settled for this event, with the attendee attached so the + // session feed can show who each line belongs to. + const rows = await dbAll( + (db as any) + .select({ + paymentId: (payments as any).id, + ticketId: (tickets as any).id, + method: (payments as any).method, + amount: (payments as any).amount, + paidAt: (payments as any).paidAt, + firstName: (tickets as any).attendeeFirstName, + lastName: (tickets as any).attendeeLastName, + ticketStatus: (tickets as any).status, + }) + .from(payments) + .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) + .where(and( + eq((tickets as any).eventId, eventId), + eq((payments as any).source, 'door'), + eq((payments as any).status, 'paid') + )) + ); + + const byMethod: Record = {}; + for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 }; + + let doorTotal = 0; + for (const r of rows) { + const key = (r.method && byMethod[r.method]) ? r.method : 'cash'; + const amount = num(r.amount); + byMethod[key].count += 1; + byMethod[key].total += amount; + doorTotal += amount; + } + + // Pre-sale revenue keeps the dashboard's existing definition — settled tickets + // at event price — minus anything that was actually taken at the door. + const doorTicketIds = new Set(rows.map((r: any) => r.ticketId)); + const settled = await dbAll( + (db as any) + .select({ id: (tickets as any).id }) + .from(tickets) + .where(and( + eq((tickets as any).eventId, eventId), + eq((tickets as any).paymentStatus, 'paid'), + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` + )) + ); + const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length; + const presaleTotal = presaleCount * event.price; + + return c.json({ + eventId, + currency: event.currency, + price: event.price, + door: { + count: rows.length, + total: doorTotal, + byMethod, + lines: rows + .map((r: any) => ({ + paymentId: r.paymentId, + ticketId: r.ticketId, + name: `${r.firstName} ${r.lastName || ''}`.trim(), + method: r.method || 'cash', + amount: num(r.amount), + paidAt: iso(r.paidAt), + })) + .sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')), + }, + presale: { count: presaleCount, total: presaleTotal }, + total: presaleTotal + doorTotal, + }); +}); + +export default doorRouter; diff --git a/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts b/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts index 2f9ca9c..58c8be0 100644 --- a/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts +++ b/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts @@ -1,27 +1,33 @@ import { useState, useEffect } from 'react'; import toast from 'react-hot-toast'; -import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api'; +import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } from '@/lib/api'; /** * Loads the core data for the admin event detail page (event, tickets, active - * email templates) and exposes a reload function used after mutations. + * email templates, door takings) and exposes a reload function used after + * mutations. */ export function useEventDetailData(eventId: string) { const [loading, setLoading] = useState(true); const [event, setEvent] = useState(null); const [tickets, setTickets] = useState([]); const [templates, setTemplates] = useState([]); + const [doorSummary, setDoorSummary] = useState(null); const loadEventData = async () => { try { - const [eventRes, ticketsRes, templatesRes] = await Promise.all([ + const [eventRes, ticketsRes, templatesRes, doorRes] = await Promise.all([ eventsApi.getById(eventId), ticketsApi.getAll({ eventId }), emailsApi.getTemplates(), + // Door takings split pre-sale from cash/bitcoin/transfer taken on the + // night. It is supporting detail, so a failure here must not blank the page. + doorApi.summary(eventId).catch(() => null), ]); setEvent(eventRes.event); setTickets(ticketsRes.tickets); setTemplates(templatesRes.templates.filter(t => t.isActive)); + setDoorSummary(doorRes); } catch (error) { toast.error('Failed to load event data'); } finally { @@ -33,5 +39,5 @@ export function useEventDetailData(eventId: string) { loadEventData(); }, [eventId]); - return { loading, event, tickets, templates, loadEventData }; + return { loading, event, tickets, templates, doorSummary, loadEventData }; } diff --git a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx index a91a675..75056dd 100644 --- a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx +++ b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx @@ -1,4 +1,4 @@ -import { PaymentOptionsConfig } from '@/lib/api'; +import { PaymentOptionsConfig, DOOR_PAYMENT_METHODS, type DoorPaymentMethod, type DoorSummary } from '@/lib/api'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import clsx from 'clsx'; @@ -12,13 +12,85 @@ import { XCircleIcon, } from '@heroicons/react/24/outline'; import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides'; +import { formatCurrency } from '../_utils/format'; interface PaymentsTabProps { locale: string; payments: PaymentOverridesController; + /** Takings recorded on the door check-in screen; null while loading or unavailable. */ + doorSummary: DoorSummary | null; } -export function PaymentsTab({ locale, payments }: PaymentsTabProps) { +const DOOR_METHOD_LABELS: Record = { + cash: { en: 'Cash', es: 'Efectivo' }, + bitcoin: { en: 'Bitcoin', es: 'Bitcoin' }, + transfer: { en: 'Transfer', es: 'Transferencia' }, + guest: { en: 'Guests', es: 'Invitados' }, +}; + +/** + * End-of-night reconciliation for this event: what staff took on the door, split + * by tender, next to the pre-sale total. Guests are counted, not totalled — they + * are free and carry no revenue. + */ +function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary }) { + const es = locale === 'es'; + return ( + +
+
+
+
+ +
+
+

{es ? 'Ventas en Puerta' : 'Door Sales'}

+

+ {es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'} +

+
+
+

{formatCurrency(summary.door.total, summary.currency)}

+
+ +
+ {DOOR_PAYMENT_METHODS.map((method) => { + const entry = summary.door.byMethod[method]; + return ( +
+

+ {es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en} +

+

+ {method === 'guest' + ? `${entry.count}` + : formatCurrency(entry.total, summary.currency)} +

+ {method !== 'guest' && ( +

+ {entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')} +

+ )} +
+ ); + })} +
+ +
+ + {es ? 'Preventa' : 'Pre-sale'}: {formatCurrency(summary.presale.total, summary.currency)} + {' '}({summary.presale.count}) + + + {es ? 'Total' : 'Total'}: {formatCurrency(summary.total, summary.currency)} + +
+
+
+ ); +} + +export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) { const { loadingPayments, hasPaymentOverrides, @@ -39,6 +111,11 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) { ) : ( <> + {/* Door takings — reconciliation first, configuration below */} + {doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && ( + + )} + {/* Header */}
diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 61c07e9..60554db 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -66,7 +66,7 @@ export default function AdminEventDetailPage() { const eventId = params.id as string; const { locale } = useLanguage(); - const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId); + const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId); const [activeTab, setActiveTab] = useState('overview'); // Email state @@ -401,7 +401,14 @@ export default function AdminEventDetailPage() { const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest); const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length; const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length; - const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price; + // Door sales can be taken at a custom amount (someone paying for their whole + // group), so once the door summary is loaded it is the authority on the total: + // pre-sale tickets at face value plus whatever was actually taken on the night. + const presaleRevenue = doorSummary + ? doorSummary.presale.total + : (paidConfirmedCount + paidCheckedInCount) * event.price; + const doorRevenue = doorSummary?.door.total ?? 0; + const revenue = presaleRevenue + doorRevenue; const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [ { key: 'overview', label: 'Overview', icon: CalendarIcon }, @@ -507,7 +514,15 @@ export default function AdminEventDetailPage() { { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' }, { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' }, { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' }, - { label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' }, + { + label: 'Revenue', + value: formatCurrency(revenue, event.currency), + icon: CurrencyDollarIcon, + color: 'bg-gray-50 text-gray-600', + detail: doorSummary + ? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}` + : undefined, + }, ].map((stat) => (
@@ -515,7 +530,7 @@ export default function AdminEventDetailPage() {

{stat.value}

-

{stat.label}

+

{('detail' in stat && stat.detail) || stat.label}

))} @@ -547,7 +562,15 @@ export default function AdminEventDetailPage() { { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' }, { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' }, { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' }, - { label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' }, + { + label: 'Revenue', + value: formatCurrency(revenue, event.currency), + icon: CurrencyDollarIcon, + color: 'text-gray-600 bg-gray-50', + detail: doorSummary + ? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}` + : undefined, + }, ].map((stat) => (
@@ -555,7 +578,7 @@ export default function AdminEventDetailPage() {

{stat.value}

-

{stat.label}

+

{('detail' in stat && stat.detail) || stat.label}

))} @@ -705,7 +728,7 @@ export default function AdminEventDetailPage() { )} {activeTab === 'payments' && ( - + )}
diff --git a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx new file mode 100644 index 0000000..0381803 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx @@ -0,0 +1,159 @@ +'use client'; + +import clsx from 'clsx'; +import { + CheckCircleIcon, + ArrowUturnLeftIcon, + UserGroupIcon, +} from '@heroicons/react/24/outline'; +import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api'; +import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils'; +import { PaymentButtons } from './PaymentButtons'; + +function checkinTime(checkinAt: string | null): string { + if (!checkinAt) return ''; + return parseDate(checkinAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + timeZone: EVENT_TIMEZONE, + }); +} + +const METHOD_LABELS: Record = { + cash: 'cash', + bitcoin: 'bitcoin', + transfer: 'transfer', + guest: 'guest', +}; + +/** The second line of a row: everything staff needs to decide in one glance. */ +function statusLine(attendee: DoorAttendee, currency: string): string { + if (attendee.status === 'cancelled') return 'Cancelled'; + if (attendee.checkedIn) { + const time = checkinTime(attendee.checkinAt); + const how = attendee.doorMethod ? ` · paid ${METHOD_LABELS[attendee.doorMethod]}` : ''; + return time ? `Checked in ${time}${how}` : `Checked in${how}`; + } + + const parts: string[] = []; + if (attendee.paymentStatus === 'comp') parts.push('Guest'); + else if (attendee.paymentStatus === 'paid') parts.push('Paid'); + else parts.push(`Unpaid · ${formatCurrency(attendee.amountDue, currency)} due`); + + if (attendee.isGroupBooking) parts.push('group booking'); + if (attendee.status === 'pending') parts.push('pending'); + return parts.join(' · '); +} + +export function AttendeeRow({ + attendee, + currency, + price, + expanded, + flashing, + busy, + onTap, + onPay, +}: { + attendee: DoorAttendee; + currency: string; + price: number; + expanded: boolean; + flashing: boolean; + busy: boolean; + onTap: () => void; + onPay: (method: DoorPaymentMethod, amount: number) => void; +}) { + const isCancelled = attendee.status === 'cancelled'; + const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp'; + // A settled, not-yet-arrived attendee is the one-tap case: the whole row checks + // them in. Everyone else opens the tenders inline instead. + const isOneTap = !isCancelled && !attendee.checkedIn && settled; + + return ( +
+ + + {expanded && !attendee.checkedIn && ( +
+ {isCancelled && ( +

+ + Reactivate as a walk-in — pick how they are paying. +

+ )} + +
+ )} + + {expanded && attendee.checkedIn && ( +
+

+ Already checked in + {attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''} + {attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}. +

+
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx new file mode 100644 index 0000000..7c849a2 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useState } from 'react'; +import clsx from 'clsx'; +import { + BanknotesIcon, + BoltIcon, + BuildingLibraryIcon, + GiftIcon, + ChevronDownIcon, +} from '@heroicons/react/24/outline'; +import type { DoorPaymentMethod } from '@/lib/api'; +import { formatCurrency } from '@/lib/utils'; + +// The four 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. + +const TENDERS: { + method: DoorPaymentMethod; + label: string; + icon: typeof BanknotesIcon; + className: string; +}[] = [ + { 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: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' }, +]; + +const LONG_PRESS_MS = 450; + +export function PaymentButtons({ + price, + currency, + onPay, + disabled, +}: { + price: number; + currency: string; + onPay: (method: DoorPaymentMethod, amount: number) => 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(''); + const [pressTimer, setPressTimer] = useState | null>(null); + const [longPressed, setLongPressed] = useState(false); + + const openAmounts = (method: DoorPaymentMethod) => { + if (method === 'guest') return; + setAmountsFor(method); + setCustomOpen(false); + setCustomValue(''); + }; + + const startPress = (method: DoorPaymentMethod) => { + setLongPressed(false); + const timer = setTimeout(() => { + setLongPressed(true); + openAmounts(method); + }, LONG_PRESS_MS); + setPressTimer(timer); + }; + + const endPress = (method: DoorPaymentMethod) => { + if (pressTimer) clearTimeout(pressTimer); + setPressTimer(null); + // A long press already opened the multiples; don't also charge 1x on release. + if (longPressed) { + setLongPressed(false); + return; + } + if (disabled) return; + onPay(method, method === 'guest' ? 0 : price); + }; + + const cancelPress = () => { + if (pressTimer) clearTimeout(pressTimer); + setPressTimer(null); + setLongPressed(false); + }; + + if (amountsFor) { + const tender = TENDERS.find((t) => t.method === amountsFor)!; + return ( +
+
+

{tender.label} — how many?

+ +
+
+ {[1, 2, 3].map((qty) => ( + + ))} + +
+ {customOpen && ( +
+ setCustomValue(e.target.value)} + placeholder={`Amount in ${currency}`} + className="flex-1 min-h-[48px] px-4 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow" + /> + +
+ )} +
+ ); + } + + return ( +
+ {TENDERS.map((tender) => ( + + ))} +
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx b/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx new file mode 100644 index 0000000..de71eb3 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { QrCodeIcon, XMarkIcon, VideoCameraIcon } from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; + +// The camera is a fullscreen overlay opened from the search row, not a tab. It +// only exists while a scan is happening, so it never holds the camera (or the +// screen) while staff are typing a name. + +/** Release any camera stream html5-qrcode left attached to a