Skip to content

Commit e3292ad

Browse files
committed
feat: implement cancellation deadline for paid events
Refs: LINK-2469
1 parent a40cc12 commit e3292ad

13 files changed

Lines changed: 328 additions & 44 deletions

File tree

public/locales/en/signup.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
"eventStarted": "The event has already started and registration cannot be cancelled.",
135135
"hasPaymentCancellation": "The payment is being cancelled and cannot be modified.",
136136
"hasPaymentRefund": "The payment is being refunded and cannot be modified.",
137-
"insufficientPermissions": "You do not have the right to edit registration details."
137+
"insufficientPermissions": "You do not have the right to edit registration details.",
138+
"paidEventCancellationDeadlinePassed": "Registration for a paid event can be cancelled no later than {{days}} days before the event starts."
138139
}
139140
}

public/locales/fi/signup.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
"eventStarted": "Tapahtuman on jo alkanut eikä ilmoittautumista voi perua.",
134134
"hasPaymentCancellation": "Ilmoittautumisen maksua perutaan eikä sitä voi muokata.",
135135
"hasPaymentRefund": "Ilmoittautumisen maksua hyvitetään eikä sitä voi muokata.",
136-
"insufficientPermissions": "Sinulla ei ole oikeuksia muokata ilmoittautumisen tietoja."
136+
"insufficientPermissions": "Sinulla ei ole oikeuksia muokata ilmoittautumisen tietoja.",
137+
"paidEventCancellationDeadlinePassed": "Maksullisen tapahtuman ilmoittautumisen voi perua viimeistään {{days}} päivää ennen tapahtuman alkua."
137138
}
138139
}

public/locales/sv/signup.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
"eventStarted": "Evenemanget har redan börjat och registreringen kan inte avbrytas.",
134134
"hasPaymentCancellation": "Anmälningsavgiften annulleras och kan inte ändras.",
135135
"hasPaymentRefund": "Anmälningsavgiften återbetalas och kan inte ändras.",
136-
"insufficientPermissions": "Du har inte rätt att ändra dina registreringsuppgifter."
136+
"insufficientPermissions": "Du har inte rätt att ändra dina registreringsuppgifter.",
137+
"paidEventCancellationDeadlinePassed": "Anmälan till ett avgiftsbelagt evenemang kan avbokas senast {{days}} dagar före evenemangets start."
137138
}
138139
}

src/constants.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const MAIN_CONTENT_ID = 'maincontent';
2727

2828
export const DATE_FORMAT = 'd.M.yyyy';
2929
export const TIME_FORMAT = 'HH.mm';
30+
export const HELSINKI_TIME_ZONE = 'Europe/Helsinki';
3031

3132
export enum RESERVATION_NAMES {
3233
SIGNUP_RESERVATION = 'signup-reservation',
@@ -48,16 +49,20 @@ export enum SPLITTED_ROW_TYPE {
4849

4950
export const DEFAULT_SPLITTED_ROW_TYPE = SPLITTED_ROW_TYPE.MEDIUM_MEDIUM;
5051

51-
export type LoginMethod = "helsinki_tunnus" | "suomi_fi" | "helsinkiad";
52+
export type LoginMethod = 'helsinki_tunnus' | 'suomi_fi' | 'helsinkiad';
5253

5354
export const parseLoginMethods = (value: string): LoginMethod[] => {
54-
const validMethods: LoginMethod[] = ["helsinki_tunnus", "suomi_fi", "helsinkiad"];
55+
const validMethods: LoginMethod[] = [
56+
'helsinki_tunnus',
57+
'suomi_fi',
58+
'helsinkiad',
59+
];
5560

56-
return value.split(',').map(i => {
61+
return value.split(',').map((i) => {
5762
const trimmed = i.trim();
5863
if (!validMethods.includes(trimmed as LoginMethod)) {
5964
throw new Error(`Invalid login method: ${trimmed}`);
6065
}
6166
return trimmed as LoginMethod;
62-
})
63-
}
67+
});
68+
};

src/domain/event/__tests__/utils.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
getEventFields,
2828
getEventLocationText,
2929
isEventStarted,
30+
isPaidEventCancellationDeadlinePassed,
3031
} from '../utils';
3132

3233
afterEach(() => {
@@ -367,6 +368,64 @@ describe('isEventStarted', () => {
367368
);
368369
});
369370

371+
describe('isPaidEventCancellationDeadlinePassed', () => {
372+
it('should return false for free events', () => {
373+
const result = isPaidEventCancellationDeadlinePassed({
374+
event: fakeEvent({
375+
offers: fakeOffers(1, [{ is_free: true }]),
376+
start_time: '2026-02-02T12:00:00Z',
377+
}),
378+
now: new Date('2026-01-27T00:00:00Z'),
379+
});
380+
381+
expect(result).toBe(false);
382+
});
383+
384+
it('should allow cancellation until 23:59:59 on the cutoff day in Helsinki time', () => {
385+
const paidEvent = fakeEvent({
386+
offers: fakeOffers(1, [{ is_free: false }]),
387+
// Event starts on 2.2.2026 at 14:00 in Helsinki time (UTC+2 => 12:00Z)
388+
start_time: '2026-02-02T12:00:00Z',
389+
});
390+
391+
const allowed = isPaidEventCancellationDeadlinePassed({
392+
event: paidEvent,
393+
// 26.1.2026 23:59:59 in Helsinki time (UTC+2 => 21:59:59Z)
394+
now: new Date('2026-01-26T21:59:59Z'),
395+
});
396+
const blocked = isPaidEventCancellationDeadlinePassed({
397+
event: paidEvent,
398+
// 27.1.2026 00:00:00 in Helsinki time (UTC+2 => 22:00:00Z)
399+
now: new Date('2026-01-26T22:00:00Z'),
400+
});
401+
402+
expect(allowed).toBe(false);
403+
expect(blocked).toBe(true);
404+
});
405+
406+
it('should keep cutoff at Helsinki midnight across spring DST transition', () => {
407+
const paidEvent = fakeEvent({
408+
offers: fakeOffers(1, [{ is_free: false }]),
409+
// Event starts on 1.4.2026 at 14:00 in Helsinki time (UTC+3 => 11:00Z)
410+
start_time: '2026-04-01T11:00:00Z',
411+
});
412+
413+
const allowed = isPaidEventCancellationDeadlinePassed({
414+
event: paidEvent,
415+
// 25.3.2026 23:59:59 in Helsinki time (UTC+2 => 21:59:59Z)
416+
now: new Date('2026-03-25T21:59:59Z'),
417+
});
418+
const blocked = isPaidEventCancellationDeadlinePassed({
419+
event: paidEvent,
420+
// 26.3.2026 00:00:00 in Helsinki time (UTC+2 => 22:00:00Z)
421+
now: new Date('2026-03-25T22:00:00Z'),
422+
});
423+
424+
expect(allowed).toBe(false);
425+
expect(blocked).toBe(true);
426+
});
427+
});
428+
370429
describe('userPathBuilder function', () => {
371430
const testCases: [Partial<EventQueryVariables>, string][] = [
372431
[{}, '/event/123/?nocache=true'],

src/domain/event/utils.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { AxiosError } from 'axios';
2+
import isAfter from 'date-fns/isAfter';
23
import isPast from 'date-fns/isPast';
4+
import { formatInTimeZone, zonedTimeToUtc } from 'date-fns-tz';
35
import { DateArray, DateTime, EventAttributes, createEvents } from 'ics';
46
import { TFunction } from 'next-i18next';
57

68
import { AddNotificationFn } from '../../common/components/notificationsContext/NotificationsContext';
9+
import { HELSINKI_TIME_ZONE } from '../../constants';
710
import { ExtendedSession, Language } from '../../types';
811
import getLocalisedString from '../../utils/getLocalisedString';
912
import queryBuilder, { VariableToKeyItem } from '../../utils/queryBuilder';
@@ -13,6 +16,28 @@ import { getPlaceFields } from '../place/utils';
1316
import { EventStatus, PublicationStatus, SuperEventType } from './constants';
1417
import { Event, EventFields, EventQueryVariables } from './types';
1518

19+
export const PAID_EVENT_CANCELLATION_DEADLINE_DAYS = 7;
20+
21+
const getHelsinkiStartOfDayFromDateString = (dateString: string): Date =>
22+
zonedTimeToUtc(`${dateString}T00:00:00`, HELSINKI_TIME_ZONE);
23+
24+
const getHelsinkiStartOfDay = (date: Date): Date =>
25+
getHelsinkiStartOfDayFromDateString(
26+
formatInTimeZone(date, HELSINKI_TIME_ZONE, 'yyyy-MM-dd')
27+
);
28+
29+
// Use UTC date arithmetic on yyyy-mm-dd to avoid runtime TZ and DST drift.
30+
const subtractDaysFromDateString = (
31+
dateString: string,
32+
days: number
33+
): string => {
34+
const [year, month, day] = dateString.split('-').map(Number);
35+
const date = new Date(Date.UTC(year, month - 1, day));
36+
date.setUTCDate(date.getUTCDate() - days);
37+
38+
return date.toISOString().slice(0, 10);
39+
};
40+
1641
export const getEventFields = (event: Event, locale: Language): EventFields => {
1742
return {
1843
audienceMaxAge: event.audience_max_age || null,
@@ -193,3 +218,34 @@ export const isEventStarted = (event: Event): boolean => {
193218
const startTime = event.start_time ? new Date(event.start_time) : null;
194219
return startTime ? isPast(startTime) : false;
195220
};
221+
222+
const isPaidEvent = (event: Event): boolean =>
223+
event.offers.some((offer) => !!offer && offer.is_free === false);
224+
225+
export const isPaidEventCancellationDeadlinePassed = ({
226+
event,
227+
now = new Date(),
228+
}: {
229+
event: Event;
230+
now?: Date;
231+
}): boolean => {
232+
if (!event.start_time || !isPaidEvent(event)) {
233+
return false;
234+
}
235+
236+
const eventStartDateInHelsinki = formatInTimeZone(
237+
new Date(event.start_time),
238+
HELSINKI_TIME_ZONE,
239+
'yyyy-MM-dd'
240+
);
241+
const latestCancellationDateInHelsinki = subtractDaysFromDateString(
242+
eventStartDateInHelsinki,
243+
PAID_EVENT_CANCELLATION_DEADLINE_DAYS
244+
);
245+
const latestCancellationDay = getHelsinkiStartOfDayFromDateString(
246+
latestCancellationDateInHelsinki
247+
);
248+
249+
// Cancellation is allowed for the full cutoff day and disabled starting next day.
250+
return isAfter(getHelsinkiStartOfDay(now), latestCancellationDay);
251+
};

src/domain/signup/__tests__/EditSignupPage.test.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import mockRouter from 'next-router-mock';
1111
import React from 'react';
1212

1313
import { ExtendedSession } from '../../../types';
14+
import { fakeOffers } from '../../../utils/mockDataUtils';
1415
import { fakeAuthenticatedSession } from '../../../utils/mockSession';
1516
import {
1617
actWait,
@@ -64,7 +65,17 @@ test.skip('page is accessible', async () => {
6465

6566
const mockedRegistrationResponse = rest.get(
6667
`*/registration/${TEST_REGISTRATION_ID}/`,
67-
(req, res, ctx) => res(ctx.status(200), ctx.json(registration))
68+
(req, res, ctx) =>
69+
res(
70+
ctx.status(200),
71+
ctx.json({
72+
...registration,
73+
event: {
74+
...registration.event,
75+
offers: fakeOffers(1, [{ is_free: true }]),
76+
},
77+
})
78+
)
6879
);
6980
const mockedSignupResponse = rest.get(`*/signup/*`, (req, res, ctx) =>
7081
res(ctx.status(200), ctx.json(signup))

0 commit comments

Comments
 (0)