Files
Spanglish/backend/src/routes/door.integration.test.ts
T
MichilisandClaude Opus 5.5 b3584e6c4d Keep event revenue at what was paid when the ticket price changes.
The event header revenue and the door summary's pre-sale total were
computed as settled tickets × the current event price, so editing the
price rewrote revenue for tickets already sold. They now sum the paid
payment amounts; the header no longer falls back to count × price.
Admin analytics per-event revenue gets the same fix.

Bookings that haven't been paid yet owe the current price, so a price or
currency change now reprices open `pending` payments in the same
transaction as the event update. Paid/refunded history, pending_approval,
on_hold and Lightning invoices keep their amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:38:35 +00:00

692 lines
29 KiB
TypeScript

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<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
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');
});
});