Skip to content

Commit 7051090

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

11 files changed

Lines changed: 308 additions & 32 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/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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
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

@@ -13,6 +15,26 @@ import { getPlaceFields } from '../place/utils';
1315
import { EventStatus, PublicationStatus, SuperEventType } from './constants';
1416
import { Event, EventFields, EventQueryVariables } from './types';
1517

18+
export const PAID_EVENT_CANCELLATION_DEADLINE_DAYS = 7;
19+
const HELSINKI_TIME_ZONE = 'Europe/Helsinki';
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 = (dateString: string, days: number): string => {
31+
const [year, month, day] = dateString.split('-').map(Number);
32+
const date = new Date(Date.UTC(year, month - 1, day));
33+
date.setUTCDate(date.getUTCDate() - days);
34+
35+
return date.toISOString().slice(0, 10);
36+
};
37+
1638
export const getEventFields = (event: Event, locale: Language): EventFields => {
1739
return {
1840
audienceMaxAge: event.audience_max_age || null,
@@ -193,3 +215,34 @@ export const isEventStarted = (event: Event): boolean => {
193215
const startTime = event.start_time ? new Date(event.start_time) : null;
194216
return startTime ? isPast(startTime) : false;
195217
};
218+
219+
export const isPaidEvent = (event: Event): boolean =>
220+
event.offers.some((offer) => !!offer && offer.is_free === false);
221+
222+
export const isPaidEventCancellationDeadlinePassed = ({
223+
event,
224+
now = new Date(),
225+
}: {
226+
event: Event;
227+
now?: Date;
228+
}): boolean => {
229+
if (!event.start_time || !isPaidEvent(event)) {
230+
return false;
231+
}
232+
233+
const eventStartDateInHelsinki = formatInTimeZone(
234+
new Date(event.start_time),
235+
HELSINKI_TIME_ZONE,
236+
'yyyy-MM-dd'
237+
);
238+
const latestCancellationDateInHelsinki = subtractDaysFromDateString(
239+
eventStartDateInHelsinki,
240+
PAID_EVENT_CANCELLATION_DEADLINE_DAYS
241+
);
242+
const latestCancellationDay = getHelsinkiStartOfDayFromDateString(
243+
latestCancellationDateInHelsinki
244+
);
245+
246+
// Cancellation is allowed for the full cutoff day and disabled starting next day.
247+
return isAfter(getHelsinkiStartOfDay(now), latestCancellationDay);
248+
};

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)