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' }; const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' }; const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' }; // Who the next request is from. Session auth itself is Better Auth's concern and // has its own integration suite; this mock keeps the *role* check real so the // tests can prove which endpoints door staff may reach. let currentUser: { id: string; name: string; role: string } = STAFF; vi.mock('../lib/auth.js', () => ({ requireAuth: (roles?: string[]) => async (c: any, next: any) => { if (roles && !roles.includes(currentUser.role)) { return c.json({ error: 'Forbidden' }, 403); } c.set('user', currentUser); await next(); }, getAuthUser: async () => currentUser, })); /** Run one request as a given role, always restoring the default afterwards. */ async function as(user: typeof STAFF, fn: () => Promise): Promise { const previous = currentUser; currentUser = user; try { return await fn(); } finally { currentUser = previous; } } // 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; /** Seed the pre-sale payment a paid ticket was bought with, at this amount. */ paidAmount?: number; }) { 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() ); if (row.paidAmount !== undefined) seedPayment(row.id, row.paidAmount, 'paid'); } function seedPayment(ticketId: string, amount: number, status: string, provider = 'bancard') { const now = new Date().toISOString(); sqlite .prepare( `INSERT INTO payments (id, ticket_id, provider, amount, currency, status, paid_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'PYG', ?, ?, ?, ?)` ) .run(`pay-${ticketId}`, ticketId, provider, amount, status, status === 'paid' ? now : null, now, now); } 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', paidAmount: PRICE }); 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 = ? AND source = 'door'").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', quantity: 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', paidAmount: PRICE }); 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 access', () => { it('is hidden from door staff — whole-event takings are not door information', async () => { const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`); expect(status).toBe(403); // The numbers must not leak in the body either: hiding the section in the UI // alone would still expose them to anyone reading the network response. expect(body).not.toHaveProperty('door'); expect(body).not.toHaveProperty('presale'); }); it('is available to admin and organizer', async () => { for (const role of [ADMIN, ORGANIZER]) { const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`)); expect(status, `${role.role} should see door takings`).toBe(200); } }); it('still lets door staff do their job — list, check in and undo', async () => { expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200); // Comp, so this ticket stays out of the revenue totals asserted below and // the two tests cannot drift into each other through the shared database. seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' }); const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, { ticketId: 'tkt-role', idempotencyKey: 'key-role-check', }); expect(checkin.status).toBe(201); const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-role-check', }); expect(undo.status).toBe(200); }); }); describe('door-summary', () => { it('totals door takings by tender and splits them from pre-sale', async () => { const { status, body } = await as(ADMIN, () => 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'); }); it('keeps pre-sale revenue at what was paid when the ticket price changes', async () => { sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(99000, EVENT_ID); try { const { body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`)); expect(body.presale).toEqual({ count: 2, total: PRICE * 2 }); expect(body.door.total).toBe(PRICE * 6); } finally { sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(PRICE, EVENT_ID); } }); }); // ==================== 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'); }); });