Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price): null falls back to the ticket price, 0 is a free walk-in. It is set in the Create/Edit Event modal (EN/ES) and carried through create, update and duplicate, but it is internal: public event responses and the attendee-facing ticket/dashboard endpoints drop it, and only admin, organizer and staff callers receive it. The door screen no longer trusts the amount the client sends. It sends a quantity and the server prices the charge from the event record: walk-ins pay the walk-in price, existing tickets the ticket price. A typed amount is only honoured with amountOverride from admin/organizer and is written to audit_logs in the same transaction. The charged amount stays snapshotted on the payment row. POS is a new door tender for the physical card terminal. Staff pick POS, see the amount to key in, charge the card, then confirm with "Mark as paid"; it is then recorded like any other door payment (provider and method 'pos'). It can be switched off globally or per event via payment options, and shows up in the payments filters, bookings, revenue summaries and door takings. PosChargePanel is where an automatic terminal push would go later; nothing calls a terminal today. tickets.booking_source (online | walk_in | admin) records how a booking was made. The migration backfills walk-ins from the door screen's idempotency records; everything else stays online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
515707c9e4
commit
b51c1360e2
@@ -223,7 +223,7 @@ describe('door-checkin: existing ticket', () => {
|
||||
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 },
|
||||
payment: { method: 'transfer', quantity: 2 },
|
||||
idempotencyKey: 'key-unpaid-2-transfer',
|
||||
});
|
||||
expect(body.payment.amount).toBe(PRICE * 2);
|
||||
@@ -441,3 +441,227 @@ describe('door-summary', () => {
|
||||
expect(body.door.lines[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user