Event finance - Finance tab on the event page: P&L summary with a revenue-to-result waterfall, costs and other income in one ledger, and the partner split with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close, Finalized) with what is left to do; closing the books goes through a checklist dialog that freezes the numbers. - Expense modal shows only the fields each calculation type needs, a live preview with the event's real counts, and a category suggested from the description. Date inputs follow the UI language. - Global Finance page: profit per event with outliers clipped and labelled, and a "Ready to close" list of past events whose books are still open. - Calculation service (integer PYG, basis points) with pinned regression scenarios; PYG formatting centralised in lib/money with locale-aware separators. Event team permissions - Per-event members with role presets and requireEventPermission; the header stat "Confirmed" is relabelled "Not checked in yet", which is what it counts. Public sales state - One sales state (online, door, sold out, ended, external, cancelled) for the event page, listings and JSON-LD, with the door price and tenders shown only while people can still pay at the door. Frontend unit tests run with vitest (npm test in frontend/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
4.5 KiB
TypeScript
97 lines
4.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { resolveSalesState, publicSalesFields, isOnlineSalesClosed } from './salesState.js';
|
|
|
|
const START = '2030-01-01T23:00:00.000Z'; // 20:00 in Asunción
|
|
const END = '2030-01-02T02:00:00.000Z';
|
|
const startMs = new Date(START).getTime();
|
|
const minutes = (n: number) => n * 60_000;
|
|
|
|
// Pre-sale closes 120 minutes before the start (the site default).
|
|
const event = {
|
|
status: 'published',
|
|
startDatetime: START,
|
|
endDatetime: END,
|
|
externalBookingEnabled: false,
|
|
price: 21000,
|
|
walkInPrice: 30000 as number | null,
|
|
};
|
|
const settings = { presaleClosureEnabled: true, presaleCloseMinutesBefore: 120 };
|
|
const beforeClose = startMs - minutes(121);
|
|
const afterClose = startMs - minutes(60);
|
|
|
|
describe('resolveSalesState', () => {
|
|
it('is online while pre-sale is open and seats are left', () => {
|
|
expect(resolveSalesState(event, settings, 10, beforeClose)).toBe('online');
|
|
});
|
|
|
|
it('is door after pre-sale closes, until the event ends', () => {
|
|
expect(resolveSalesState(event, settings, 10, afterClose)).toBe('door');
|
|
expect(resolveSalesState(event, settings, 10, startMs + minutes(30))).toBe('door');
|
|
expect(resolveSalesState(event, settings, 10, new Date(END).getTime() - 1)).toBe('door');
|
|
});
|
|
|
|
it('is sold_out with no seats left, online or at the door', () => {
|
|
expect(resolveSalesState(event, settings, 0, beforeClose)).toBe('sold_out');
|
|
expect(resolveSalesState(event, settings, 0, afterClose)).toBe('sold_out');
|
|
});
|
|
|
|
it('is ended once the end time passes, or the start time when there is no end', () => {
|
|
expect(resolveSalesState(event, settings, 10, new Date(END).getTime())).toBe('ended');
|
|
expect(resolveSalesState(event, settings, 0, new Date(END).getTime())).toBe('ended');
|
|
expect(resolveSalesState({ ...event, endDatetime: null }, settings, 10, startMs)).toBe('ended');
|
|
expect(resolveSalesState({ ...event, status: 'completed' }, settings, 10, beforeClose)).toBe('ended');
|
|
});
|
|
|
|
it('is external for external booking events, with no door state', () => {
|
|
const external = { ...event, externalBookingEnabled: true };
|
|
expect(resolveSalesState(external, settings, 10, beforeClose)).toBe('external');
|
|
expect(resolveSalesState(external, settings, 10, afterClose)).toBe('external');
|
|
expect(resolveSalesState({ ...event, externalBookingEnabled: 1 }, settings, 10, afterClose)).toBe('external');
|
|
});
|
|
|
|
it('keeps cancelled events cancelled', () => {
|
|
expect(resolveSalesState({ ...event, status: 'cancelled' }, settings, 10, afterClose)).toBe('cancelled');
|
|
});
|
|
|
|
it('closes online sales at the start when pre-sale closure is off', () => {
|
|
const noClosure = { ...event, presaleClosureEnabled: false };
|
|
expect(resolveSalesState(noClosure, settings, 10, startMs - 1)).toBe('online');
|
|
expect(resolveSalesState(noClosure, settings, 10, startMs)).toBe('door');
|
|
expect(isOnlineSalesClosed(noClosure, settings, startMs - 1)).toBe(false);
|
|
expect(isOnlineSalesClosed(noClosure, settings, startMs)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('publicSalesFields', () => {
|
|
it('door with walk_in_price set: doorPrice is the walk-in price', () => {
|
|
expect(publicSalesFields(event, settings, 48, afterClose)).toEqual({ salesState: 'door', doorPrice: 30000 });
|
|
});
|
|
|
|
it('door with walk_in_price null: doorPrice falls back to the ticket price', () => {
|
|
expect(publicSalesFields({ ...event, walkInPrice: null }, settings, 48, afterClose))
|
|
.toEqual({ salesState: 'door', doorPrice: 21000 });
|
|
// Postgres decimals arrive as strings
|
|
expect(publicSalesFields({ ...event, price: '21000.00', walkInPrice: null }, settings, 48, afterClose).doorPrice)
|
|
.toBe(21000);
|
|
});
|
|
|
|
it('door with a free walk-in keeps 0, not the ticket price', () => {
|
|
expect(publicSalesFields({ ...event, walkInPrice: 0 }, settings, 48, afterClose).doorPrice).toBe(0);
|
|
});
|
|
|
|
it('omits doorPrice in every other state', () => {
|
|
const cases: Array<[number, number, any]> = [
|
|
[10, beforeClose, event], // online
|
|
[0, afterClose, event], // sold_out
|
|
[10, new Date(END).getTime(), event], // ended
|
|
[10, afterClose, { ...event, externalBookingEnabled: true }], // external
|
|
[10, afterClose, { ...event, status: 'cancelled' }], // cancelled
|
|
];
|
|
for (const [spots, now, e] of cases) {
|
|
const fields = publicSalesFields(e, settings, spots, now);
|
|
expect(fields.salesState).not.toBe('door');
|
|
expect(fields).not.toHaveProperty('doorPrice');
|
|
}
|
|
});
|
|
});
|