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 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-22 06:09:55 +00:00
co-authored by Claude Opus 5
parent a0161a67d2
commit e296e80e48
22 changed files with 3419 additions and 913 deletions
+388
View File
@@ -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');
});
});