Skip to content

Commit e69d95b

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

11 files changed

Lines changed: 263 additions & 25 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: 37 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,42 @@ 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+
370407
describe('userPathBuilder function', () => {
371408
const testCases: [Partial<EventQueryVariables>, string][] = [
372409
[{}, '/event/123/?nocache=true'],

src/domain/event/utils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { AxiosError } from 'axios';
2+
import isAfter from 'date-fns/isAfter';
23
import isPast from 'date-fns/isPast';
4+
import startOfDay from 'date-fns/startOfDay';
5+
import subDays from 'date-fns/subDays';
6+
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
37
import { DateArray, DateTime, EventAttributes, createEvents } from 'ics';
48
import { TFunction } from 'next-i18next';
59

@@ -13,6 +17,15 @@ import { getPlaceFields } from '../place/utils';
1317
import { EventStatus, PublicationStatus, SuperEventType } from './constants';
1418
import { Event, EventFields, EventQueryVariables } from './types';
1519

20+
export const PAID_EVENT_CANCELLATION_DEADLINE_DAYS = 7;
21+
const HELSINKI_TIME_ZONE = 'Europe/Helsinki';
22+
23+
const getHelsinkiStartOfDay = (date: Date): Date => {
24+
const dateInHelsinki = utcToZonedTime(date, HELSINKI_TIME_ZONE);
25+
const helsinkiStartOfDay = startOfDay(dateInHelsinki);
26+
return zonedTimeToUtc(helsinkiStartOfDay, HELSINKI_TIME_ZONE);
27+
};
28+
1629
export const getEventFields = (event: Event, locale: Language): EventFields => {
1730
return {
1831
audienceMaxAge: event.audience_max_age || null,
@@ -193,3 +206,27 @@ export const isEventStarted = (event: Event): boolean => {
193206
const startTime = event.start_time ? new Date(event.start_time) : null;
194207
return startTime ? isPast(startTime) : false;
195208
};
209+
210+
export const isPaidEvent = (event: Event): boolean =>
211+
event.offers.some((offer) => !!offer && offer.is_free === false);
212+
213+
export const isPaidEventCancellationDeadlinePassed = ({
214+
event,
215+
now = new Date(),
216+
}: {
217+
event: Event;
218+
now?: Date;
219+
}): boolean => {
220+
if (!event.start_time || !isPaidEvent(event)) {
221+
return false;
222+
}
223+
224+
const eventStartDay = getHelsinkiStartOfDay(new Date(event.start_time));
225+
const latestCancellationDay = subDays(
226+
eventStartDay,
227+
PAID_EVENT_CANCELLATION_DEADLINE_DAYS
228+
);
229+
230+
// Cancellation is allowed for the full cutoff day and disabled starting next day.
231+
return isAfter(getHelsinkiStartOfDay(now), latestCancellationDay);
232+
};

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))

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

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { advanceTo, clear } from 'jest-date-mock';
44

55
import {
66
fakeEvent,
7+
fakeOffers,
78
fakeSignup,
89
fakeSignupGroup,
910
fakeSignupPaymentCancellation,
@@ -166,7 +167,7 @@ describe('canEditSignup function', () => {
166167

167168
describe('canECancelSignup function', () => {
168169
const editableEvent = fakeEvent({ start_time: '2024-06-24' });
169-
const cases: [Signup, SignupGroup | undefined, Event, boolean][] = [
170+
const cases: [Signup, SignupGroup | undefined, Event, boolean, string][] = [
170171
[
171172
fakeSignup({
172173
has_contact_person_access: false,
@@ -175,6 +176,7 @@ describe('canECancelSignup function', () => {
175176
undefined,
176177
editableEvent,
177178
false,
179+
'2024-06-01',
178180
],
179181
[
180182
fakeSignup({
@@ -184,6 +186,7 @@ describe('canECancelSignup function', () => {
184186
undefined,
185187
editableEvent,
186188
true,
189+
'2024-06-01',
187190
],
188191
[
189192
fakeSignup({
@@ -193,8 +196,15 @@ describe('canECancelSignup function', () => {
193196
undefined,
194197
editableEvent,
195198
true,
199+
'2024-06-01',
200+
],
201+
[
202+
fakeSignup(editableSignupOverride),
203+
undefined,
204+
editableEvent,
205+
true,
206+
'2024-06-01',
196207
],
197-
[fakeSignup(editableSignupOverride), undefined, editableEvent, true],
198208
[
199209
fakeSignup({
200210
...editableSignupOverride,
@@ -203,6 +213,7 @@ describe('canECancelSignup function', () => {
203213
undefined,
204214
editableEvent,
205215
false,
216+
'2024-06-01',
206217
],
207218
[
208219
fakeSignup({
@@ -212,6 +223,7 @@ describe('canECancelSignup function', () => {
212223
undefined,
213224
editableEvent,
214225
false,
226+
'2024-06-01',
215227
],
216228
[
217229
fakeSignup(editableSignupOverride),
@@ -220,6 +232,7 @@ describe('canECancelSignup function', () => {
220232
}),
221233
editableEvent,
222234
false,
235+
'2024-06-01',
223236
],
224237
[
225238
fakeSignup(editableSignupOverride),
@@ -228,19 +241,41 @@ describe('canECancelSignup function', () => {
228241
}),
229242
editableEvent,
230243
false,
244+
'2024-06-01',
231245
],
232246
[
233247
fakeSignup(editableSignupOverride),
234248
undefined,
235249
fakeEvent({ start_time: '2024-01-01' }),
236250
false,
251+
'2024-06-01',
252+
],
253+
[
254+
fakeSignup(editableSignupOverride),
255+
undefined,
256+
fakeEvent({
257+
offers: fakeOffers(1, [{ is_free: false }]),
258+
start_time: '2026-02-01T10:00:00Z',
259+
}),
260+
true,
261+
'2026-01-25T21:59:00Z',
262+
],
263+
[
264+
fakeSignup(editableSignupOverride),
265+
undefined,
266+
fakeEvent({
267+
offers: fakeOffers(1, [{ is_free: false }]),
268+
start_time: '2026-02-01T10:00:00Z',
269+
}),
270+
false,
271+
'2026-01-25T22:00:00Z',
237272
],
238273
];
239274

240275
it.each(cases)(
241276
'should return true if signup can be cancelled',
242-
(signup, signupGroup, event, expectedResult) => {
243-
advanceTo('2024-06-01');
277+
(signup, signupGroup, event, expectedResult, now) => {
278+
advanceTo(now);
244279
expect(canCancelSignup({ event, signup, signupGroup })).toBe(
245280
expectedResult
246281
);
@@ -250,8 +285,14 @@ describe('canECancelSignup function', () => {
250285

251286
describe('canEditSignup function', () => {
252287
const editableEvent = fakeEvent({ start_time: '2024-06-24' });
253-
const cases: [Signup, SignupGroup | undefined, Event, string][] = [
254-
[fakeSignup(editableSignupOverride), undefined, editableEvent, ''],
288+
const cases: [Signup, SignupGroup | undefined, Event, string, string][] = [
289+
[
290+
fakeSignup(editableSignupOverride),
291+
undefined,
292+
editableEvent,
293+
'',
294+
'2024-06-01',
295+
],
255296
[
256297
fakeSignup({
257298
has_contact_person_access: false,
@@ -260,6 +301,7 @@ describe('canEditSignup function', () => {
260301
undefined,
261302
editableEvent,
262303
'Sinulla ei ole oikeuksia muokata ilmoittautumisen tietoja.',
304+
'2024-06-01',
263305
],
264306
[
265307
fakeSignup({
@@ -269,6 +311,7 @@ describe('canEditSignup function', () => {
269311
undefined,
270312
editableEvent,
271313
'Ilmoittautumisen maksua perutaan eikä sitä voi muokata.',
314+
'2024-06-01',
272315
],
273316
[
274317
fakeSignup({
@@ -278,6 +321,7 @@ describe('canEditSignup function', () => {
278321
undefined,
279322
editableEvent,
280323
'Ilmoittautumisen maksua hyvitetään eikä sitä voi muokata.',
324+
'2024-06-01',
281325
],
282326
[
283327
fakeSignup(editableSignupOverride),
@@ -286,6 +330,7 @@ describe('canEditSignup function', () => {
286330
}),
287331
editableEvent,
288332
'Ilmoittautumisen maksua perutaan eikä sitä voi muokata.',
333+
'2024-06-01',
289334
],
290335
[
291336
fakeSignup(editableSignupOverride),
@@ -294,25 +339,38 @@ describe('canEditSignup function', () => {
294339
}),
295340
editableEvent,
296341
'Ilmoittautumisen maksua hyvitetään eikä sitä voi muokata.',
342+
'2024-06-01',
297343
],
298344
[
299345
fakeSignup(editableSignupOverride),
300346
undefined,
301347
fakeEvent({ start_time: '2024-01-01' }),
302348
'Tapahtuman on jo alkanut eikä ilmoittautumista voi perua.',
349+
'2024-06-01',
350+
],
351+
[
352+
fakeSignup(editableSignupOverride),
353+
undefined,
354+
fakeEvent({
355+
offers: fakeOffers(1, [{ is_free: false }]),
356+
start_time: '2026-02-01T10:00:00Z',
357+
}),
358+
'Maksullisen tapahtuman ilmoittautumisen voi perua viimeistään 7 päivää ennen tapahtuman alkua.',
359+
'2026-01-25T22:00:00Z',
303360
],
304361
[
305362
fakeSignup(editableSignupOverride),
306363
undefined,
307364
fakeEvent({ start_time: null }),
308365
'',
366+
'2024-06-01',
309367
],
310368
];
311369

312370
it.each(cases)(
313371
'should return correct cancel signup warning',
314-
(signup, signupGroup, event, expectedResult) => {
315-
advanceTo('2024-06-01');
372+
(signup, signupGroup, event, expectedResult, now) => {
373+
advanceTo(now);
316374
expect(
317375
getCancelSignupWarning({
318376
event,

0 commit comments

Comments
 (0)