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>
692 lines
29 KiB
TypeScript
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');
|
|
});
|
|
});
|