Files
Spanglish/backend/src/lib/salesState.test.ts
T
MichilisandClaude Opus 5.5 e203fb6c74 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>
2026-10-03 03:11:46 +00:00

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');
}
});
});