Add per-event finance, event team permissions and public door sales state.
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b51c1360e2
commit
e203fb6c74
@@ -0,0 +1,96 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user