@stamhoofd/backend 2.83.5 → 2.84.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/index.ts +19 -4
  2. package/package.json +18 -14
  3. package/src/crons/amazon-ses.ts +26 -5
  4. package/src/crons/balance-emails.ts +18 -17
  5. package/src/email-recipient-loaders/registrations.ts +87 -0
  6. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +5 -2
  7. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +40 -40
  8. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +28 -22
  9. package/src/endpoints/global/events/PatchEventsEndpoint.ts +81 -49
  10. package/src/endpoints/global/files/UploadFile.ts +11 -16
  11. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +234 -0
  12. package/src/endpoints/global/groups/GetGroupsEndpoint.ts +117 -43
  13. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +1054 -0
  14. package/src/endpoints/global/members/GetMembersEndpoint.ts +163 -141
  15. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -6
  16. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +0 -16
  17. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +73 -0
  18. package/src/endpoints/global/registration/GetPaymentRegistrations.ts +1 -2
  19. package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +43 -0
  20. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +1016 -0
  21. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +234 -0
  22. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +5 -5
  23. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +474 -554
  24. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +191 -52
  25. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +107 -9
  26. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +89 -0
  27. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +9 -6
  28. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +88 -0
  29. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +0 -6
  30. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +10 -6
  31. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +10 -25
  32. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +0 -5
  33. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +0 -5
  34. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +4 -0
  35. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -0
  36. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +44 -19
  37. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +140 -25
  38. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +40 -10
  39. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +2 -2
  40. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +2 -2
  41. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +4 -1
  42. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +2 -2
  43. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -2
  44. package/src/excel-loaders/members.ts +233 -232
  45. package/src/excel-loaders/payments.ts +1 -1
  46. package/src/excel-loaders/receivable-balances.ts +1 -1
  47. package/src/excel-loaders/registrations.ts +153 -0
  48. package/src/helpers/AdminPermissionChecker.ts +65 -37
  49. package/src/helpers/AuthenticatedStructures.ts +43 -3
  50. package/src/helpers/Context.ts +29 -1
  51. package/src/helpers/GlobalHelper.ts +3 -1
  52. package/src/helpers/GroupedThrottledQueue.test.ts +219 -0
  53. package/src/helpers/GroupedThrottledQueue.ts +108 -0
  54. package/src/helpers/LimitedFilteredRequestHelper.ts +26 -1
  55. package/src/helpers/MemberCharger.ts +0 -5
  56. package/src/helpers/MembershipCharger.ts +3 -9
  57. package/src/helpers/OrganizationCharger.ts +0 -5
  58. package/src/helpers/ThrottledQueue.test.ts +194 -0
  59. package/src/helpers/ThrottledQueue.ts +145 -0
  60. package/src/helpers/XlsxTransformerColumnHelper.ts +44 -1
  61. package/src/middleware/ContextMiddleware.ts +1 -1
  62. package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +2 -1
  63. package/src/seeds/1735577912-update-cached-outstanding-balance-from-items.ts +2 -1
  64. package/src/services/BalanceItemPaymentService.ts +1 -33
  65. package/src/services/BalanceItemService.ts +167 -48
  66. package/src/services/FileSignService.ts +18 -13
  67. package/src/services/MemberRecordStore.ts +28 -19
  68. package/src/services/PaymentReallocationService.test.ts +25 -14
  69. package/src/services/PaymentReallocationService.ts +29 -10
  70. package/src/services/PaymentService.ts +4 -16
  71. package/src/services/PlatformMembershipService.ts +8 -4
  72. package/src/services/RegistrationService.ts +66 -2
  73. package/src/sql-filters/base-registration-filter-compilers.ts +43 -0
  74. package/src/sql-filters/groups.ts +67 -0
  75. package/src/sql-filters/members.ts +33 -58
  76. package/src/sql-filters/organization-registration-periods.ts +8 -0
  77. package/src/sql-filters/registration-periods.ts +8 -0
  78. package/src/sql-filters/registrations.ts +11 -22
  79. package/src/sql-sorters/groups.ts +24 -0
  80. package/src/sql-sorters/organization-registration-periods.ts +24 -0
  81. package/src/sql-sorters/registration-periods.ts +47 -0
  82. package/src/sql-sorters/registrations.ts +77 -0
  83. package/tests/actions/patchOrganizationMember.ts +27 -0
  84. package/tests/actions/patchPaymentStatus.ts +45 -0
  85. package/tests/actions/patchUserMember.ts +27 -0
  86. package/tests/assertions/assertBalances.ts +49 -0
  87. package/tests/e2e/api-rate-limits.test.ts +5 -5
  88. package/tests/e2e/bundle-discounts.test.ts +4060 -0
  89. package/tests/e2e/charge-members.test.ts +27 -24
  90. package/tests/e2e/documents.test.ts +398 -0
  91. package/tests/e2e/register.test.ts +292 -312
  92. package/tests/helpers/PayconiqMocker.ts +55 -0
  93. package/tests/init/index.ts +5 -0
  94. package/tests/init/initAdmin.ts +14 -0
  95. package/tests/init/initBundleDiscount.ts +47 -0
  96. package/tests/init/initPayconiq.ts +9 -0
  97. package/tests/init/initPlatformAdmin.ts +13 -0
  98. package/tests/init/initStripe.ts +21 -0
  99. package/tests/jest.setup.ts +29 -0
  100. package/src/seeds-temporary/1736266448-recall-balance-item-price-paid.ts +0 -70
@@ -0,0 +1,4060 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { BalanceItem, BalanceItemFactory, GroupFactory, MemberFactory, Organization, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
3
+ import { AppliedRegistrationDiscount, BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BooleanStatus, GroupPriceDiscount, GroupPriceDiscountType, IDRegisterCart, IDRegisterCheckout, IDRegisterItem, PaymentMethod, PermissionLevel, Permissions, ReduceablePrice } from '@stamhoofd/structures';
4
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
5
+ import { RegisterMembersEndpoint } from '../../src/endpoints/global/registration/RegisterMembersEndpoint';
6
+ import { assertBalances } from '../assertions/assertBalances';
7
+ import { testServer } from '../helpers/TestServer';
8
+ import { initBundleDiscount } from '../init/initBundleDiscount';
9
+ import { initStripe } from '../init/initStripe';
10
+ import { initAdmin } from '../init/initAdmin';
11
+ import { BalanceItemService } from '../../src/services/BalanceItemService';
12
+
13
+ const baseUrl = `/members/register`;
14
+
15
+ describe('E2E.Bundle Discounts', () => {
16
+ const endpoint = new RegisterMembersEndpoint();
17
+ let period: RegistrationPeriod;
18
+ const post = async (body: IDRegisterCheckout, organization: Organization, token: Token) => {
19
+ const request = Request.post({
20
+ path: baseUrl,
21
+ host: organization.getApiHost(),
22
+ body,
23
+ headers: {
24
+ authorization: 'Bearer ' + token.accessToken,
25
+ },
26
+ });
27
+ return await testServer.test(endpoint, request);
28
+ };
29
+
30
+ beforeAll(async () => {
31
+ const previousPeriod = await new RegistrationPeriodFactory({
32
+ startDate: new Date(2022, 0, 1),
33
+ endDate: new Date(2022, 11, 31),
34
+ }).create();
35
+
36
+ period = await new RegistrationPeriodFactory({
37
+ startDate: new Date(2023, 0, 1),
38
+ endDate: new Date(2030, 11, 31),
39
+ previousPeriodId: previousPeriod.id,
40
+ }).create();
41
+ });
42
+
43
+ beforeEach(async () => {
44
+ TestUtils.setEnvironment('userMode', 'platform');
45
+ });
46
+
47
+ afterEach(() => {
48
+ jest.restoreAllMocks();
49
+ });
50
+
51
+ async function initData() {
52
+ const organization = await new OrganizationFactory({ period })
53
+ .create();
54
+
55
+ const organizationRegistrationPeriod = await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
56
+
57
+ const user = await new UserFactory({
58
+ organization,
59
+ permissions: null,
60
+ }).create();
61
+
62
+ const token = await Token.createToken(user);
63
+
64
+ const member = await new MemberFactory({ organization, user })
65
+ .create();
66
+
67
+ return {
68
+ organization,
69
+ organizationRegistrationPeriod,
70
+ user,
71
+ token,
72
+ member,
73
+ };
74
+ }
75
+
76
+ describe('Registering as member', () => {
77
+ test('PointOfSale: The first registration has no discount applied', async () => {
78
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
79
+ const bundleDiscount = await initBundleDiscount({
80
+ organizationRegistrationPeriod,
81
+ discount: {
82
+ discounts: [
83
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
84
+ ],
85
+ },
86
+ });
87
+
88
+ const group = await new GroupFactory({
89
+ organization,
90
+ price: 25_00,
91
+ bundleDiscount,
92
+ })
93
+ .create();
94
+
95
+ const groupPrice = group.settings.prices[0];
96
+
97
+ // First register the member for group 1. No discount should be applied yet
98
+ const checkout1 = IDRegisterCheckout.create({
99
+ cart: IDRegisterCart.create({
100
+ items: [
101
+ IDRegisterItem.create({
102
+ groupPrice,
103
+ groupId: group.id,
104
+ organizationId: organization.id,
105
+ memberId: member.id,
106
+ }),
107
+ ],
108
+ }),
109
+ paymentMethod: PaymentMethod.PointOfSale,
110
+ totalPrice: 25_00,
111
+ });
112
+
113
+ const response1 = await post(checkout1, organization, token);
114
+ expect(response1.body.registrations).toEqual([
115
+ expect.objectContaining({
116
+ registeredAt: expect.any(Date),
117
+ discounts: new Map(),
118
+ }),
119
+ ]);
120
+
121
+ await assertBalances({ user }, [
122
+ {
123
+ type: BalanceItemType.Registration,
124
+ amount: 1,
125
+ price: 25_00,
126
+ status: BalanceItemStatus.Due,
127
+ priceOpen: 0,
128
+ pricePending: 25_00,
129
+ },
130
+ ]);
131
+ });
132
+
133
+ test('Unrelated registrations do not get counted for bundle discounts', async () => {
134
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
135
+ const bundleDiscount = await initBundleDiscount({
136
+ organizationRegistrationPeriod,
137
+ discount: {
138
+ discounts: [
139
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
140
+ ],
141
+ },
142
+ });
143
+
144
+ const group = await new GroupFactory({
145
+ organization,
146
+ price: 25_00,
147
+ }).create();
148
+
149
+ const groupPrice = group.settings.prices[0];
150
+
151
+ const group2 = await new GroupFactory({
152
+ organization,
153
+ price: 35_00,
154
+ bundleDiscount,
155
+ }).create();
156
+
157
+ const groupPrice2 = group2.settings.prices[0];
158
+
159
+ // First register the member for group 1. No discount should be applied yet
160
+ const checkout1 = IDRegisterCheckout.create({
161
+ cart: IDRegisterCart.create({
162
+ items: [
163
+ IDRegisterItem.create({
164
+ groupPrice,
165
+ groupId: group.id,
166
+ organizationId: organization.id,
167
+ memberId: member.id,
168
+ }),
169
+ ],
170
+ }),
171
+ paymentMethod: PaymentMethod.PointOfSale,
172
+ totalPrice: 25_00,
173
+ });
174
+
175
+ const response1 = await post(checkout1, organization, token);
176
+ expect(response1.body.registrations.length).toBe(1);
177
+ const registration1 = response1.body.registrations[0];
178
+ expect(registration1.registeredAt).not.toBeNull();
179
+ expect(registration1.discounts).toMatchMap(new Map());
180
+
181
+ const checkout2 = IDRegisterCheckout.create({
182
+ cart: IDRegisterCart.create({
183
+ items: [
184
+ IDRegisterItem.create({
185
+ groupPrice: groupPrice2,
186
+ groupId: group2.id,
187
+ organizationId: organization.id,
188
+ memberId: member.id,
189
+ }),
190
+ ],
191
+ }),
192
+ paymentMethod: PaymentMethod.PointOfSale,
193
+ totalPrice: 35_00,
194
+ });
195
+ const response2 = await post(checkout2, organization, token);
196
+ expect(response2.body.registrations.length).toBe(1);
197
+
198
+ const registration2 = response2.body.registrations[0];
199
+ expect(registration2.registeredAt).not.toBeNull();
200
+ expect(registration2.discounts).toMatchMap(new Map());
201
+
202
+ const updatedRegistration1 = (await Registration.getByID(registration1.id))!;
203
+ expect(updatedRegistration1).toBeDefined();
204
+ expect(updatedRegistration1.discounts).toMatchMap(new Map());
205
+
206
+ await assertBalances({ user }, [
207
+ {
208
+ type: BalanceItemType.Registration,
209
+ registrationId: registration1.id,
210
+ amount: 1,
211
+ price: 25_00,
212
+ status: BalanceItemStatus.Due,
213
+ priceOpen: 0,
214
+ pricePending: 25_00,
215
+ },
216
+ {
217
+ type: BalanceItemType.Registration,
218
+ registrationId: registration2.id,
219
+ amount: 1,
220
+ price: 35_00,
221
+ status: BalanceItemStatus.Due,
222
+ priceOpen: 0,
223
+ pricePending: 35_00,
224
+ },
225
+ ]);
226
+ });
227
+
228
+ test('PointOfSale: A bundle discount can be applied to a previous registration', async () => {
229
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
230
+ const bundleDiscount = await initBundleDiscount({
231
+ organizationRegistrationPeriod,
232
+ discount: {
233
+ discounts: [
234
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
235
+ ],
236
+ },
237
+ });
238
+
239
+ const group = await new GroupFactory({
240
+ organization,
241
+ price: 25_00,
242
+ bundleDiscount,
243
+ }).create();
244
+
245
+ const groupPrice = group.settings.prices[0];
246
+
247
+ const group2 = await new GroupFactory({
248
+ organization,
249
+ price: 15_00, // Lower price so discount is applied preferably on the first group
250
+ bundleDiscount,
251
+ }).create();
252
+
253
+ const groupPrice2 = group2.settings.prices[0];
254
+
255
+ // First register the member for group 1. No discount should be applied yet
256
+ const checkout1 = IDRegisterCheckout.create({
257
+ cart: IDRegisterCart.create({
258
+ items: [
259
+ IDRegisterItem.create({
260
+ groupPrice,
261
+ groupId: group.id,
262
+ organizationId: organization.id,
263
+ memberId: member.id,
264
+ }),
265
+ ],
266
+ }),
267
+ paymentMethod: PaymentMethod.PointOfSale,
268
+ totalPrice: groupPrice.price.price,
269
+ });
270
+
271
+ const response1 = await post(checkout1, organization, token);
272
+ expect(response1.body.registrations.length).toBe(1);
273
+ const registration1 = response1.body.registrations[0];
274
+ expect(registration1.registeredAt).not.toBeNull();
275
+ expect(registration1.discounts).toMatchMap(new Map());
276
+
277
+ const checkout2 = IDRegisterCheckout.create({
278
+ cart: IDRegisterCart.create({
279
+ items: [
280
+ IDRegisterItem.create({
281
+ groupPrice: groupPrice2,
282
+ groupId: group2.id,
283
+ organizationId: organization.id,
284
+ memberId: member.id,
285
+ }),
286
+ ],
287
+ }),
288
+ paymentMethod: PaymentMethod.PointOfSale,
289
+ totalPrice: 15_00 - 5_00, // 20% discount on first group
290
+ });
291
+ const response2 = await post(checkout2, organization, token);
292
+ expect(response2.body.registrations.length).toBe(1);
293
+
294
+ const registration2 = response2.body.registrations[0];
295
+ expect(registration2.registeredAt).not.toBeNull();
296
+ expect(registration2.discounts).toMatchMap(new Map());
297
+
298
+ // Get registration 1 again, it should now have the bundle discount applied
299
+ const updatedRegistration1 = (await Registration.getByID(registration1.id))!;
300
+ expect(updatedRegistration1).toBeDefined();
301
+ expect(updatedRegistration1.discounts).toMatchMap(new Map([
302
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
303
+ name: bundleDiscount.name,
304
+ amount: 5_00,
305
+ })],
306
+ ]));
307
+
308
+ await assertBalances({ user }, [
309
+ {
310
+ type: BalanceItemType.Registration,
311
+ registrationId: registration1.id,
312
+ amount: 1,
313
+ price: 25_00,
314
+ status: BalanceItemStatus.Due,
315
+ priceOpen: 0,
316
+ pricePending: 25_00,
317
+ },
318
+ {
319
+ type: BalanceItemType.RegistrationBundleDiscount,
320
+ registrationId: registration1.id,
321
+ amount: 1,
322
+ price: -5_00,
323
+ status: BalanceItemStatus.Due,
324
+ priceOpen: 0,
325
+ pricePending: -5_00,
326
+ },
327
+ {
328
+ type: BalanceItemType.Registration,
329
+ registrationId: registration2.id,
330
+ amount: 1,
331
+ price: 15_00,
332
+ status: BalanceItemStatus.Due,
333
+ priceOpen: 0,
334
+ pricePending: 15_00,
335
+ },
336
+ ]);
337
+ });
338
+
339
+ test('PointOfSale: A bundle discount can be applied to an added registration', async () => {
340
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
341
+ const bundleDiscount = await initBundleDiscount({
342
+ organizationRegistrationPeriod,
343
+ discount: {
344
+ discounts: [
345
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
346
+ ],
347
+ },
348
+ });
349
+
350
+ const group = await new GroupFactory({
351
+ organization,
352
+ price: 25_00,
353
+ bundleDiscount,
354
+ })
355
+ .create();
356
+
357
+ const groupPrice = group.settings.prices[0];
358
+
359
+ const group2 = await new GroupFactory({
360
+ organization,
361
+ price: 35_00, // Higher price so discount is applied preferably here
362
+ bundleDiscount,
363
+ }).create();
364
+
365
+ const groupPrice2 = group2.settings.prices[0];
366
+
367
+ // First register the member for group 1. No discount should be applied yet
368
+ const checkout1 = IDRegisterCheckout.create({
369
+ cart: IDRegisterCart.create({
370
+ items: [
371
+ IDRegisterItem.create({
372
+ groupPrice,
373
+ groupId: group.id,
374
+ organizationId: organization.id,
375
+ memberId: member.id,
376
+ }),
377
+ ],
378
+ }),
379
+ paymentMethod: PaymentMethod.PointOfSale,
380
+ totalPrice: 25_00,
381
+ });
382
+
383
+ const response1 = await post(checkout1, organization, token);
384
+ expect(response1.body.registrations.length).toBe(1);
385
+ const registration1 = response1.body.registrations[0];
386
+ expect(registration1.registeredAt).not.toBeNull();
387
+ expect(registration1.discounts).toMatchMap(new Map());
388
+
389
+ const checkout2 = IDRegisterCheckout.create({
390
+ cart: IDRegisterCart.create({
391
+ items: [
392
+ IDRegisterItem.create({
393
+ groupPrice: groupPrice2,
394
+ groupId: group2.id,
395
+ organizationId: organization.id,
396
+ memberId: member.id,
397
+ }),
398
+ ],
399
+ }),
400
+ paymentMethod: PaymentMethod.PointOfSale,
401
+ totalPrice: 35_00 - 7_00, // 20% discount on 35_00 = 7_00
402
+ });
403
+ const response2 = await post(checkout2, organization, token);
404
+ expect(response2.body.registrations.length).toBe(1);
405
+
406
+ const registration2 = response2.body.registrations[0];
407
+ expect(registration2.registeredAt).not.toBeNull();
408
+ expect(registration2.discounts).toMatchMap(new Map([
409
+ [
410
+ bundleDiscount.id,
411
+ AppliedRegistrationDiscount.create({
412
+ name: bundleDiscount.name,
413
+ amount: 7_00,
414
+ }),
415
+ ],
416
+ ]));
417
+
418
+ // Get registration 1 again, it should now have the bundle discount applied
419
+ const updatedRegistration1 = (await Registration.getByID(registration1.id))!;
420
+ expect(updatedRegistration1).toBeDefined();
421
+ expect(updatedRegistration1.discounts).toMatchMap(new Map());
422
+
423
+ await assertBalances({ user }, [
424
+ {
425
+ type: BalanceItemType.Registration,
426
+ registrationId: registration1.id,
427
+ amount: 1,
428
+ price: 25_00,
429
+ status: BalanceItemStatus.Due,
430
+ priceOpen: 0,
431
+ pricePending: 25_00,
432
+ },
433
+ {
434
+ type: BalanceItemType.RegistrationBundleDiscount,
435
+ registrationId: registration2.id,
436
+ amount: 1,
437
+ price: -7_00,
438
+ status: BalanceItemStatus.Due,
439
+ priceOpen: 0,
440
+ pricePending: -7_00,
441
+ },
442
+ {
443
+ type: BalanceItemType.Registration,
444
+ registrationId: registration2.id,
445
+ amount: 1,
446
+ price: 35_00,
447
+ status: BalanceItemStatus.Due,
448
+ priceOpen: 0,
449
+ pricePending: 35_00,
450
+ },
451
+ ]);
452
+ });
453
+
454
+ test('PointOfSale: A bundle discount can be applied to a new and previous registration at the same time', async () => {
455
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
456
+ const bundleDiscount = await initBundleDiscount({
457
+ organizationRegistrationPeriod,
458
+ discount: {
459
+ discounts: [
460
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
461
+ { value: 40_00, type: GroupPriceDiscountType.Percentage },
462
+ ],
463
+ },
464
+ });
465
+
466
+ const groups = [
467
+ await new GroupFactory({
468
+ organization,
469
+ price: 35_00,
470
+ bundleDiscount,
471
+ }).create(),
472
+ await new GroupFactory({
473
+ organization,
474
+ price: 25_00,
475
+ bundleDiscount,
476
+ }).create(),
477
+ await new GroupFactory({
478
+ organization,
479
+ price: 45_00,
480
+ bundleDiscount,
481
+ }).create(),
482
+ ];
483
+
484
+ // Create existing registration for group 1
485
+ const registration1 = await new RegistrationFactory({
486
+ organization,
487
+ member,
488
+ group: groups[0],
489
+ }).create();
490
+
491
+ // Create balance item for existing registration
492
+ await new BalanceItemFactory({
493
+ userId: user.id,
494
+ memberId: member.id,
495
+ organizationId: organization.id,
496
+ registrationId: registration1.id,
497
+ type: BalanceItemType.Registration,
498
+ amount: 1,
499
+ unitPrice: 35_00,
500
+ status: BalanceItemStatus.Due,
501
+ }).create();
502
+
503
+ // Now register the member for group 2 & 3 at the same time
504
+ const checkout = IDRegisterCheckout.create({
505
+ cart: IDRegisterCart.create({
506
+ items: [
507
+ IDRegisterItem.create({
508
+ options: [],
509
+ groupPrice: groups[1].settings.prices[0],
510
+ groupId: groups[1].id,
511
+ organizationId: organization.id,
512
+ memberId: member.id,
513
+ }),
514
+ IDRegisterItem.create({
515
+ options: [],
516
+ groupPrice: groups[2].settings.prices[0],
517
+ groupId: groups[2].id,
518
+ organizationId: organization.id,
519
+ memberId: member.id,
520
+ }),
521
+ ],
522
+ }),
523
+ administrationFee: 0,
524
+ freeContribution: 0,
525
+ paymentMethod: PaymentMethod.PointOfSale,
526
+ totalPrice: 25_00 + 45_00 - 7_00 - 18_00, // 20% off 35_00 = 7_00, 40% off 45_00 = 18_00
527
+ });
528
+ const response = await post(checkout, organization, token);
529
+ expect(response.body.registrations.length).toBe(2);
530
+ const registration2 = response.body.registrations.find(r => r.groupId === groups[1].id)!;
531
+ const registration3 = response.body.registrations.find(r => r.groupId === groups[2].id)!;
532
+
533
+ expect(registration2).toMatchObject({
534
+ registeredAt: expect.any(Date),
535
+ discounts: new Map(),
536
+ });
537
+
538
+ expect(registration3).toMatchObject({
539
+ registeredAt: expect.any(Date),
540
+ discounts: new Map([
541
+ [
542
+ bundleDiscount.id,
543
+ AppliedRegistrationDiscount.create({
544
+ name: bundleDiscount.name,
545
+ amount: 18_00,
546
+ }),
547
+ ],
548
+ ]),
549
+ });
550
+
551
+ await assertBalances({ user }, [
552
+ {
553
+ registrationId: registration1.id,
554
+ unitPrice: 35_00,
555
+ amount: 1,
556
+ type: BalanceItemType.Registration,
557
+ priceOpen: 35_00,
558
+ },
559
+ {
560
+ registrationId: registration2.id,
561
+ unitPrice: 25_00,
562
+ amount: 1,
563
+ type: BalanceItemType.Registration,
564
+ pricePending: 25_00,
565
+ },
566
+ {
567
+ registrationId: registration3.id,
568
+ unitPrice: 45_00,
569
+ amount: 1,
570
+ type: BalanceItemType.Registration,
571
+ pricePending: 45_00,
572
+ },
573
+ // Discounts
574
+ {
575
+ registrationId: registration1.id,
576
+ unitPrice: -7_00,
577
+ amount: 1,
578
+ type: BalanceItemType.RegistrationBundleDiscount,
579
+ pricePending: -7_00,
580
+ },
581
+ {
582
+ registrationId: registration3.id,
583
+ unitPrice: -18_00,
584
+ amount: 1,
585
+ type: BalanceItemType.RegistrationBundleDiscount,
586
+ pricePending: -18_00,
587
+ },
588
+ ]);
589
+ });
590
+
591
+ /**
592
+ * When we calculate the discounts, we calculate the discount based on the group price at the time of the registration.
593
+ * This also mainly applies to corrections that happen later
594
+ */
595
+ test('Discounts are calculated based on the price at time of registration', async () => {
596
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
597
+ const bundleDiscount = await initBundleDiscount({
598
+ organizationRegistrationPeriod,
599
+ discount: {
600
+ discounts: [
601
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
602
+ { value: 40_00, type: GroupPriceDiscountType.Percentage },
603
+ ],
604
+ },
605
+ });
606
+
607
+ const groups = [
608
+ await new GroupFactory({
609
+ organization,
610
+ price: 35_00,
611
+ bundleDiscount,
612
+ }).create(),
613
+ await new GroupFactory({
614
+ organization,
615
+ price: 25_00,
616
+ bundleDiscount,
617
+ }).create(),
618
+ ];
619
+
620
+ // Create existing registration for group 1
621
+ const registration1 = await new RegistrationFactory({
622
+ organization,
623
+ member,
624
+ group: groups[0],
625
+ groupPrice: groups[0].settings.prices[0].patch({
626
+ price: ReduceablePrice.create({
627
+ price: 30_00, // This has changed, the group normally 'costs' 35_00
628
+ }),
629
+ }),
630
+ }).create();
631
+
632
+ // Create balance item for existing registration
633
+ await new BalanceItemFactory({
634
+ userId: user.id,
635
+ memberId: member.id,
636
+ organizationId: organization.id,
637
+ registrationId: registration1.id,
638
+ type: BalanceItemType.Registration,
639
+ amount: 1,
640
+ unitPrice: 30_00, // This has changed, the group normally 'costs' 35_00
641
+ status: BalanceItemStatus.Due,
642
+ }).create();
643
+
644
+ // Now register the member for group 2 & 3 at the same time
645
+ const checkout = IDRegisterCheckout.create({
646
+ cart: IDRegisterCart.create({
647
+ items: [
648
+ IDRegisterItem.create({
649
+ options: [],
650
+ groupPrice: groups[1].settings.prices[0],
651
+ groupId: groups[1].id,
652
+ organizationId: organization.id,
653
+ memberId: member.id,
654
+ }),
655
+ ],
656
+ }),
657
+ administrationFee: 0,
658
+ freeContribution: 0,
659
+ paymentMethod: PaymentMethod.PointOfSale,
660
+ totalPrice: 25_00 - 6_00, // 20% of 30_00 = 6_00
661
+ });
662
+ const response = await post(checkout, organization, token);
663
+ expect(response.body.registrations.length).toBe(1);
664
+ const registration2 = response.body.registrations[0];
665
+
666
+ expect(registration2).toMatchObject({
667
+ registeredAt: expect.any(Date),
668
+ discounts: new Map(),
669
+ });
670
+
671
+ await assertBalances({ user }, [
672
+ {
673
+ registrationId: registration1.id,
674
+ unitPrice: 30_00,
675
+ amount: 1,
676
+ type: BalanceItemType.Registration,
677
+ priceOpen: 30_00,
678
+ },
679
+ {
680
+ registrationId: registration2.id,
681
+ unitPrice: 25_00,
682
+ amount: 1,
683
+ type: BalanceItemType.Registration,
684
+ pricePending: 25_00,
685
+ },
686
+ // Discounts
687
+ {
688
+ registrationId: registration1.id,
689
+ unitPrice: -6_00,
690
+ amount: 1,
691
+ type: BalanceItemType.RegistrationBundleDiscount,
692
+ pricePending: -6_00,
693
+ },
694
+ ]);
695
+ });
696
+
697
+ /**
698
+ * If you cancel a registration with 100% cancellation fee, we'll keep the balances items 'Due'.
699
+ * This tests that we don't substract the discounts on those registration on new registrations.
700
+ */
701
+ test('A previous discount on a cancelled registration that has a 100% cancellation fee is not taken into account', async () => {
702
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
703
+ const bundleDiscount = await initBundleDiscount({
704
+ organizationRegistrationPeriod,
705
+ discount: {
706
+ discounts: [
707
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
708
+ { value: 100_00, type: GroupPriceDiscountType.Percentage },
709
+ ],
710
+ },
711
+ });
712
+
713
+ const groups = [
714
+ await new GroupFactory({
715
+ organization,
716
+ price: 25_00,
717
+ bundleDiscount,
718
+ }).create(),
719
+
720
+ await new GroupFactory({
721
+ organization,
722
+ price: 15_00,
723
+ bundleDiscount,
724
+ }).create(),
725
+
726
+ await new GroupFactory({
727
+ organization,
728
+ price: 35_00,
729
+ bundleDiscount,
730
+ }).create(),
731
+ ];
732
+
733
+ const registrations = [
734
+ await new RegistrationFactory({
735
+ organization,
736
+ member,
737
+ group: groups[0],
738
+ deactivatedAt: new Date(), // had a discount in the past - but was cancelled
739
+ }).create(),
740
+ await new RegistrationFactory({
741
+ organization,
742
+ member,
743
+ group: groups[1],
744
+ }).create(),
745
+ ];
746
+
747
+ // Create initial balances
748
+ await new BalanceItemFactory({
749
+ userId: user.id,
750
+ memberId: member.id,
751
+ organizationId: organization.id,
752
+ registrationId: registrations[0].id,
753
+ type: BalanceItemType.Registration,
754
+ amount: 1,
755
+ unitPrice: 25_00,
756
+ status: BalanceItemStatus.Due, // Still due, because we had a 100% cancellation fee
757
+ }).create();
758
+
759
+ // Create applied discount
760
+ await new BalanceItemFactory({
761
+ userId: user.id,
762
+ memberId: member.id,
763
+ organizationId: organization.id,
764
+ registrationId: registrations[0].id,
765
+ type: BalanceItemType.RegistrationBundleDiscount,
766
+ amount: 1,
767
+ unitPrice: -5_00,
768
+ status: BalanceItemStatus.Due, // Still due, because we had a 100% cancellation fee
769
+ relations: new Map([
770
+ [
771
+ BalanceItemRelationType.Discount,
772
+ BalanceItemRelation.create({
773
+ // We need the ID of the discount saved here
774
+ id: bundleDiscount.id,
775
+ name: bundleDiscount.name,
776
+ }),
777
+ ],
778
+ ]),
779
+ }).create();
780
+
781
+ await new BalanceItemFactory({
782
+ userId: user.id,
783
+ memberId: member.id,
784
+ organizationId: organization.id,
785
+ registrationId: registrations[1].id,
786
+ type: BalanceItemType.Registration,
787
+ amount: 1,
788
+ unitPrice: 15_00,
789
+ status: BalanceItemStatus.Due,
790
+ }).create();
791
+
792
+ // Register for group 3
793
+ const checkout = IDRegisterCheckout.create({
794
+ cart: IDRegisterCart.create({
795
+ items: [
796
+ IDRegisterItem.create({
797
+ options: [],
798
+ groupPrice: groups[2].settings.prices[0],
799
+ groupId: groups[2].id,
800
+ organizationId: organization.id,
801
+ memberId: member.id,
802
+ }),
803
+ ],
804
+ }),
805
+ administrationFee: 0,
806
+ freeContribution: 0,
807
+ paymentMethod: PaymentMethod.PointOfSale,
808
+ totalPrice: 35_00 - 7_00, // 20% of 35 = 7_00
809
+ });
810
+ const response = await post(checkout, organization, token);
811
+ const registration3 = response.body.registrations[0];
812
+
813
+ // Check did not reuse id
814
+ expect(registration3.id).not.toEqual(registrations[0].id);
815
+
816
+ expect(registration3).toMatchObject({
817
+ registeredAt: expect.any(Date),
818
+ discounts: new Map([
819
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
820
+ name: bundleDiscount.name,
821
+ amount: 7_00,
822
+ })],
823
+ ]),
824
+ });
825
+
826
+ // Check registration 1 still has the discount applied
827
+ await registrations[0].refresh();
828
+ expect(registrations[0].discounts).toMatchMap(new Map([
829
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
830
+ name: bundleDiscount.name,
831
+ amount: 5_00,
832
+ })],
833
+ ]));
834
+
835
+ // Check balances as expected
836
+ await assertBalances({ user }, [
837
+ {
838
+ type: BalanceItemType.Registration,
839
+ registrationId: registrations[0].id,
840
+ amount: 1,
841
+ price: 25_00,
842
+ status: BalanceItemStatus.Due,
843
+ priceOpen: 25_00,
844
+ pricePending: 0,
845
+ },
846
+ {
847
+ type: BalanceItemType.RegistrationBundleDiscount,
848
+ registrationId: registrations[0].id,
849
+ amount: 1,
850
+ price: -5_00,
851
+ status: BalanceItemStatus.Due,
852
+ priceOpen: -5_00,
853
+ pricePending: 0,
854
+ },
855
+ {
856
+ type: BalanceItemType.Registration,
857
+ registrationId: registrations[1].id,
858
+ amount: 1,
859
+ price: 15_00,
860
+ status: BalanceItemStatus.Due,
861
+ priceOpen: 15_00,
862
+ pricePending: 0,
863
+ },
864
+ {
865
+ type: BalanceItemType.RegistrationBundleDiscount,
866
+ registrationId: registration3.id,
867
+ amount: 1,
868
+ price: -7_00,
869
+ status: BalanceItemStatus.Due,
870
+ priceOpen: 0,
871
+ pricePending: -7_00,
872
+ },
873
+ {
874
+ type: BalanceItemType.Registration,
875
+ registrationId: registration3.id,
876
+ amount: 1,
877
+ price: 35_00,
878
+ status: BalanceItemStatus.Due,
879
+ priceOpen: 0,
880
+ pricePending: 35_00,
881
+ },
882
+ ]);
883
+ });
884
+
885
+ test('A wrong discount on a previous registration is automatically corrected', async () => {
886
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
887
+ const bundleDiscount = await initBundleDiscount({
888
+ organizationRegistrationPeriod,
889
+ discount: {
890
+ discounts: [
891
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
892
+ { value: 100_00, type: GroupPriceDiscountType.Percentage },
893
+ ],
894
+ },
895
+ });
896
+
897
+ const groups = [
898
+ await new GroupFactory({
899
+ organization,
900
+ price: 25_00,
901
+ bundleDiscount,
902
+ }).create(),
903
+
904
+ await new GroupFactory({
905
+ organization,
906
+ price: 35_00,
907
+ bundleDiscount,
908
+ }).create(),
909
+ ];
910
+
911
+ // Due to a fictive bug, we have a registration that already has a discount applied
912
+ // The system should auto recover from this automatically by removing it on the next registration
913
+ const registrations = [
914
+ await new RegistrationFactory({
915
+ organization,
916
+ member,
917
+ group: groups[0],
918
+ }).create(),
919
+ ];
920
+
921
+ // Create initial balances
922
+ await new BalanceItemFactory({
923
+ userId: user.id,
924
+ memberId: member.id,
925
+ organizationId: organization.id,
926
+ registrationId: registrations[0].id,
927
+ type: BalanceItemType.Registration,
928
+ amount: 1,
929
+ unitPrice: 25_00,
930
+ status: BalanceItemStatus.Due,
931
+ }).create();
932
+
933
+ // Create applied discount
934
+ await new BalanceItemFactory({
935
+ userId: user.id,
936
+ memberId: member.id,
937
+ organizationId: organization.id,
938
+ registrationId: registrations[0].id,
939
+ type: BalanceItemType.RegistrationBundleDiscount,
940
+ amount: 1,
941
+ unitPrice: -15_00, // not correct, this is what we'll correct
942
+ status: BalanceItemStatus.Due,
943
+ relations: new Map([
944
+ [
945
+ BalanceItemRelationType.Discount,
946
+ BalanceItemRelation.create({
947
+ // We need the ID of the discount saved here
948
+ id: bundleDiscount.id,
949
+ name: bundleDiscount.name,
950
+ }),
951
+ ],
952
+ ]),
953
+ }).create();
954
+
955
+ // Register for group 2
956
+ const checkout = IDRegisterCheckout.create({
957
+ cart: IDRegisterCart.create({
958
+ items: [
959
+ IDRegisterItem.create({
960
+ options: [],
961
+ groupPrice: groups[1].settings.prices[0],
962
+ groupId: groups[1].id,
963
+ organizationId: organization.id,
964
+ memberId: member.id,
965
+ }),
966
+ ],
967
+ }),
968
+ administrationFee: 0,
969
+ freeContribution: 0,
970
+ paymentMethod: PaymentMethod.PointOfSale,
971
+ totalPrice: 35_00 - 7_00 + 15_00, // - normal discount + wrong discount
972
+ });
973
+ const response = await post(checkout, organization, token);
974
+ const registration2 = response.body.registrations[0];
975
+
976
+ expect(registration2).toMatchObject({
977
+ registeredAt: expect.any(Date),
978
+ discounts: new Map([
979
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
980
+ name: bundleDiscount.name,
981
+ amount: 7_00,
982
+ })],
983
+ ]),
984
+ });
985
+
986
+ // Check registration 1 has removed the wrong discount
987
+ await registrations[0].refresh();
988
+ expect(registrations[0].discounts).toMatchMap(new Map());
989
+
990
+ // Check balances as expected
991
+ await assertBalances({ user }, [
992
+ {
993
+ type: BalanceItemType.Registration,
994
+ registrationId: registrations[0].id,
995
+ amount: 1,
996
+ price: 25_00,
997
+ status: BalanceItemStatus.Due,
998
+ priceOpen: 25_00,
999
+ pricePending: 0,
1000
+ },
1001
+ {
1002
+ type: BalanceItemType.RegistrationBundleDiscount,
1003
+ registrationId: registrations[0].id,
1004
+ amount: 1,
1005
+ price: -15_00,
1006
+ status: BalanceItemStatus.Due,
1007
+ priceOpen: -15_00,
1008
+ pricePending: 0,
1009
+ },
1010
+ {
1011
+ type: BalanceItemType.RegistrationBundleDiscount,
1012
+ registrationId: registration2.id,
1013
+ amount: 1,
1014
+ price: -7_00,
1015
+ status: BalanceItemStatus.Due,
1016
+ priceOpen: 0,
1017
+ pricePending: -7_00,
1018
+ },
1019
+ {
1020
+ type: BalanceItemType.Registration,
1021
+ registrationId: registration2.id,
1022
+ amount: 1,
1023
+ price: 35_00,
1024
+ status: BalanceItemStatus.Due,
1025
+ priceOpen: 0,
1026
+ pricePending: 35_00,
1027
+ },
1028
+ // Corrected discount:
1029
+ {
1030
+ type: BalanceItemType.RegistrationBundleDiscount,
1031
+ registrationId: registrations[0].id,
1032
+ amount: 1,
1033
+ price: 15_00,
1034
+ status: BalanceItemStatus.Due,
1035
+ priceOpen: 0,
1036
+ pricePending: 15_00,
1037
+ },
1038
+ ]);
1039
+ });
1040
+
1041
+ test('New registrations do not autocorrect if they are not eligible for the discount', async () => {
1042
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
1043
+ const bundleDiscount = await initBundleDiscount({
1044
+ organizationRegistrationPeriod,
1045
+ discount: {
1046
+ discounts: [
1047
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1048
+ { value: 100_00, type: GroupPriceDiscountType.Percentage },
1049
+ ],
1050
+ },
1051
+ });
1052
+
1053
+ const groups = [
1054
+ await new GroupFactory({
1055
+ organization,
1056
+ price: 25_00,
1057
+ bundleDiscount,
1058
+ }).create(),
1059
+
1060
+ await new GroupFactory({
1061
+ organization,
1062
+ price: 35_00,
1063
+ }).create(),
1064
+ ];
1065
+
1066
+ // Due to a fictive bug, we have a registration that already has a discount applied
1067
+ // The system should auto recover from this automatically by removing it on the next registration
1068
+ const registrations = [
1069
+ await new RegistrationFactory({
1070
+ organization,
1071
+ member,
1072
+ group: groups[0],
1073
+ }).create(),
1074
+ ];
1075
+
1076
+ // Create initial balances
1077
+ await new BalanceItemFactory({
1078
+ userId: user.id,
1079
+ memberId: member.id,
1080
+ organizationId: organization.id,
1081
+ registrationId: registrations[0].id,
1082
+ type: BalanceItemType.Registration,
1083
+ amount: 1,
1084
+ unitPrice: 25_00,
1085
+ status: BalanceItemStatus.Due,
1086
+ }).create();
1087
+
1088
+ // Create applied discount
1089
+ await new BalanceItemFactory({
1090
+ userId: user.id,
1091
+ memberId: member.id,
1092
+ organizationId: organization.id,
1093
+ registrationId: registrations[0].id,
1094
+ type: BalanceItemType.RegistrationBundleDiscount,
1095
+ amount: 1,
1096
+ unitPrice: -15_00, // not correct, this is what we'll correct
1097
+ status: BalanceItemStatus.Due,
1098
+ relations: new Map([
1099
+ [
1100
+ BalanceItemRelationType.Discount,
1101
+ BalanceItemRelation.create({
1102
+ // We need the ID of the discount saved here
1103
+ id: bundleDiscount.id,
1104
+ name: bundleDiscount.name,
1105
+ }),
1106
+ ],
1107
+ ]),
1108
+ }).create();
1109
+
1110
+ // Register for group 2
1111
+ const checkout = IDRegisterCheckout.create({
1112
+ cart: IDRegisterCart.create({
1113
+ items: [
1114
+ IDRegisterItem.create({
1115
+ options: [],
1116
+ groupPrice: groups[1].settings.prices[0],
1117
+ groupId: groups[1].id,
1118
+ organizationId: organization.id,
1119
+ memberId: member.id,
1120
+ }),
1121
+ ],
1122
+ }),
1123
+ administrationFee: 0,
1124
+ freeContribution: 0,
1125
+ paymentMethod: PaymentMethod.PointOfSale,
1126
+ totalPrice: 35_00,
1127
+ });
1128
+ const response = await post(checkout, organization, token);
1129
+ const registration2 = response.body.registrations[0];
1130
+
1131
+ expect(registration2).toMatchObject({
1132
+ registeredAt: expect.any(Date),
1133
+ discounts: new Map([]),
1134
+ });
1135
+
1136
+ // Check registration 1 has NOT removed the wrong discount
1137
+ await registrations[0].refresh();
1138
+ expect(registrations[0].discounts).toMatchMap(new Map([
1139
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
1140
+ name: bundleDiscount.name,
1141
+ amount: 15_00,
1142
+ })],
1143
+ ]));
1144
+
1145
+ // Check balances as expected
1146
+ await assertBalances({ user }, [
1147
+ {
1148
+ type: BalanceItemType.Registration,
1149
+ registrationId: registrations[0].id,
1150
+ amount: 1,
1151
+ price: 25_00,
1152
+ status: BalanceItemStatus.Due,
1153
+ priceOpen: 25_00,
1154
+ pricePending: 0,
1155
+ },
1156
+ {
1157
+ type: BalanceItemType.RegistrationBundleDiscount,
1158
+ registrationId: registrations[0].id,
1159
+ amount: 1,
1160
+ price: -15_00,
1161
+ status: BalanceItemStatus.Due,
1162
+ priceOpen: -15_00,
1163
+ pricePending: 0,
1164
+ },
1165
+ {
1166
+ type: BalanceItemType.Registration,
1167
+ registrationId: registration2.id,
1168
+ amount: 1,
1169
+ price: 35_00,
1170
+ status: BalanceItemStatus.Due,
1171
+ priceOpen: 0,
1172
+ pricePending: 35_00,
1173
+ },
1174
+ ]);
1175
+ });
1176
+
1177
+ test('PointOfSale: A bundle discount is applied when registering for two groups at once', async () => {
1178
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
1179
+ const bundleDiscount = await initBundleDiscount({
1180
+ organizationRegistrationPeriod,
1181
+ discount: {
1182
+ discounts: [
1183
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1184
+ ],
1185
+ },
1186
+ });
1187
+
1188
+ const groups = [
1189
+ await new GroupFactory({
1190
+ organization,
1191
+ price: 25_00,
1192
+ bundleDiscount,
1193
+ }).create(),
1194
+ await new GroupFactory({
1195
+ organization,
1196
+ price: 15_00,
1197
+ bundleDiscount,
1198
+ }).create(),
1199
+ ];
1200
+
1201
+ // First register the member for group 1 & 2
1202
+ const checkout = IDRegisterCheckout.create({
1203
+ cart: IDRegisterCart.create({
1204
+ items: [
1205
+ IDRegisterItem.create({
1206
+ options: [],
1207
+ groupPrice: groups[0].settings.prices[0],
1208
+ groupId: groups[0].id,
1209
+ organizationId: organization.id,
1210
+ memberId: member.id,
1211
+ }),
1212
+ IDRegisterItem.create({
1213
+ options: [],
1214
+ groupPrice: groups[1].settings.prices[0],
1215
+ groupId: groups[1].id,
1216
+ organizationId: organization.id,
1217
+ memberId: member.id,
1218
+ }),
1219
+ ],
1220
+ }),
1221
+ administrationFee: 0,
1222
+ freeContribution: 0,
1223
+ paymentMethod: PaymentMethod.PointOfSale,
1224
+ totalPrice: 25_00 + 15_00 - 5_00, // 20% discount on highest price (25_00 * 0.2 = 5_00)
1225
+ });
1226
+
1227
+ const response1 = await post(checkout, organization, token);
1228
+ expect(response1.body.registrations.length).toBe(2);
1229
+
1230
+ expect(response1.body.registrations).toIncludeAllMembers([
1231
+ expect.objectContaining({
1232
+ registeredAt: expect.any(Date),
1233
+ groupId: groups[0].id,
1234
+ discounts: new Map([
1235
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
1236
+ name: bundleDiscount.name,
1237
+ amount: 5_00,
1238
+ })],
1239
+ ]),
1240
+ }),
1241
+ expect.objectContaining({
1242
+ registeredAt: expect.any(Date),
1243
+ groupId: groups[1].id,
1244
+ discounts: new Map(),
1245
+ }),
1246
+ ]);
1247
+
1248
+ await assertBalances({ user }, [
1249
+ {
1250
+ type: BalanceItemType.Registration,
1251
+ registrationId: expect.any(String),
1252
+ amount: 1,
1253
+ price: 25_00,
1254
+ status: BalanceItemStatus.Due,
1255
+ priceOpen: 0,
1256
+ pricePending: 25_00,
1257
+ },
1258
+ {
1259
+ type: BalanceItemType.RegistrationBundleDiscount,
1260
+ registrationId: expect.any(String),
1261
+ amount: 1,
1262
+ price: -5_00,
1263
+ status: BalanceItemStatus.Due,
1264
+ priceOpen: 0,
1265
+ pricePending: -5_00,
1266
+ },
1267
+ {
1268
+ type: BalanceItemType.Registration,
1269
+ registrationId: expect.any(String),
1270
+ amount: 1,
1271
+ price: 15_00,
1272
+ status: BalanceItemStatus.Due,
1273
+ priceOpen: 0,
1274
+ pricePending: 15_00,
1275
+ },
1276
+ ]);
1277
+ });
1278
+
1279
+ test('Apply a discount on a previous registration with online payment (2 tries)', async () => {
1280
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
1281
+ const { stripeMocker } = await initStripe({ organization });
1282
+
1283
+ const bundleDiscount = await initBundleDiscount({
1284
+ organizationRegistrationPeriod,
1285
+ discount: {
1286
+ discounts: [
1287
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1288
+ ],
1289
+ },
1290
+ });
1291
+
1292
+ const group = await new GroupFactory({
1293
+ organization,
1294
+ price: 25_00,
1295
+ stock: 500,
1296
+ bundleDiscount,
1297
+ })
1298
+ .create();
1299
+ const groupPrice = group.settings.prices[0];
1300
+
1301
+ const group2 = await new GroupFactory({
1302
+ organization,
1303
+ price: 15_00, // Lower price so discount is applied preferably on the first group
1304
+ bundleDiscount,
1305
+ }).create();
1306
+
1307
+ const groupPrice2 = group2.settings.prices[0];
1308
+
1309
+ // First register the member for group 1. No discount should be applied yet
1310
+ const checkout1 = IDRegisterCheckout.create({
1311
+ cart: IDRegisterCart.create({
1312
+ items: [
1313
+ IDRegisterItem.create({
1314
+ options: [],
1315
+ groupPrice,
1316
+ groupId: group.id,
1317
+ organizationId: organization.id,
1318
+ memberId: member.id,
1319
+ }),
1320
+ ],
1321
+ }),
1322
+ administrationFee: 0,
1323
+ freeContribution: 0,
1324
+ paymentMethod: PaymentMethod.Bancontact,
1325
+ totalPrice: groupPrice.price.price,
1326
+ redirectUrl: new URL('https://www.example.com/success'),
1327
+ cancelUrl: new URL('https://www.example.com/cancel'),
1328
+ });
1329
+
1330
+ const response1 = await post(checkout1, organization, token);
1331
+ expect(response1.body.registrations.length).toBe(1);
1332
+ const registration1 = response1.body.registrations[0];
1333
+ expect(registration1.registeredAt).toBeNull();
1334
+ expect(registration1.discounts).toMatchMap(new Map());
1335
+
1336
+ // Check state of balances
1337
+ await assertBalances({ user }, [
1338
+ {
1339
+ type: BalanceItemType.Registration,
1340
+ registrationId: registration1.id,
1341
+ amount: 1,
1342
+ unitPrice: 25_00,
1343
+ status: BalanceItemStatus.Hidden,
1344
+ priceOpen: -25_00, // hidden, so no payment expected yet
1345
+ pricePending: 25_00,
1346
+ pricePaid: 0,
1347
+ },
1348
+ ]);
1349
+
1350
+ await stripeMocker.succeedPayment(stripeMocker.getLastIntent());
1351
+
1352
+ // Check registration became valid
1353
+ const updatedRegistration1 = (await Registration.getByID(registration1.id))!;
1354
+ expect(updatedRegistration1).toBeDefined();
1355
+ expect(updatedRegistration1.registeredAt).not.toBeNull();
1356
+
1357
+ // Check state of balances
1358
+ const expectedBalances: Partial<BalanceItem>[] = [
1359
+ {
1360
+ type: BalanceItemType.Registration,
1361
+ registrationId: registration1.id,
1362
+ amount: 1,
1363
+ unitPrice: 25_00,
1364
+ status: BalanceItemStatus.Due,
1365
+ priceOpen: 0,
1366
+ pricePaid: 25_00,
1367
+ pricePending: 0,
1368
+ paidAt: expect.any(Date),
1369
+ },
1370
+ ];
1371
+
1372
+ await assertBalances({ user }, expectedBalances);
1373
+
1374
+ const checkout2 = IDRegisterCheckout.create({
1375
+ cart: IDRegisterCart.create({
1376
+ items: [
1377
+ IDRegisterItem.create({
1378
+ options: [],
1379
+ groupPrice: groupPrice2,
1380
+ groupId: group2.id,
1381
+ organizationId: organization.id,
1382
+ memberId: member.id,
1383
+ }),
1384
+ ],
1385
+ }),
1386
+ administrationFee: 0,
1387
+ freeContribution: 0,
1388
+ paymentMethod: PaymentMethod.Bancontact,
1389
+ totalPrice: 15_00 - 5_00, // 20% discount on first group
1390
+ redirectUrl: new URL('https://www.example.com/success'),
1391
+ cancelUrl: new URL('https://www.example.com/cancel'),
1392
+ });
1393
+ const response2 = await post(checkout2, organization, token);
1394
+ expect(response2.body.registrations.length).toBe(1);
1395
+
1396
+ const registration2 = response2.body.registrations[0];
1397
+ expect(registration2.registeredAt).toBeNull(); // not yet valid
1398
+ expect(registration2.discounts).toMatchMap(new Map());
1399
+
1400
+ // Get registration 1 again, it should not yet have any bundle discount applied
1401
+ await updatedRegistration1.refresh();
1402
+ expect(updatedRegistration1.discounts).toMatchMap(new Map());
1403
+
1404
+ // Check state of balances
1405
+ await assertBalances({ user }, [
1406
+ ...expectedBalances,
1407
+ {
1408
+ type: BalanceItemType.Registration,
1409
+ registrationId: registration2.id,
1410
+ amount: 1,
1411
+ unitPrice: 15_00,
1412
+ status: BalanceItemStatus.Hidden, // Pending
1413
+ priceOpen: -15_00, // hidden, so no payment expected yet
1414
+ pricePending: 15_00,
1415
+ pricePaid: 0,
1416
+ },
1417
+ {
1418
+ type: BalanceItemType.RegistrationBundleDiscount,
1419
+ registrationId: registration1.id,
1420
+ amount: 1,
1421
+ unitPrice: -5_00,
1422
+ status: BalanceItemStatus.Hidden, // Pending
1423
+ priceOpen: 5_00,
1424
+ pricePending: -5_00,
1425
+ pricePaid: 0,
1426
+ },
1427
+ ]);
1428
+
1429
+ // Fail the payment...
1430
+ await stripeMocker.failPayment(stripeMocker.getLastIntent());
1431
+
1432
+ // Only price pending and open has changed now
1433
+ expectedBalances.push(
1434
+ {
1435
+ type: BalanceItemType.Registration,
1436
+ registrationId: registration2.id,
1437
+ amount: 1,
1438
+ unitPrice: 15_00,
1439
+ status: BalanceItemStatus.Hidden,
1440
+ priceOpen: 0,
1441
+ pricePending: 0,
1442
+ pricePaid: 0,
1443
+ paidAt: null,
1444
+ },
1445
+ {
1446
+ type: BalanceItemType.RegistrationBundleDiscount,
1447
+ registrationId: registration1.id,
1448
+ amount: 1,
1449
+ unitPrice: -5_00,
1450
+ status: BalanceItemStatus.Hidden,
1451
+ priceOpen: 0,
1452
+ pricePending: 0,
1453
+ pricePaid: 0,
1454
+ paidAt: null,
1455
+ },
1456
+ );
1457
+
1458
+ await assertBalances({ user }, expectedBalances);
1459
+
1460
+ // Try the payment again
1461
+ const response3 = await post(checkout2, organization, token);
1462
+ expect(response3.body.registrations.length).toBe(1);
1463
+
1464
+ const registration3 = response3.body.registrations[0];
1465
+ expect(registration3.id).not.toEqual(registration2.id);
1466
+ expect(registration3.registeredAt).toBeNull(); // not yet valid
1467
+ expect(registration3.discounts).toMatchMap(new Map());
1468
+
1469
+ // Get registration 1 again, it should not yet have any bundle discount applied
1470
+ await updatedRegistration1.refresh();
1471
+ expect(updatedRegistration1.discounts).toMatchMap(new Map());
1472
+
1473
+ await assertBalances({ user }, [
1474
+ ...expectedBalances,
1475
+ {
1476
+ type: BalanceItemType.Registration,
1477
+ registrationId: registration3.id,
1478
+ amount: 1,
1479
+ unitPrice: 15_00,
1480
+ status: BalanceItemStatus.Hidden, // Pending
1481
+ priceOpen: -15_00, // hidden, so no payment expected yet
1482
+ pricePending: 15_00,
1483
+ pricePaid: 0,
1484
+ paidAt: null,
1485
+ },
1486
+ {
1487
+ type: BalanceItemType.RegistrationBundleDiscount,
1488
+ registrationId: registration1.id,
1489
+ amount: 1,
1490
+ unitPrice: -5_00,
1491
+ status: BalanceItemStatus.Hidden, // Pending
1492
+ priceOpen: 5_00,
1493
+ pricePending: -5_00,
1494
+ pricePaid: 0,
1495
+ paidAt: null,
1496
+ },
1497
+ ]);
1498
+
1499
+ // Success the payment
1500
+ await stripeMocker.succeedPayment(stripeMocker.getLastIntent());
1501
+ await BalanceItemService.flushCaches(organization.id);
1502
+
1503
+ // Check registration 3 became valid
1504
+ const updatedRegistration3 = (await Registration.getByID(registration3.id))!;
1505
+ expect(updatedRegistration3).toBeDefined();
1506
+ expect(updatedRegistration3.registeredAt).not.toBeNull();
1507
+
1508
+ // Check registration 1 now has the discounts saved
1509
+ await updatedRegistration1.refresh();
1510
+ expect(updatedRegistration1.discounts).toMatchMap(new Map([
1511
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
1512
+ name: bundleDiscount.name,
1513
+ amount: 5_00,
1514
+ })],
1515
+ ]));
1516
+
1517
+ // Only price pending and open has changed now
1518
+ expectedBalances.push(
1519
+ {
1520
+ type: BalanceItemType.Registration,
1521
+ registrationId: registration3.id,
1522
+ amount: 1,
1523
+ unitPrice: 15_00,
1524
+ status: BalanceItemStatus.Due,
1525
+ priceOpen: 0,
1526
+ pricePending: 0,
1527
+ pricePaid: 15_00,
1528
+ paidAt: expect.any(Date),
1529
+ },
1530
+ {
1531
+ type: BalanceItemType.RegistrationBundleDiscount,
1532
+ registrationId: registration1.id,
1533
+ amount: 1,
1534
+ unitPrice: -5_00,
1535
+ status: BalanceItemStatus.Due,
1536
+ priceOpen: 0,
1537
+ pricePending: 0,
1538
+ pricePaid: -5_00,
1539
+ paidAt: expect.any(Date),
1540
+ },
1541
+ );
1542
+
1543
+ await assertBalances({ user }, expectedBalances);
1544
+ });
1545
+
1546
+ test('Multiple discounts can be applied to a single registration', async () => {
1547
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
1548
+
1549
+ const bundleDiscounts = [
1550
+ await initBundleDiscount({
1551
+ organizationRegistrationPeriod,
1552
+ discount: {
1553
+ discounts: [
1554
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1555
+ { value: 40_00, type: GroupPriceDiscountType.Percentage },
1556
+ ],
1557
+ },
1558
+ }),
1559
+ await initBundleDiscount({
1560
+ organizationRegistrationPeriod,
1561
+ discount: {
1562
+ discounts: [
1563
+ { value: 50, type: GroupPriceDiscountType.Fixed },
1564
+ { value: 5_00, type: GroupPriceDiscountType.Fixed },
1565
+ ],
1566
+ },
1567
+ }),
1568
+ ];
1569
+
1570
+ const groups = [
1571
+ await new GroupFactory({
1572
+ organization,
1573
+ price: 35_00,
1574
+ bundleDiscounts,
1575
+ }).create(),
1576
+ await new GroupFactory({
1577
+ organization,
1578
+ price: 25_00,
1579
+ bundleDiscounts,
1580
+ }).create(),
1581
+ await new GroupFactory({
1582
+ organization,
1583
+ price: 45_00,
1584
+ bundleDiscounts,
1585
+ }).create(),
1586
+ ];
1587
+
1588
+ // Create existing registration for group 1
1589
+ const registration1 = await new RegistrationFactory({
1590
+ organization,
1591
+ member,
1592
+ group: groups[0],
1593
+ }).create();
1594
+
1595
+ // Create balance item for existing registration
1596
+ await new BalanceItemFactory({
1597
+ userId: user.id,
1598
+ memberId: member.id,
1599
+ organizationId: organization.id,
1600
+ registrationId: registration1.id,
1601
+ type: BalanceItemType.Registration,
1602
+ amount: 1,
1603
+ unitPrice: 35_00,
1604
+ status: BalanceItemStatus.Due,
1605
+ }).create();
1606
+
1607
+ // Now register the member for group 2 & 3 at the same time
1608
+ const checkout = IDRegisterCheckout.create({
1609
+ cart: IDRegisterCart.create({
1610
+ items: [
1611
+ IDRegisterItem.create({
1612
+ groupPrice: groups[1].settings.prices[0],
1613
+ groupId: groups[1].id,
1614
+ organizationId: organization.id,
1615
+ memberId: member.id,
1616
+ }),
1617
+ IDRegisterItem.create({
1618
+ groupPrice: groups[2].settings.prices[0],
1619
+ groupId: groups[2].id,
1620
+ organizationId: organization.id,
1621
+ memberId: member.id,
1622
+ }),
1623
+ ],
1624
+ }),
1625
+ paymentMethod: PaymentMethod.PointOfSale,
1626
+ totalPrice: 25_00 + 45_00 - 7_00 - 18_00 - 50 - 5_00,
1627
+ });
1628
+ const response = await post(checkout, organization, token);
1629
+ expect(response.body.registrations.length).toBe(2);
1630
+ const registration2 = response.body.registrations.find(r => r.groupId === groups[1].id)!;
1631
+ const registration3 = response.body.registrations.find(r => r.groupId === groups[2].id)!;
1632
+
1633
+ expect(registration2).toMatchObject({
1634
+ registeredAt: expect.any(Date),
1635
+ discounts: new Map([
1636
+ [
1637
+ bundleDiscounts[1].id,
1638
+ AppliedRegistrationDiscount.create({
1639
+ name: bundleDiscounts[1].name,
1640
+ amount: 50,
1641
+ }),
1642
+ ],
1643
+ ]),
1644
+ });
1645
+
1646
+ expect(registration3).toMatchObject({
1647
+ registeredAt: expect.any(Date),
1648
+ discounts: new Map([
1649
+ [
1650
+ bundleDiscounts[0].id,
1651
+ AppliedRegistrationDiscount.create({
1652
+ name: bundleDiscounts[0].name,
1653
+ amount: 18_00,
1654
+ }),
1655
+ ],
1656
+ [
1657
+ bundleDiscounts[1].id,
1658
+ AppliedRegistrationDiscount.create({
1659
+ name: bundleDiscounts[1].name,
1660
+ amount: 5_00,
1661
+ }),
1662
+ ],
1663
+ ]),
1664
+ });
1665
+
1666
+ await assertBalances({ user }, [
1667
+ {
1668
+ type: BalanceItemType.Registration,
1669
+ registrationId: registration1.id,
1670
+ amount: 1,
1671
+ unitPrice: 35_00,
1672
+ status: BalanceItemStatus.Due,
1673
+ priceOpen: 35_00,
1674
+ pricePending: 0,
1675
+ },
1676
+ {
1677
+ type: BalanceItemType.Registration,
1678
+ registrationId: registration2.id,
1679
+ amount: 1,
1680
+ unitPrice: 25_00,
1681
+ status: BalanceItemStatus.Due,
1682
+ priceOpen: 0,
1683
+ pricePending: 25_00,
1684
+ },
1685
+ {
1686
+ type: BalanceItemType.Registration,
1687
+ registrationId: registration3.id,
1688
+ amount: 1,
1689
+ unitPrice: 45_00,
1690
+ status: BalanceItemStatus.Due,
1691
+ priceOpen: 0,
1692
+ pricePending: 45_00,
1693
+ },
1694
+
1695
+ // Discounts (4)
1696
+ {
1697
+ type: BalanceItemType.RegistrationBundleDiscount,
1698
+ registrationId: registration2.id,
1699
+ amount: 1,
1700
+ unitPrice: -50,
1701
+ status: BalanceItemStatus.Due,
1702
+ priceOpen: 0,
1703
+ pricePending: -50,
1704
+ },
1705
+ {
1706
+ type: BalanceItemType.RegistrationBundleDiscount,
1707
+ registrationId: registration3.id,
1708
+ amount: 1,
1709
+ unitPrice: -18_00,
1710
+ status: BalanceItemStatus.Due,
1711
+ priceOpen: 0,
1712
+ pricePending: -18_00,
1713
+ },
1714
+ {
1715
+ type: BalanceItemType.RegistrationBundleDiscount,
1716
+ registrationId: registration3.id,
1717
+ amount: 1,
1718
+ unitPrice: -5_00,
1719
+ status: BalanceItemStatus.Due,
1720
+ priceOpen: 0,
1721
+ pricePending: -5_00,
1722
+ },
1723
+ {
1724
+ type: BalanceItemType.RegistrationBundleDiscount,
1725
+ registrationId: registration1.id,
1726
+ amount: 1,
1727
+ unitPrice: -7_00,
1728
+ status: BalanceItemStatus.Due,
1729
+ priceOpen: 0,
1730
+ pricePending: -7_00,
1731
+ },
1732
+ ]);
1733
+ });
1734
+
1735
+ // We run this test 5 times because it should be stable
1736
+ test.each([1, 2, 3, 4, 5])('If discounts are the same, they are not moved around after new registrations are added (%i th try)', async () => {
1737
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
1738
+ const bundleDiscount = await initBundleDiscount({
1739
+ organizationRegistrationPeriod,
1740
+ discount: {
1741
+ discounts: [
1742
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1743
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1744
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1745
+ ],
1746
+ },
1747
+ });
1748
+
1749
+ const groups = [
1750
+ await new GroupFactory({
1751
+ organization,
1752
+ price: 25_00, // 20% discount = 5_00
1753
+ bundleDiscount,
1754
+ }).create(),
1755
+ await new GroupFactory({
1756
+ organization,
1757
+ price: 25_00, // 20% discount = 5_00 (same as group 1)
1758
+ bundleDiscount,
1759
+ }).create(),
1760
+ await new GroupFactory({
1761
+ organization,
1762
+ price: 25_00, // 20% discount = 5_00 (same as groups 1 & 2)
1763
+ bundleDiscount,
1764
+ }).create(),
1765
+ ];
1766
+
1767
+ // Create existing registrations for group 1 & 2 with discounts already applied
1768
+ const registration1 = await new RegistrationFactory({
1769
+ organization,
1770
+ member,
1771
+ group: groups[0],
1772
+ }).create();
1773
+
1774
+ const registration2 = await new RegistrationFactory({
1775
+ organization,
1776
+ member,
1777
+ group: groups[1],
1778
+ }).create();
1779
+
1780
+ // Create balance items for existing registrations
1781
+ await new BalanceItemFactory({
1782
+ userId: user.id,
1783
+ memberId: member.id,
1784
+ organizationId: organization.id,
1785
+ registrationId: registration1.id,
1786
+ type: BalanceItemType.Registration,
1787
+ amount: 1,
1788
+ unitPrice: 25_00,
1789
+ status: BalanceItemStatus.Due,
1790
+ }).create();
1791
+
1792
+ await new BalanceItemFactory({
1793
+ userId: user.id,
1794
+ memberId: member.id,
1795
+ organizationId: organization.id,
1796
+ registrationId: registration1.id,
1797
+ type: BalanceItemType.RegistrationBundleDiscount,
1798
+ amount: 1,
1799
+ unitPrice: -5_00,
1800
+ status: BalanceItemStatus.Due,
1801
+ relations: new Map([
1802
+ [
1803
+ BalanceItemRelationType.Discount,
1804
+ BalanceItemRelation.create({
1805
+ id: bundleDiscount.id,
1806
+ name: bundleDiscount.name,
1807
+ }),
1808
+ ],
1809
+ ]),
1810
+ }).create();
1811
+
1812
+ await new BalanceItemFactory({
1813
+ userId: user.id,
1814
+ memberId: member.id,
1815
+ organizationId: organization.id,
1816
+ registrationId: registration2.id,
1817
+ type: BalanceItemType.Registration,
1818
+ amount: 1,
1819
+ unitPrice: 25_00,
1820
+ status: BalanceItemStatus.Due,
1821
+ }).create();
1822
+
1823
+ // No discount on 2 ( = first)
1824
+
1825
+ await BalanceItemService.flushCaches(organization.id);
1826
+ await registration1.refresh();
1827
+ await registration2.refresh();
1828
+
1829
+ // Check this is what we expect
1830
+ expect(registration2.discounts).toMatchMap(new Map([]));
1831
+ expect(registration1.discounts).toMatchMap(new Map([
1832
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
1833
+ name: bundleDiscount.name,
1834
+ amount: 5_00,
1835
+ })],
1836
+ ]));
1837
+
1838
+ // Now register the member for group 3
1839
+ const checkout = IDRegisterCheckout.create({
1840
+ cart: IDRegisterCart.create({
1841
+ items: [
1842
+ IDRegisterItem.create({
1843
+ groupPrice: groups[2].settings.prices[0],
1844
+ groupId: groups[2].id,
1845
+ organizationId: organization.id,
1846
+ memberId: member.id,
1847
+ }),
1848
+ ],
1849
+ }),
1850
+ paymentMethod: PaymentMethod.PointOfSale,
1851
+ totalPrice: 25_00 - 5_00, // 20% discount on new registration
1852
+ });
1853
+
1854
+ const response = await post(checkout, organization, token);
1855
+ expect(response.body.registrations.length).toBe(1);
1856
+ const registration3 = response.body.registrations[0];
1857
+
1858
+ expect(registration3).toMatchObject({
1859
+ registeredAt: expect.any(Date),
1860
+ discounts: new Map([
1861
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
1862
+ name: bundleDiscount.name,
1863
+ amount: 5_00,
1864
+ })],
1865
+ ]),
1866
+ });
1867
+
1868
+ // Verify that existing registrations still have their original discounts
1869
+ await registration1.refresh();
1870
+ await registration2.refresh();
1871
+
1872
+ expect(registration2.discounts).toMatchMap(new Map([]));
1873
+ expect(registration1.discounts).toMatchMap(new Map([
1874
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
1875
+ name: bundleDiscount.name,
1876
+ amount: 5_00,
1877
+ })],
1878
+ ]));
1879
+
1880
+ await assertBalances({ user }, [
1881
+ {
1882
+ type: BalanceItemType.Registration,
1883
+ registrationId: registration1.id,
1884
+ amount: 1,
1885
+ price: 25_00,
1886
+ status: BalanceItemStatus.Due,
1887
+ priceOpen: 25_00,
1888
+ pricePending: 0,
1889
+ },
1890
+ {
1891
+ type: BalanceItemType.RegistrationBundleDiscount,
1892
+ registrationId: registration1.id,
1893
+ amount: 1,
1894
+ price: -5_00,
1895
+ status: BalanceItemStatus.Due,
1896
+ priceOpen: -5_00,
1897
+ pricePending: 0,
1898
+ },
1899
+ {
1900
+ type: BalanceItemType.Registration,
1901
+ registrationId: registration2.id,
1902
+ amount: 1,
1903
+ price: 25_00,
1904
+ status: BalanceItemStatus.Due,
1905
+ priceOpen: 25_00,
1906
+ pricePending: 0,
1907
+ },
1908
+ {
1909
+ type: BalanceItemType.Registration,
1910
+ registrationId: registration3.id,
1911
+ amount: 1,
1912
+ price: 25_00,
1913
+ status: BalanceItemStatus.Due,
1914
+ priceOpen: 0,
1915
+ pricePending: 25_00,
1916
+ },
1917
+ {
1918
+ type: BalanceItemType.RegistrationBundleDiscount,
1919
+ registrationId: registration3.id,
1920
+ amount: 1,
1921
+ price: -5_00,
1922
+ status: BalanceItemStatus.Due,
1923
+ priceOpen: 0,
1924
+ pricePending: -5_00,
1925
+ },
1926
+ ]);
1927
+ });
1928
+
1929
+ test('Wrong discounts are not corrected for unrelated registrations', async () => {
1930
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
1931
+ const bundleDiscount = await initBundleDiscount({
1932
+ organizationRegistrationPeriod,
1933
+ discount: {
1934
+ discounts: [
1935
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
1936
+ ],
1937
+ },
1938
+ });
1939
+
1940
+ const groups = [
1941
+ await new GroupFactory({
1942
+ organization,
1943
+ price: 25_00, // 20% discount = 5_00
1944
+ bundleDiscount,
1945
+ }).create(),
1946
+
1947
+ // Does not have discount:
1948
+ await new GroupFactory({
1949
+ organization,
1950
+ price: 25_00,
1951
+ }).create(),
1952
+ ];
1953
+
1954
+ // Create existing registrations for group 1
1955
+ const registration1 = await new RegistrationFactory({
1956
+ organization,
1957
+ member,
1958
+ group: groups[0],
1959
+ }).create();
1960
+
1961
+ // Create balance items for existing registrations
1962
+ await new BalanceItemFactory({
1963
+ userId: user.id,
1964
+ memberId: member.id,
1965
+ organizationId: organization.id,
1966
+ registrationId: registration1.id,
1967
+ type: BalanceItemType.Registration,
1968
+ amount: 1,
1969
+ unitPrice: 25_00,
1970
+ status: BalanceItemStatus.Due,
1971
+ }).create();
1972
+
1973
+ // This registration should not have a discount applied
1974
+ // but for some reason it has
1975
+ // (e.g the discount was altered in some way)
1976
+ // we test that we won't touch this discount
1977
+ await new BalanceItemFactory({
1978
+ userId: user.id,
1979
+ memberId: member.id,
1980
+ organizationId: organization.id,
1981
+ registrationId: registration1.id,
1982
+ type: BalanceItemType.RegistrationBundleDiscount,
1983
+ amount: 1,
1984
+ unitPrice: -5_00,
1985
+ status: BalanceItemStatus.Due,
1986
+ relations: new Map([
1987
+ [
1988
+ BalanceItemRelationType.Discount,
1989
+ BalanceItemRelation.create({
1990
+ id: bundleDiscount.id,
1991
+ name: bundleDiscount.name,
1992
+ }),
1993
+ ],
1994
+ ]),
1995
+ }).create();
1996
+
1997
+ // No discount on 2 ( = first)
1998
+
1999
+ await BalanceItemService.flushCaches(organization.id);
2000
+ await registration1.refresh();
2001
+
2002
+ // Check this is what we expect
2003
+ expect(registration1.discounts).toMatchMap(new Map([
2004
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2005
+ name: bundleDiscount.name,
2006
+ amount: 5_00,
2007
+ })],
2008
+ ]));
2009
+
2010
+ // Now register the member for group 2
2011
+ const checkout = IDRegisterCheckout.create({
2012
+ cart: IDRegisterCart.create({
2013
+ items: [
2014
+ IDRegisterItem.create({
2015
+ groupPrice: groups[1].settings.prices[0],
2016
+ groupId: groups[1].id,
2017
+ organizationId: organization.id,
2018
+ memberId: member.id,
2019
+ }),
2020
+ ],
2021
+ }),
2022
+ paymentMethod: PaymentMethod.PointOfSale,
2023
+ totalPrice: 25_00,
2024
+ });
2025
+
2026
+ const response = await post(checkout, organization, token);
2027
+ expect(response.body.registrations.length).toBe(1);
2028
+ const registration2 = response.body.registrations[0];
2029
+
2030
+ expect(registration2).toMatchObject({
2031
+ registeredAt: expect.any(Date),
2032
+ discounts: new Map([]),
2033
+ });
2034
+
2035
+ // Verify that existing registrations still have their original discounts
2036
+ await registration1.refresh();
2037
+ expect(registration1.discounts).toMatchMap(new Map([
2038
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2039
+ name: bundleDiscount.name,
2040
+ amount: 5_00,
2041
+ })],
2042
+ ]));
2043
+
2044
+ await assertBalances({ user }, [
2045
+ {
2046
+ type: BalanceItemType.Registration,
2047
+ registrationId: registration1.id,
2048
+ amount: 1,
2049
+ price: 25_00,
2050
+ status: BalanceItemStatus.Due,
2051
+ priceOpen: 25_00,
2052
+ pricePending: 0,
2053
+ },
2054
+ {
2055
+ type: BalanceItemType.RegistrationBundleDiscount,
2056
+ registrationId: registration1.id,
2057
+ amount: 1,
2058
+ price: -5_00,
2059
+ status: BalanceItemStatus.Due,
2060
+ priceOpen: -5_00,
2061
+ pricePending: 0,
2062
+ },
2063
+ {
2064
+ type: BalanceItemType.Registration,
2065
+ registrationId: registration2.id,
2066
+ amount: 1,
2067
+ price: 25_00,
2068
+ status: BalanceItemStatus.Due,
2069
+ priceOpen: 0,
2070
+ pricePending: 25_00,
2071
+ },
2072
+ ]);
2073
+ });
2074
+
2075
+ // We run this test 5 times because it should be stable
2076
+ test.each([1, 2, 3, 4, 5])('If discounts are the same, they are preferrably added to the new registration, not the existing registrations (%i th try)', async () => {
2077
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
2078
+ const bundleDiscount = await initBundleDiscount({
2079
+ organizationRegistrationPeriod,
2080
+ discount: {
2081
+ discounts: [
2082
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
2083
+ ],
2084
+ },
2085
+ });
2086
+
2087
+ const groups = [
2088
+ await new GroupFactory({
2089
+ organization,
2090
+ price: 25_00, // 20% discount = 5_00
2091
+ bundleDiscount,
2092
+ }).create(),
2093
+ await new GroupFactory({
2094
+ organization,
2095
+ price: 25_00, // 20% discount = 5_00 (same as group 1)
2096
+ bundleDiscount,
2097
+ }).create(),
2098
+ ];
2099
+
2100
+ // Create existing registrations for group 1
2101
+ const registration1 = await new RegistrationFactory({
2102
+ organization,
2103
+ member,
2104
+ group: groups[0],
2105
+ }).create();
2106
+
2107
+ // Create balance items for existing registrations
2108
+ await new BalanceItemFactory({
2109
+ userId: user.id,
2110
+ memberId: member.id,
2111
+ organizationId: organization.id,
2112
+ registrationId: registration1.id,
2113
+ type: BalanceItemType.Registration,
2114
+ amount: 1,
2115
+ unitPrice: 25_00,
2116
+ status: BalanceItemStatus.Due,
2117
+ }).create();
2118
+
2119
+ // No discount on 2 ( = first)
2120
+ await BalanceItemService.flushCaches(organization.id);
2121
+ await registration1.refresh();
2122
+
2123
+ // Check this is what we expect
2124
+ expect(registration1.discounts).toMatchMap(new Map([]));
2125
+
2126
+ // Now register the member for group 3
2127
+ const checkout = IDRegisterCheckout.create({
2128
+ cart: IDRegisterCart.create({
2129
+ items: [
2130
+ IDRegisterItem.create({
2131
+ groupPrice: groups[1].settings.prices[0],
2132
+ groupId: groups[1].id,
2133
+ organizationId: organization.id,
2134
+ memberId: member.id,
2135
+ }),
2136
+ ],
2137
+ }),
2138
+ paymentMethod: PaymentMethod.PointOfSale,
2139
+ totalPrice: 25_00 - 5_00, // 20% discount on new registration
2140
+ });
2141
+
2142
+ const response = await post(checkout, organization, token);
2143
+ expect(response.body.registrations.length).toBe(1);
2144
+ const registration2 = response.body.registrations[0];
2145
+
2146
+ expect(registration2).toMatchObject({
2147
+ registeredAt: expect.any(Date),
2148
+ discounts: new Map([
2149
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2150
+ name: bundleDiscount.name,
2151
+ amount: 5_00,
2152
+ })],
2153
+ ]),
2154
+ });
2155
+
2156
+ // Verify that existing registrations still have their original discounts
2157
+ await registration1.refresh();
2158
+ expect(registration1.discounts).toMatchMap(new Map([]));
2159
+
2160
+ await assertBalances({ user }, [
2161
+ {
2162
+ type: BalanceItemType.Registration,
2163
+ registrationId: registration1.id,
2164
+ amount: 1,
2165
+ price: 25_00,
2166
+ status: BalanceItemStatus.Due,
2167
+ priceOpen: 25_00,
2168
+ pricePending: 0,
2169
+ },
2170
+ {
2171
+ type: BalanceItemType.Registration,
2172
+ registrationId: registration2.id,
2173
+ amount: 1,
2174
+ price: 25_00,
2175
+ status: BalanceItemStatus.Due,
2176
+ priceOpen: 0,
2177
+ pricePending: 25_00,
2178
+ },
2179
+ {
2180
+ type: BalanceItemType.RegistrationBundleDiscount,
2181
+ registrationId: registration2.id,
2182
+ amount: 1,
2183
+ price: -5_00,
2184
+ status: BalanceItemStatus.Due,
2185
+ priceOpen: 0,
2186
+ pricePending: -5_00,
2187
+ },
2188
+ ]);
2189
+ });
2190
+
2191
+ test('Negative prices are not possible for combination of discounts', async () => {
2192
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
2193
+ const bundleDiscounts = [
2194
+ await initBundleDiscount({
2195
+ organizationRegistrationPeriod,
2196
+ discount: {
2197
+ discounts: [
2198
+ { value: 60_00, type: GroupPriceDiscountType.Percentage },
2199
+ { value: 40_00, type: GroupPriceDiscountType.Percentage },
2200
+ ],
2201
+ },
2202
+ }),
2203
+ await initBundleDiscount({
2204
+ organizationRegistrationPeriod,
2205
+ discount: {
2206
+ discounts: [
2207
+ { value: 60_00, type: GroupPriceDiscountType.Percentage },
2208
+ { value: 40_00, type: GroupPriceDiscountType.Percentage },
2209
+ ],
2210
+ },
2211
+ }),
2212
+ ];
2213
+
2214
+ const groups = [
2215
+ await new GroupFactory({
2216
+ organization,
2217
+ price: 25_00,
2218
+ bundleDiscounts,
2219
+ }).create(),
2220
+ await new GroupFactory({
2221
+ organization,
2222
+ price: 35_00, // Higher price so discount is applied preferably here
2223
+ bundleDiscounts,
2224
+ }).create(),
2225
+ ];
2226
+
2227
+ // Create existing registration for group 1
2228
+ const registration1 = await new RegistrationFactory({
2229
+ organization,
2230
+ member,
2231
+ group: groups[0],
2232
+ }).create();
2233
+
2234
+ // Create balance item for existing registration
2235
+ await new BalanceItemFactory({
2236
+ userId: user.id,
2237
+ memberId: member.id,
2238
+ organizationId: organization.id,
2239
+ registrationId: registration1.id,
2240
+ type: BalanceItemType.Registration,
2241
+ amount: 1,
2242
+ unitPrice: 25_00,
2243
+ status: BalanceItemStatus.Due,
2244
+ }).create();
2245
+
2246
+ const checkout = IDRegisterCheckout.create({
2247
+ cart: IDRegisterCart.create({
2248
+ items: [
2249
+ IDRegisterItem.create({
2250
+ groupPrice: groups[1].settings.prices[0],
2251
+ groupId: groups[1].id,
2252
+ organizationId: organization.id,
2253
+ memberId: member.id,
2254
+ }),
2255
+ ],
2256
+ }),
2257
+ paymentMethod: PaymentMethod.PointOfSale,
2258
+ totalPrice: 35_00 - 42_00, // 120% discount on 35_00
2259
+ });
2260
+ await expect(post(checkout, organization, token)).rejects
2261
+ .toThrow(STExpect.simpleError({
2262
+ code: 'negative_price',
2263
+ }));
2264
+
2265
+ // The backend should have thrown the error early enough, so no balances should have been created
2266
+ await assertBalances({ user }, [
2267
+ {
2268
+ type: BalanceItemType.Registration,
2269
+ registrationId: registration1.id,
2270
+ amount: 1,
2271
+ unitPrice: 25_00,
2272
+ status: BalanceItemStatus.Due,
2273
+ priceOpen: 25_00,
2274
+ pricePending: 0,
2275
+ },
2276
+ ]);
2277
+ });
2278
+
2279
+ test('Reduced prices and discounts are used', async () => {
2280
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
2281
+ member.details.requiresFinancialSupport = BooleanStatus.create({ value: true });
2282
+ await member.save();
2283
+
2284
+ const bundleDiscount = await initBundleDiscount({
2285
+ organizationRegistrationPeriod,
2286
+ discount: {
2287
+ discounts: [
2288
+ {
2289
+ value: 20_00,
2290
+ type: GroupPriceDiscountType.Percentage,
2291
+ reducedValue: 10_00,
2292
+ },
2293
+ ],
2294
+ },
2295
+ });
2296
+
2297
+ const groups = [
2298
+ await new GroupFactory({
2299
+ organization,
2300
+ price: 25_00,
2301
+ reducedPrice: 20_00,
2302
+ bundleDiscount,
2303
+ }).create(),
2304
+ await new GroupFactory({
2305
+ organization,
2306
+ price: 15_00,
2307
+ reducedPrice: 10_00,
2308
+ bundleDiscount,
2309
+ }).create(),
2310
+ ];
2311
+
2312
+ // First register the member for group 1. No discount should be applied yet
2313
+ const registration1 = await new RegistrationFactory({
2314
+ organization,
2315
+ member,
2316
+ group: groups[0],
2317
+ }).create();
2318
+
2319
+ await new BalanceItemFactory({
2320
+ userId: user.id,
2321
+ memberId: member.id,
2322
+ organizationId: organization.id,
2323
+ type: BalanceItemType.Registration,
2324
+ amount: 1,
2325
+ unitPrice: 20_00, // Reduced price
2326
+ status: BalanceItemStatus.Due,
2327
+ registrationId: registration1.id,
2328
+ }).create();
2329
+
2330
+ const checkout = IDRegisterCheckout.create({
2331
+ cart: IDRegisterCart.create({
2332
+ items: [
2333
+ IDRegisterItem.create({
2334
+ groupPrice: groups[1].settings.prices[0],
2335
+ groupId: groups[1].id,
2336
+ organizationId: organization.id,
2337
+ memberId: member.id,
2338
+ }),
2339
+ ],
2340
+ }),
2341
+ paymentMethod: PaymentMethod.PointOfSale,
2342
+ totalPrice: 10_00 - 2_00, // 10% discount on first group
2343
+ });
2344
+
2345
+ const response2 = await post(checkout, organization, token);
2346
+ expect(response2.body.registrations.length).toBe(1);
2347
+
2348
+ const registration2 = response2.body.registrations[0];
2349
+ expect(registration2.registeredAt).not.toBeNull();
2350
+ expect(registration2.discounts).toMatchMap(new Map());
2351
+
2352
+ // Get registration 1 again, it should now have the bundle discount applied
2353
+ await registration1.refresh();
2354
+ expect(registration1.discounts).toMatchMap(new Map([
2355
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2356
+ name: bundleDiscount.name,
2357
+ amount: 2_00,
2358
+ })],
2359
+ ]));
2360
+
2361
+ await assertBalances({ user }, [
2362
+ {
2363
+ type: BalanceItemType.Registration,
2364
+ registrationId: registration1.id,
2365
+ amount: 1,
2366
+ price: 20_00,
2367
+ status: BalanceItemStatus.Due,
2368
+ priceOpen: 20_00,
2369
+ pricePending: 0,
2370
+ },
2371
+ {
2372
+ type: BalanceItemType.RegistrationBundleDiscount,
2373
+ registrationId: registration1.id,
2374
+ amount: 1,
2375
+ price: -2_00,
2376
+ status: BalanceItemStatus.Due,
2377
+ priceOpen: 0,
2378
+ pricePending: -2_00,
2379
+ },
2380
+ {
2381
+ type: BalanceItemType.Registration,
2382
+ registrationId: registration2.id,
2383
+ amount: 1,
2384
+ price: 10_00,
2385
+ status: BalanceItemStatus.Due,
2386
+ priceOpen: 0,
2387
+ pricePending: 10_00,
2388
+ },
2389
+ ]);
2390
+ });
2391
+
2392
+ test('Custom discounts are used for certain groups', async () => {
2393
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
2394
+ member.details.requiresFinancialSupport = BooleanStatus.create({ value: true });
2395
+ await member.save();
2396
+
2397
+ const bundleDiscount = await initBundleDiscount({
2398
+ organizationRegistrationPeriod,
2399
+ discount: {
2400
+ discounts: [
2401
+ {
2402
+ value: 20_00,
2403
+ type: GroupPriceDiscountType.Percentage,
2404
+ },
2405
+ ],
2406
+ },
2407
+ });
2408
+
2409
+ const groups = [
2410
+ await new GroupFactory({
2411
+ organization,
2412
+ price: 25_00,
2413
+ bundleDiscount,
2414
+ }).create(),
2415
+ await new GroupFactory({
2416
+ organization,
2417
+ price: 15_00,
2418
+ bundleDiscounts: new Map([
2419
+ [
2420
+ bundleDiscount,
2421
+ [
2422
+ GroupPriceDiscount.create({
2423
+ value: ReduceablePrice.create({ price: 50_00 }),
2424
+ type: GroupPriceDiscountType.Percentage,
2425
+ }),
2426
+ ],
2427
+ ],
2428
+ ]),
2429
+ }).create(),
2430
+ ];
2431
+
2432
+ // First register the member for group 1. No discount should be applied yet
2433
+ const registration1 = await new RegistrationFactory({
2434
+ organization,
2435
+ member,
2436
+ group: groups[0],
2437
+ }).create();
2438
+
2439
+ await new BalanceItemFactory({
2440
+ userId: user.id,
2441
+ memberId: member.id,
2442
+ organizationId: organization.id,
2443
+ type: BalanceItemType.Registration,
2444
+ amount: 1,
2445
+ unitPrice: 25_00,
2446
+ status: BalanceItemStatus.Due,
2447
+ registrationId: registration1.id,
2448
+ }).create();
2449
+
2450
+ const checkout = IDRegisterCheckout.create({
2451
+ cart: IDRegisterCart.create({
2452
+ items: [
2453
+ IDRegisterItem.create({
2454
+ groupPrice: groups[1].settings.prices[0],
2455
+ groupId: groups[1].id,
2456
+ organizationId: organization.id,
2457
+ memberId: member.id,
2458
+ }),
2459
+ ],
2460
+ }),
2461
+ paymentMethod: PaymentMethod.PointOfSale,
2462
+ totalPrice: 15_00 - 7_50, // 50% discount on last group
2463
+ });
2464
+
2465
+ const response2 = await post(checkout, organization, token);
2466
+ expect(response2.body.registrations.length).toBe(1);
2467
+
2468
+ const registration2 = response2.body.registrations[0];
2469
+ expect(registration2.registeredAt).not.toBeNull();
2470
+ expect(registration2.discounts).toMatchMap(new Map([
2471
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2472
+ name: bundleDiscount.name,
2473
+ amount: 7_50,
2474
+ })],
2475
+ ]));
2476
+
2477
+ // Get registration 1 again, it should now have the bundle discount applied
2478
+ await registration1.refresh();
2479
+ expect(registration1.discounts).toMatchMap(new Map());
2480
+
2481
+ await assertBalances({ user }, [
2482
+ {
2483
+ type: BalanceItemType.Registration,
2484
+ registrationId: registration1.id,
2485
+ amount: 1,
2486
+ price: 25_00,
2487
+ status: BalanceItemStatus.Due,
2488
+ priceOpen: 25_00,
2489
+ pricePending: 0,
2490
+ },
2491
+ {
2492
+ type: BalanceItemType.RegistrationBundleDiscount,
2493
+ registrationId: registration2.id,
2494
+ amount: 1,
2495
+ price: -7_50,
2496
+ status: BalanceItemStatus.Due,
2497
+ priceOpen: 0,
2498
+ pricePending: -7_50,
2499
+ },
2500
+ {
2501
+ type: BalanceItemType.Registration,
2502
+ registrationId: registration2.id,
2503
+ amount: 1,
2504
+ price: 15_00,
2505
+ status: BalanceItemStatus.Due,
2506
+ priceOpen: 0,
2507
+ pricePending: 15_00,
2508
+ },
2509
+ ]);
2510
+ });
2511
+
2512
+ test('Discounts work across family members', async () => {
2513
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
2514
+
2515
+ const otherMember = await new MemberFactory({
2516
+ organization,
2517
+ user,
2518
+ }).create();
2519
+
2520
+ const bundleDiscount = await initBundleDiscount({
2521
+ organizationRegistrationPeriod,
2522
+ discount: {
2523
+ countWholeFamily: true,
2524
+ discounts: [
2525
+ {
2526
+ value: 20_00,
2527
+ type: GroupPriceDiscountType.Percentage,
2528
+ },
2529
+ ],
2530
+ },
2531
+ });
2532
+
2533
+ const groups = [
2534
+ await new GroupFactory({
2535
+ organization,
2536
+ price: 25_00,
2537
+ bundleDiscount,
2538
+ }).create(),
2539
+ await new GroupFactory({
2540
+ organization,
2541
+ price: 15_00,
2542
+ bundleDiscount,
2543
+ }).create(),
2544
+ ];
2545
+
2546
+ // First register the otherMember for group 1. No discount should be applied yet
2547
+ const registration1 = await new RegistrationFactory({
2548
+ organization,
2549
+ member: otherMember,
2550
+ group: groups[0],
2551
+ }).create();
2552
+
2553
+ await new BalanceItemFactory({
2554
+ userId: user.id,
2555
+ memberId: otherMember.id,
2556
+ organizationId: organization.id,
2557
+ type: BalanceItemType.Registration,
2558
+ amount: 1,
2559
+ unitPrice: 25_00,
2560
+ status: BalanceItemStatus.Due,
2561
+ registrationId: registration1.id,
2562
+ }).create();
2563
+
2564
+ const checkout = IDRegisterCheckout.create({
2565
+ cart: IDRegisterCart.create({
2566
+ items: [
2567
+ IDRegisterItem.create({
2568
+ groupPrice: groups[1].settings.prices[0],
2569
+ groupId: groups[1].id,
2570
+ organizationId: organization.id,
2571
+ memberId: member.id,
2572
+ }),
2573
+ ],
2574
+ }),
2575
+ paymentMethod: PaymentMethod.PointOfSale,
2576
+ totalPrice: 15_00 - 5_00, // 20% discount on first group
2577
+ });
2578
+
2579
+ const response2 = await post(checkout, organization, token);
2580
+ expect(response2.body.registrations.length).toBe(1);
2581
+
2582
+ const registration2 = response2.body.registrations[0];
2583
+ expect(registration2.registeredAt).not.toBeNull();
2584
+ expect(registration2.discounts).toMatchMap(new Map());
2585
+
2586
+ // Get registration 1 again, it should now have the bundle discount applied
2587
+ await registration1.refresh();
2588
+ expect(registration1.discounts).toMatchMap(new Map([
2589
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2590
+ name: bundleDiscount.name,
2591
+ amount: 5_00,
2592
+ })],
2593
+ ]));
2594
+
2595
+ await assertBalances({ user }, [
2596
+ {
2597
+ type: BalanceItemType.Registration,
2598
+ registrationId: registration1.id,
2599
+ amount: 1,
2600
+ price: 25_00,
2601
+ status: BalanceItemStatus.Due,
2602
+ priceOpen: 25_00,
2603
+ pricePending: 0,
2604
+ },
2605
+ {
2606
+ type: BalanceItemType.RegistrationBundleDiscount,
2607
+ registrationId: registration1.id,
2608
+ amount: 1,
2609
+ price: -5_00,
2610
+ status: BalanceItemStatus.Due,
2611
+ priceOpen: 0,
2612
+ pricePending: -5_00,
2613
+ },
2614
+ {
2615
+ type: BalanceItemType.Registration,
2616
+ registrationId: registration2.id,
2617
+ amount: 1,
2618
+ price: 15_00,
2619
+ status: BalanceItemStatus.Due,
2620
+ priceOpen: 0,
2621
+ pricePending: 15_00,
2622
+ },
2623
+ ]);
2624
+ });
2625
+
2626
+ test('Discounts can be disabled across family members', async () => {
2627
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
2628
+
2629
+ const otherMember = await new MemberFactory({
2630
+ organization,
2631
+ user,
2632
+ }).create();
2633
+
2634
+ const bundleDiscount = await initBundleDiscount({
2635
+ organizationRegistrationPeriod,
2636
+ discount: {
2637
+ countWholeFamily: false,
2638
+ discounts: [
2639
+ {
2640
+ value: 20_00,
2641
+ type: GroupPriceDiscountType.Percentage,
2642
+ },
2643
+ ],
2644
+ },
2645
+ });
2646
+
2647
+ const groups = [
2648
+ await new GroupFactory({
2649
+ organization,
2650
+ price: 25_00,
2651
+ bundleDiscount,
2652
+ }).create(),
2653
+ await new GroupFactory({
2654
+ organization,
2655
+ price: 15_00,
2656
+ bundleDiscount,
2657
+ }).create(),
2658
+ ];
2659
+
2660
+ // First register the otherMember for group 1. No discount should be applied yet
2661
+ const registration1 = await new RegistrationFactory({
2662
+ organization,
2663
+ member: otherMember,
2664
+ group: groups[0],
2665
+ }).create();
2666
+
2667
+ await new BalanceItemFactory({
2668
+ userId: user.id,
2669
+ memberId: otherMember.id,
2670
+ organizationId: organization.id,
2671
+ type: BalanceItemType.Registration,
2672
+ amount: 1,
2673
+ unitPrice: 25_00,
2674
+ status: BalanceItemStatus.Due,
2675
+ registrationId: registration1.id,
2676
+ }).create();
2677
+
2678
+ const checkout = IDRegisterCheckout.create({
2679
+ cart: IDRegisterCart.create({
2680
+ items: [
2681
+ IDRegisterItem.create({
2682
+ groupPrice: groups[1].settings.prices[0],
2683
+ groupId: groups[1].id,
2684
+ organizationId: organization.id,
2685
+ memberId: member.id,
2686
+ }),
2687
+ ],
2688
+ }),
2689
+ paymentMethod: PaymentMethod.PointOfSale,
2690
+ totalPrice: 15_00,
2691
+ });
2692
+
2693
+ const response2 = await post(checkout, organization, token);
2694
+ expect(response2.body.registrations.length).toBe(1);
2695
+
2696
+ const registration2 = response2.body.registrations[0];
2697
+ expect(registration2.registeredAt).not.toBeNull();
2698
+ expect(registration2.discounts).toMatchMap(new Map());
2699
+
2700
+ await registration1.refresh();
2701
+ expect(registration1.discounts).toMatchMap(new Map());
2702
+
2703
+ await assertBalances({ user }, [
2704
+ {
2705
+ type: BalanceItemType.Registration,
2706
+ registrationId: registration1.id,
2707
+ amount: 1,
2708
+ price: 25_00,
2709
+ status: BalanceItemStatus.Due,
2710
+ priceOpen: 25_00,
2711
+ pricePending: 0,
2712
+ },
2713
+ {
2714
+ type: BalanceItemType.Registration,
2715
+ registrationId: registration2.id,
2716
+ amount: 1,
2717
+ price: 15_00,
2718
+ status: BalanceItemStatus.Due,
2719
+ priceOpen: 0,
2720
+ pricePending: 15_00,
2721
+ },
2722
+ ]);
2723
+ });
2724
+ });
2725
+
2726
+ describe('Changing registrations as admin', () => {
2727
+ async function initDataWithRegistrations() {
2728
+ const { organizationRegistrationPeriod, organization, member, user } = await initData();
2729
+
2730
+ const bundleDiscount = await initBundleDiscount({
2731
+ organizationRegistrationPeriod,
2732
+ discount: {
2733
+ discounts: [
2734
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
2735
+ ],
2736
+ },
2737
+ });
2738
+
2739
+ const groups = [
2740
+ await new GroupFactory({
2741
+ organization,
2742
+ price: 25_00,
2743
+ bundleDiscount,
2744
+ }).create(),
2745
+ await new GroupFactory({
2746
+ organization,
2747
+ price: 15_00,
2748
+ bundleDiscount,
2749
+ }).create(),
2750
+ await new GroupFactory({
2751
+ organization,
2752
+ price: 40_00,
2753
+ bundleDiscount,
2754
+ }).create(),
2755
+ ];
2756
+
2757
+ // First register the member for group 1 & 2
2758
+ const registration1 = await new RegistrationFactory({
2759
+ organization,
2760
+ member,
2761
+ group: groups[0],
2762
+ }).create();
2763
+
2764
+ const registration2 = await new RegistrationFactory({
2765
+ organization,
2766
+ member,
2767
+ group: groups[1],
2768
+ }).create();
2769
+
2770
+ // Create initial balances
2771
+ await new BalanceItemFactory({
2772
+ memberId: member.id,
2773
+ organizationId: organization.id,
2774
+ registrationId: registration1.id,
2775
+ type: BalanceItemType.Registration,
2776
+ amount: 1,
2777
+ unitPrice: 25_00,
2778
+ status: BalanceItemStatus.Due,
2779
+ }).create();
2780
+
2781
+ // Create applied discount
2782
+ await new BalanceItemFactory({
2783
+ memberId: member.id,
2784
+ organizationId: organization.id,
2785
+ registrationId: registration1.id,
2786
+ type: BalanceItemType.RegistrationBundleDiscount,
2787
+ amount: 1,
2788
+ unitPrice: -5_00,
2789
+ status: BalanceItemStatus.Due,
2790
+ relations: new Map([
2791
+ [
2792
+ BalanceItemRelationType.Discount,
2793
+ BalanceItemRelation.create({
2794
+ // We need the ID of the discount saved here
2795
+ id: bundleDiscount.id,
2796
+ name: bundleDiscount.name,
2797
+ }),
2798
+ ],
2799
+ ]),
2800
+ }).create();
2801
+
2802
+ await new BalanceItemFactory({
2803
+ memberId: member.id,
2804
+ organizationId: organization.id,
2805
+ registrationId: registration2.id,
2806
+ type: BalanceItemType.Registration,
2807
+ amount: 1,
2808
+ unitPrice: 15_00,
2809
+ status: BalanceItemStatus.Due,
2810
+ }).create();
2811
+
2812
+ await BalanceItemService.flushCaches(organization.id);
2813
+
2814
+ await registration1.refresh();
2815
+ expect(registration1).toMatchObject({
2816
+ groupId: groups[0].id,
2817
+ registeredAt: expect.any(Date),
2818
+ discounts: new Map([
2819
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2820
+ name: bundleDiscount.name,
2821
+ amount: 5_00,
2822
+ })],
2823
+ ]),
2824
+ });
2825
+
2826
+ return {
2827
+ organization,
2828
+ user,
2829
+ groups,
2830
+ registration1,
2831
+ registration2,
2832
+ member,
2833
+ bundleDiscount,
2834
+ };
2835
+ }
2836
+
2837
+ /**
2838
+ * If you replace a registration, it is possible that a bundle discount of a different registration is not optimal anymore and
2839
+ * a more optimal discount can be applied. In that case, the bundle discount can be moved from the unaltered registration to the newly created registration.
2840
+ */
2841
+ test('Replacing a registration can move the bundle discount of an unaltered registration', async () => {
2842
+ const { organization, bundleDiscount, groups, registration1, registration2, member } = await initDataWithRegistrations();
2843
+ const { adminToken } = await initAdmin({ organization });
2844
+
2845
+ // Now replace registration 2 with group 3, which is more expensive and should give more discount
2846
+ const checkout = IDRegisterCheckout.create({
2847
+ cart: IDRegisterCart.create({
2848
+ items: [
2849
+ IDRegisterItem.create({
2850
+ groupPrice: groups[2].settings.prices[0],
2851
+ groupId: groups[2].id,
2852
+ organizationId: organization.id,
2853
+ memberId: member.id,
2854
+ replaceRegistrationIds: [registration2.id],
2855
+ }),
2856
+ ],
2857
+ }),
2858
+ asOrganizationId: organization.id,
2859
+ totalPrice: 40_00 - 15_00 + 5_00 - 8_00, // group 3 - group 2 + reverted discount - new discount
2860
+ });
2861
+
2862
+ const response = await post(checkout, organization, adminToken);
2863
+ expect(response.body.registrations.length).toBe(1);
2864
+
2865
+ const registration3 = response.body.registrations[0];
2866
+ expect(registration3).toMatchObject({
2867
+ groupId: groups[2].id,
2868
+ registeredAt: expect.any(Date),
2869
+ discounts: new Map([
2870
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2871
+ name: bundleDiscount.name,
2872
+ amount: 8_00,
2873
+ })],
2874
+ ]),
2875
+ });
2876
+
2877
+ await registration2.refresh();
2878
+ await registration1.refresh();
2879
+
2880
+ // Check discount has been removed in registration 1
2881
+ expect(registration2.discounts).toMatchMap(new Map());
2882
+ expect(registration1.discounts).toMatchMap(new Map());
2883
+
2884
+ // Check balances: no deletions happened, only additions or status changes
2885
+ await assertBalances({ member }, [
2886
+ {
2887
+ type: BalanceItemType.Registration,
2888
+ registrationId: registration1.id,
2889
+ amount: 1,
2890
+ unitPrice: 25_00,
2891
+ status: BalanceItemStatus.Due,
2892
+ priceOpen: 25_00,
2893
+ pricePending: 0,
2894
+ },
2895
+ {
2896
+ type: BalanceItemType.RegistrationBundleDiscount,
2897
+ registrationId: registration1.id,
2898
+ amount: 1,
2899
+ unitPrice: -5_00,
2900
+ status: BalanceItemStatus.Due,
2901
+ pricePending: 0,
2902
+ priceOpen: -5_00,
2903
+ },
2904
+ {
2905
+ type: BalanceItemType.Registration,
2906
+ registrationId: registration2.id,
2907
+ amount: 1,
2908
+ unitPrice: 15_00,
2909
+ status: BalanceItemStatus.Canceled,
2910
+ pricePending: 0,
2911
+ priceOpen: 0,
2912
+ },
2913
+ {
2914
+ type: BalanceItemType.Registration,
2915
+ userId: null,
2916
+ memberId: member.id,
2917
+ registrationId: registration3.id,
2918
+ amount: 1,
2919
+ unitPrice: 40_00,
2920
+ status: BalanceItemStatus.Due,
2921
+ priceOpen: 40_00,
2922
+
2923
+ // Not pending because created by admin
2924
+ pricePending: 0,
2925
+ },
2926
+ // Revert first discount
2927
+ {
2928
+ type: BalanceItemType.RegistrationBundleDiscount,
2929
+ userId: null,
2930
+ memberId: member.id,
2931
+ registrationId: registration1.id,
2932
+ amount: 1,
2933
+ unitPrice: 5_00,
2934
+ status: BalanceItemStatus.Due,
2935
+ priceOpen: 5_00,
2936
+
2937
+ // Not pending because created by admin
2938
+ pricePending: 0,
2939
+ },
2940
+ // Add new discount
2941
+ {
2942
+ type: BalanceItemType.RegistrationBundleDiscount,
2943
+ userId: null,
2944
+ memberId: member.id,
2945
+ registrationId: registration3.id,
2946
+ amount: 1,
2947
+ unitPrice: -8_00,
2948
+ status: BalanceItemStatus.Due,
2949
+ priceOpen: -8_00,
2950
+
2951
+ // Not pending because created by admin
2952
+ pricePending: 0,
2953
+ },
2954
+ ]);
2955
+ });
2956
+
2957
+ test('Replacing a registration with discount', async () => {
2958
+ const { organization, bundleDiscount, groups, registration1, registration2, member } = await initDataWithRegistrations();
2959
+ const { adminToken } = await initAdmin({ organization });
2960
+
2961
+ // Now replace registration 2 with group 3, which is more expensive and should give more discount
2962
+ const checkout = IDRegisterCheckout.create({
2963
+ cart: IDRegisterCart.create({
2964
+ items: [
2965
+ IDRegisterItem.create({
2966
+ groupPrice: groups[2].settings.prices[0],
2967
+ groupId: groups[2].id,
2968
+ organizationId: organization.id,
2969
+ memberId: member.id,
2970
+ replaceRegistrationIds: [registration1.id],
2971
+ }),
2972
+ ],
2973
+ }),
2974
+ asOrganizationId: organization.id,
2975
+ totalPrice: 40_00 - 25_00 + 5_00 - 8_00, // group 3 - group 1 + reverted discount - new discount
2976
+ });
2977
+
2978
+ const response = await post(checkout, organization, adminToken);
2979
+ expect(response.body.registrations.length).toBe(1);
2980
+
2981
+ const registration3 = response.body.registrations[0];
2982
+ expect(registration3).toMatchObject({
2983
+ groupId: groups[2].id,
2984
+ registeredAt: expect.any(Date),
2985
+ discounts: new Map([
2986
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
2987
+ name: bundleDiscount.name,
2988
+ amount: 8_00,
2989
+ })],
2990
+ ]),
2991
+ });
2992
+
2993
+ await registration2.refresh();
2994
+ await registration1.refresh();
2995
+
2996
+ // Check discount has been removed in registration 1
2997
+ expect(registration2.discounts).toMatchMap(new Map());
2998
+ expect(registration1.discounts).toMatchMap(new Map());
2999
+
3000
+ // Check balances
3001
+ await assertBalances({ member }, [
3002
+ {
3003
+ type: BalanceItemType.Registration,
3004
+ registrationId: registration1.id,
3005
+ amount: 1,
3006
+ unitPrice: 25_00,
3007
+ status: BalanceItemStatus.Canceled, // has been cancelled
3008
+ priceOpen: 0,
3009
+ pricePending: 0,
3010
+ },
3011
+ {
3012
+ type: BalanceItemType.RegistrationBundleDiscount,
3013
+ registrationId: registration1.id,
3014
+ amount: 1,
3015
+ unitPrice: -5_00,
3016
+ status: BalanceItemStatus.Canceled, // has been cancelled
3017
+ pricePending: 0,
3018
+ priceOpen: 0,
3019
+ },
3020
+ {
3021
+ type: BalanceItemType.Registration,
3022
+ registrationId: registration2.id,
3023
+ amount: 1,
3024
+ unitPrice: 15_00,
3025
+ status: BalanceItemStatus.Due,
3026
+ pricePending: 0,
3027
+ priceOpen: 15_00,
3028
+ },
3029
+ {
3030
+ type: BalanceItemType.Registration,
3031
+ userId: null,
3032
+ memberId: member.id,
3033
+ registrationId: registration3.id,
3034
+ amount: 1,
3035
+ unitPrice: 40_00,
3036
+ status: BalanceItemStatus.Due,
3037
+ priceOpen: 40_00,
3038
+
3039
+ // Not pending because created by admin
3040
+ pricePending: 0,
3041
+ },
3042
+ // Add new discount
3043
+ {
3044
+ type: BalanceItemType.RegistrationBundleDiscount,
3045
+ userId: null,
3046
+ memberId: member.id,
3047
+ registrationId: registration3.id,
3048
+ amount: 1,
3049
+ unitPrice: -8_00,
3050
+ status: BalanceItemStatus.Due,
3051
+ priceOpen: -8_00,
3052
+
3053
+ // Not pending because created by admin
3054
+ pricePending: 0,
3055
+ },
3056
+ ]);
3057
+ });
3058
+
3059
+ test('Deleting a registration with discount, without cancellation fee', async () => {
3060
+ const { organization, registration1, registration2, member } = await initDataWithRegistrations();
3061
+ const { adminToken } = await initAdmin({ organization });
3062
+
3063
+ // Now delete registration 1, which has discount
3064
+ const checkout = IDRegisterCheckout.create({
3065
+ cart: IDRegisterCart.create({
3066
+ deleteRegistrationIds: [registration1.id],
3067
+ }),
3068
+ asOrganizationId: organization.id,
3069
+ totalPrice: -25_00 + 5_00,
3070
+ cancellationFeePercentage: 0,
3071
+ });
3072
+
3073
+ await post(checkout, organization, adminToken);
3074
+ await registration2.refresh();
3075
+ await registration1.refresh();
3076
+
3077
+ // Check discount has been removed in registration 1
3078
+ expect(registration2.discounts).toMatchMap(new Map());
3079
+ expect(registration1.discounts).toMatchMap(new Map());
3080
+ expect(registration1.deactivatedAt).not.toBeNull(); // should be cancelled
3081
+
3082
+ // Check balances
3083
+ await assertBalances({ member }, [
3084
+ {
3085
+ type: BalanceItemType.Registration,
3086
+ registrationId: registration1.id,
3087
+ amount: 1,
3088
+ unitPrice: 25_00,
3089
+ status: BalanceItemStatus.Canceled, // has been cancelled
3090
+ priceOpen: 0,
3091
+ pricePending: 0,
3092
+ },
3093
+ {
3094
+ type: BalanceItemType.RegistrationBundleDiscount,
3095
+ registrationId: registration1.id,
3096
+ amount: 1,
3097
+ unitPrice: -5_00,
3098
+ status: BalanceItemStatus.Canceled, // has been cancelled
3099
+ pricePending: 0,
3100
+ priceOpen: 0,
3101
+ },
3102
+ {
3103
+ type: BalanceItemType.Registration,
3104
+ registrationId: registration2.id,
3105
+ amount: 1,
3106
+ unitPrice: 15_00,
3107
+ status: BalanceItemStatus.Due,
3108
+ pricePending: 0,
3109
+ priceOpen: 15_00,
3110
+ },
3111
+ ]);
3112
+ });
3113
+
3114
+ test('Deleting a registration with discount, with cancellation fee', async () => {
3115
+ const { organization, bundleDiscount, registration1, registration2, member } = await initDataWithRegistrations();
3116
+ const { adminToken } = await initAdmin({ organization });
3117
+
3118
+ // Now delete registration 1, which has discount
3119
+ const checkout = IDRegisterCheckout.create({
3120
+ cart: IDRegisterCart.create({
3121
+ deleteRegistrationIds: [registration1.id],
3122
+ }),
3123
+ asOrganizationId: organization.id,
3124
+ totalPrice: 0, // no change in outstanding balance, because we charge a cancellation fee
3125
+ cancellationFeePercentage: 100_00,
3126
+ });
3127
+
3128
+ await post(checkout, organization, adminToken);
3129
+ await registration2.refresh();
3130
+ await registration1.refresh();
3131
+
3132
+ // Check discount has NOT been removed in registration 1
3133
+ expect(registration2.discounts).toMatchMap(new Map());
3134
+ expect(registration1.discounts).toMatchMap(new Map([
3135
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3136
+ name: bundleDiscount.name,
3137
+ amount: 5_00,
3138
+ })],
3139
+ ]));
3140
+ expect(registration1.deactivatedAt).not.toBeNull(); // should be cancelled
3141
+
3142
+ // Check balances
3143
+ await assertBalances({ member }, [
3144
+ {
3145
+ type: BalanceItemType.Registration,
3146
+ registrationId: registration1.id,
3147
+ amount: 1,
3148
+ unitPrice: 25_00,
3149
+ status: BalanceItemStatus.Due, // NOT cancelled, because we charge a cancellation fee
3150
+ priceOpen: 25_00, // cancellation fee
3151
+ pricePending: 0,
3152
+ },
3153
+ {
3154
+ type: BalanceItemType.RegistrationBundleDiscount,
3155
+ registrationId: registration1.id,
3156
+ amount: 1,
3157
+ unitPrice: -5_00,
3158
+ status: BalanceItemStatus.Due, // NOT cancelled, because we charge a cancellation fee
3159
+ priceOpen: -5_00,
3160
+ pricePending: 0,
3161
+ },
3162
+ {
3163
+ type: BalanceItemType.Registration,
3164
+ registrationId: registration2.id,
3165
+ amount: 1,
3166
+ unitPrice: 15_00,
3167
+ status: BalanceItemStatus.Due,
3168
+ pricePending: 0,
3169
+ priceOpen: 15_00,
3170
+ },
3171
+ ]);
3172
+ });
3173
+
3174
+ test('Deleting a registration can alter a discount on a different registration, without cancellation fee', async () => {
3175
+ const { organization, registration1, registration2, member } = await initDataWithRegistrations();
3176
+ const { adminToken } = await initAdmin({ organization });
3177
+
3178
+ // Now delete registration 2, which does not have a discount
3179
+ const checkout = IDRegisterCheckout.create({
3180
+ cart: IDRegisterCart.create({
3181
+ deleteRegistrationIds: [registration2.id],
3182
+ }),
3183
+ asOrganizationId: organization.id,
3184
+ totalPrice: -15_00 + 5_00, // -15 back, but also lose discount on registration 1, so add 5
3185
+ cancellationFeePercentage: 0,
3186
+ });
3187
+
3188
+ await post(checkout, organization, adminToken);
3189
+ await registration2.refresh();
3190
+ await registration1.refresh();
3191
+
3192
+ // Check discount has been removed in registration 1
3193
+ expect(registration2.discounts).toMatchMap(new Map());
3194
+ expect(registration1.discounts).toMatchMap(new Map());
3195
+
3196
+ // Registration 2 is canceled:
3197
+ expect(registration2.deactivatedAt).not.toBeNull();
3198
+
3199
+ // Check balances
3200
+ await assertBalances({ member }, [
3201
+ {
3202
+ type: BalanceItemType.Registration,
3203
+ registrationId: registration1.id,
3204
+ amount: 1,
3205
+ unitPrice: 25_00,
3206
+ status: BalanceItemStatus.Due,
3207
+ priceOpen: 25_00,
3208
+ pricePending: 0,
3209
+ },
3210
+ {
3211
+ type: BalanceItemType.RegistrationBundleDiscount,
3212
+ registrationId: registration1.id,
3213
+ amount: 1,
3214
+ unitPrice: -5_00,
3215
+ status: BalanceItemStatus.Due, // has NOT been cancelled
3216
+ pricePending: 0,
3217
+ priceOpen: -5_00,
3218
+ },
3219
+ {
3220
+ type: BalanceItemType.Registration,
3221
+ registrationId: registration2.id,
3222
+ amount: 1,
3223
+ unitPrice: 15_00,
3224
+ status: BalanceItemStatus.Canceled,
3225
+ pricePending: 0,
3226
+ priceOpen: 0,
3227
+ },
3228
+ // A new bundle discount balance item has been created to offset the difference
3229
+ {
3230
+ type: BalanceItemType.RegistrationBundleDiscount,
3231
+ registrationId: registration1.id,
3232
+ amount: 1,
3233
+ unitPrice: 5_00,
3234
+ status: BalanceItemStatus.Due,
3235
+ pricePending: 0,
3236
+ priceOpen: 5_00,
3237
+ },
3238
+ ]);
3239
+ });
3240
+
3241
+ test('Deleting a registration can alter a discount on a different registration, with cancellation fee', async () => {
3242
+ const { organization, registration1, registration2, member } = await initDataWithRegistrations();
3243
+ const { adminToken } = await initAdmin({ organization });
3244
+
3245
+ // Now delete registration 2, which does not have a discount
3246
+ const checkout = IDRegisterCheckout.create({
3247
+ cart: IDRegisterCart.create({
3248
+ deleteRegistrationIds: [registration2.id],
3249
+ }),
3250
+ asOrganizationId: organization.id,
3251
+ totalPrice: 5_00, // Positive 5 because we lose the discount on registration 1
3252
+ cancellationFeePercentage: 100_00, // we charge a cancellation fee
3253
+ });
3254
+
3255
+ await post(checkout, organization, adminToken);
3256
+ await registration2.refresh();
3257
+ await registration1.refresh();
3258
+
3259
+ // Check discount has been removed in registration 1
3260
+ expect(registration2.discounts).toMatchMap(new Map());
3261
+ expect(registration1.discounts).toMatchMap(new Map());
3262
+
3263
+ // Registration 2 is canceled:
3264
+ expect(registration2.deactivatedAt).not.toBeNull();
3265
+
3266
+ // Check balances
3267
+ await assertBalances({ member }, [
3268
+ {
3269
+ type: BalanceItemType.Registration,
3270
+ registrationId: registration1.id,
3271
+ amount: 1,
3272
+ unitPrice: 25_00,
3273
+ status: BalanceItemStatus.Due,
3274
+ priceOpen: 25_00,
3275
+ pricePending: 0,
3276
+ },
3277
+ {
3278
+ type: BalanceItemType.RegistrationBundleDiscount,
3279
+ registrationId: registration1.id,
3280
+ amount: 1,
3281
+ unitPrice: -5_00,
3282
+ status: BalanceItemStatus.Due, // has NOT been cancelled
3283
+ pricePending: 0,
3284
+ priceOpen: -5_00,
3285
+ },
3286
+ {
3287
+ type: BalanceItemType.Registration,
3288
+ registrationId: registration2.id,
3289
+ amount: 1,
3290
+ unitPrice: 15_00,
3291
+ status: BalanceItemStatus.Due, // still due because of cancellation fee
3292
+ pricePending: 0,
3293
+ priceOpen: 15_00,
3294
+ },
3295
+ // A new bundle discount balance item has been created to offset the difference
3296
+ {
3297
+ type: BalanceItemType.RegistrationBundleDiscount,
3298
+ registrationId: registration1.id,
3299
+ amount: 1,
3300
+ unitPrice: 5_00,
3301
+ status: BalanceItemStatus.Due,
3302
+ pricePending: 0,
3303
+ priceOpen: 5_00,
3304
+ },
3305
+ ]);
3306
+ });
3307
+
3308
+ test('Discounts work across family members when admins register members', async () => {
3309
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
3310
+ const { adminToken } = await initAdmin({ organization });
3311
+
3312
+ const otherMember = await new MemberFactory({
3313
+ organization,
3314
+ user,
3315
+ }).create();
3316
+
3317
+ const bundleDiscount = await initBundleDiscount({
3318
+ organizationRegistrationPeriod,
3319
+ discount: {
3320
+ countWholeFamily: true,
3321
+ discounts: [
3322
+ {
3323
+ value: 20_00,
3324
+ type: GroupPriceDiscountType.Percentage,
3325
+ },
3326
+ ],
3327
+ },
3328
+ });
3329
+
3330
+ // Create an unrelated group and registration so admin has access to the member
3331
+ const randomGroup = await new GroupFactory({
3332
+ organization,
3333
+ price: 0,
3334
+ }).create();
3335
+
3336
+ await new RegistrationFactory({
3337
+ organization,
3338
+ member: member,
3339
+ group: randomGroup,
3340
+ }).create();
3341
+
3342
+ const groups = [
3343
+ await new GroupFactory({
3344
+ organization,
3345
+ price: 25_00,
3346
+ bundleDiscount,
3347
+ }).create(),
3348
+ await new GroupFactory({
3349
+ organization,
3350
+ price: 15_00,
3351
+ bundleDiscount,
3352
+ }).create(),
3353
+ ];
3354
+
3355
+ // First register the otherMember for group 1. No discount should be applied yet
3356
+ const registration1 = await new RegistrationFactory({
3357
+ organization,
3358
+ member: otherMember,
3359
+ group: groups[0],
3360
+ }).create();
3361
+
3362
+ await new BalanceItemFactory({
3363
+ userId: user.id,
3364
+ memberId: otherMember.id,
3365
+ organizationId: organization.id,
3366
+ type: BalanceItemType.Registration,
3367
+ amount: 1,
3368
+ unitPrice: 25_00,
3369
+ status: BalanceItemStatus.Due,
3370
+ registrationId: registration1.id,
3371
+ }).create();
3372
+
3373
+ const checkout = IDRegisterCheckout.create({
3374
+ cart: IDRegisterCart.create({
3375
+ items: [
3376
+ IDRegisterItem.create({
3377
+ groupPrice: groups[1].settings.prices[0],
3378
+ groupId: groups[1].id,
3379
+ organizationId: organization.id,
3380
+ memberId: member.id,
3381
+ }),
3382
+ ],
3383
+ }),
3384
+ totalPrice: 15_00 - 5_00, // 20% discount on first group
3385
+ asOrganizationId: organization.id,
3386
+ });
3387
+
3388
+ const response2 = await post(checkout, organization, adminToken);
3389
+ expect(response2.body.registrations.length).toBe(1);
3390
+
3391
+ const registration2 = response2.body.registrations[0];
3392
+ expect(registration2.registeredAt).not.toBeNull();
3393
+ expect(registration2.discounts).toMatchMap(new Map());
3394
+
3395
+ // Get registration 1 again, it should now have the bundle discount applied
3396
+ await registration1.refresh();
3397
+ expect(registration1.discounts).toMatchMap(new Map([
3398
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3399
+ name: bundleDiscount.name,
3400
+ amount: 5_00,
3401
+ })],
3402
+ ]));
3403
+
3404
+ await assertBalances({ member: otherMember }, [
3405
+ {
3406
+ type: BalanceItemType.Registration,
3407
+ registrationId: registration1.id,
3408
+ amount: 1,
3409
+ price: 25_00,
3410
+ status: BalanceItemStatus.Due,
3411
+ priceOpen: 25_00,
3412
+ pricePending: 0,
3413
+ userId: user.id,
3414
+ },
3415
+ {
3416
+ type: BalanceItemType.RegistrationBundleDiscount,
3417
+ registrationId: registration1.id,
3418
+ amount: 1,
3419
+ price: -5_00,
3420
+ status: BalanceItemStatus.Due,
3421
+ priceOpen: -5_00,
3422
+ pricePending: 0,
3423
+ userId: null,
3424
+ },
3425
+ ]);
3426
+
3427
+ await assertBalances({ member }, [
3428
+ {
3429
+ type: BalanceItemType.Registration,
3430
+ registrationId: registration2.id,
3431
+ amount: 1,
3432
+ price: 15_00,
3433
+ status: BalanceItemStatus.Due,
3434
+ priceOpen: 15_00,
3435
+ pricePending: 0,
3436
+ userId: null,
3437
+ },
3438
+ ]);
3439
+ });
3440
+
3441
+ // Test repeats 5 times because it should be stable
3442
+ // This tests real edge cases that were fixed
3443
+ test.each([1, 2, 3, 4, 5])('If discounts are the same, they are not moved when the one without discount is edited (%i th try)', async () => {
3444
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
3445
+ const { adminToken } = await initAdmin({ organization });
3446
+
3447
+ const bundleDiscount = await initBundleDiscount({
3448
+ organizationRegistrationPeriod,
3449
+ discount: {
3450
+ discounts: [
3451
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3452
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3453
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3454
+ ],
3455
+ },
3456
+ });
3457
+
3458
+ const groups = [
3459
+ await new GroupFactory({
3460
+ organization,
3461
+ price: 25_00, // 20% discount = 5_00
3462
+ bundleDiscount,
3463
+ }).create(),
3464
+ await new GroupFactory({
3465
+ organization,
3466
+ price: 25_00, // 20% discount = 5_00 (same as group 1)
3467
+ bundleDiscount,
3468
+ }).create(),
3469
+ await new GroupFactory({
3470
+ organization,
3471
+ price: 25_00, // 20% discount = 5_00 (same as groups 1 & 2)
3472
+ bundleDiscount,
3473
+ }).create(),
3474
+ ];
3475
+
3476
+ // Create existing registrations for group 1 & 2 with discounts already applied
3477
+ const registration1 = await new RegistrationFactory({
3478
+ organization,
3479
+ member,
3480
+ group: groups[0],
3481
+ }).create();
3482
+
3483
+ const registration2 = await new RegistrationFactory({
3484
+ organization,
3485
+ member,
3486
+ group: groups[1],
3487
+ }).create();
3488
+
3489
+ const registration3 = await new RegistrationFactory({
3490
+ organization,
3491
+ member,
3492
+ group: groups[2],
3493
+ }).create();
3494
+
3495
+ // Create balance items for existing registrations
3496
+ await new BalanceItemFactory({
3497
+ userId: user.id,
3498
+ memberId: member.id,
3499
+ organizationId: organization.id,
3500
+ registrationId: registration1.id,
3501
+ type: BalanceItemType.Registration,
3502
+ amount: 1,
3503
+ unitPrice: 25_00,
3504
+ status: BalanceItemStatus.Due,
3505
+ }).create();
3506
+
3507
+ await new BalanceItemFactory({
3508
+ userId: user.id,
3509
+ memberId: member.id,
3510
+ organizationId: organization.id,
3511
+ registrationId: registration1.id,
3512
+ type: BalanceItemType.RegistrationBundleDiscount,
3513
+ amount: 1,
3514
+ unitPrice: -5_00,
3515
+ status: BalanceItemStatus.Due,
3516
+ relations: new Map([
3517
+ [
3518
+ BalanceItemRelationType.Discount,
3519
+ BalanceItemRelation.create({
3520
+ id: bundleDiscount.id,
3521
+ name: bundleDiscount.name,
3522
+ }),
3523
+ ],
3524
+ ]),
3525
+ }).create();
3526
+
3527
+ await new BalanceItemFactory({
3528
+ userId: user.id,
3529
+ memberId: member.id,
3530
+ organizationId: organization.id,
3531
+ registrationId: registration2.id,
3532
+ type: BalanceItemType.Registration,
3533
+ amount: 1,
3534
+ unitPrice: 25_00,
3535
+ status: BalanceItemStatus.Due,
3536
+ }).create();
3537
+
3538
+ // No discount on 2 ( = first)
3539
+
3540
+ // Discount on third
3541
+ await new BalanceItemFactory({
3542
+ userId: user.id,
3543
+ memberId: member.id,
3544
+ organizationId: organization.id,
3545
+ registrationId: registration3.id,
3546
+ type: BalanceItemType.Registration,
3547
+ amount: 1,
3548
+ unitPrice: 25_00,
3549
+ status: BalanceItemStatus.Due,
3550
+ }).create();
3551
+
3552
+ await new BalanceItemFactory({
3553
+ userId: user.id,
3554
+ memberId: member.id,
3555
+ organizationId: organization.id,
3556
+ registrationId: registration3.id,
3557
+ type: BalanceItemType.RegistrationBundleDiscount,
3558
+ amount: 1,
3559
+ unitPrice: -5_00,
3560
+ status: BalanceItemStatus.Due,
3561
+ relations: new Map([
3562
+ [
3563
+ BalanceItemRelationType.Discount,
3564
+ BalanceItemRelation.create({
3565
+ id: bundleDiscount.id,
3566
+ name: bundleDiscount.name,
3567
+ }),
3568
+ ],
3569
+ ]),
3570
+ }).create();
3571
+
3572
+ await BalanceItemService.flushCaches(organization.id);
3573
+ await registration1.refresh();
3574
+ await registration2.refresh();
3575
+ await registration3.refresh();
3576
+
3577
+ // Check this is what we expect
3578
+ expect(registration2.discounts).toMatchMap(new Map([]));
3579
+ expect(registration1.discounts).toMatchMap(new Map([
3580
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3581
+ name: bundleDiscount.name,
3582
+ amount: 5_00,
3583
+ })],
3584
+ ]));
3585
+ expect(registration3.discounts).toMatchMap(new Map([
3586
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3587
+ name: bundleDiscount.name,
3588
+ amount: 5_00,
3589
+ })],
3590
+ ]));
3591
+
3592
+ // Now alter the third registration (the one which didn't had a discount)
3593
+ const checkout = IDRegisterCheckout.create({
3594
+ cart: IDRegisterCart.create({
3595
+ items: [
3596
+ IDRegisterItem.create({
3597
+ groupPrice: groups[1].settings.prices[0],
3598
+ groupId: groups[1].id,
3599
+ organizationId: organization.id,
3600
+ memberId: member.id,
3601
+ replaceRegistrationIds: [registration2.id],
3602
+ }),
3603
+ ],
3604
+ }),
3605
+ totalPrice: 0, // no balance change
3606
+ asOrganizationId: organization.id,
3607
+ });
3608
+
3609
+ const response = await post(checkout, organization, adminToken);
3610
+ expect(response.body.registrations.length).toBe(1);
3611
+ const registration2Struct = response.body.registrations[0];
3612
+
3613
+ expect(registration2Struct).toMatchObject({
3614
+ id: registration2.id,
3615
+ registeredAt: expect.any(Date),
3616
+ discounts: new Map([]),
3617
+ });
3618
+
3619
+ // Verify that existing registrations still have their original discounts
3620
+ await registration1.refresh();
3621
+ await registration2.refresh();
3622
+ await registration3.refresh();
3623
+
3624
+ expect(registration2.discounts).toMatchMap(new Map([]));
3625
+ expect(registration1.discounts).toMatchMap(new Map([
3626
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3627
+ name: bundleDiscount.name,
3628
+ amount: 5_00,
3629
+ })],
3630
+ ]));
3631
+ expect(registration3.discounts).toMatchMap(new Map([
3632
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3633
+ name: bundleDiscount.name,
3634
+ amount: 5_00,
3635
+ })],
3636
+ ]));
3637
+ });
3638
+
3639
+ // Test repeats 5 times because it should be stable
3640
+ // This tests real edge cases that were fixed
3641
+ test.each([1, 2, 3, 4, 5])('If discounts are the same, they are not moved when the one with discount is edited (%i th try)', async () => {
3642
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
3643
+ const { adminToken } = await initAdmin({ organization });
3644
+
3645
+ const bundleDiscount = await initBundleDiscount({
3646
+ organizationRegistrationPeriod,
3647
+ discount: {
3648
+ discounts: [
3649
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3650
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3651
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3652
+ ],
3653
+ },
3654
+ });
3655
+
3656
+ const groups = [
3657
+ await new GroupFactory({
3658
+ organization,
3659
+ price: 25_00, // 20% discount = 5_00
3660
+ bundleDiscount,
3661
+ }).create(),
3662
+ await new GroupFactory({
3663
+ organization,
3664
+ price: 25_00, // 20% discount = 5_00 (same as group 1)
3665
+ bundleDiscount,
3666
+ }).create(),
3667
+ await new GroupFactory({
3668
+ organization,
3669
+ price: 25_00, // 20% discount = 5_00 (same as groups 1 & 2)
3670
+ bundleDiscount,
3671
+ }).create(),
3672
+ ];
3673
+
3674
+ // Create existing registrations for group 1 & 2 with discounts already applied
3675
+ const registration1 = await new RegistrationFactory({
3676
+ organization,
3677
+ member,
3678
+ group: groups[0],
3679
+ }).create();
3680
+
3681
+ const registration2 = await new RegistrationFactory({
3682
+ organization,
3683
+ member,
3684
+ group: groups[1],
3685
+ }).create();
3686
+
3687
+ const registration3 = await new RegistrationFactory({
3688
+ organization,
3689
+ member,
3690
+ group: groups[2],
3691
+ }).create();
3692
+
3693
+ // Create balance items for existing registrations
3694
+ await new BalanceItemFactory({
3695
+ userId: user.id,
3696
+ memberId: member.id,
3697
+ organizationId: organization.id,
3698
+ registrationId: registration1.id,
3699
+ type: BalanceItemType.Registration,
3700
+ amount: 1,
3701
+ unitPrice: 25_00,
3702
+ status: BalanceItemStatus.Due,
3703
+ }).create();
3704
+
3705
+ await new BalanceItemFactory({
3706
+ userId: user.id,
3707
+ memberId: member.id,
3708
+ organizationId: organization.id,
3709
+ registrationId: registration1.id,
3710
+ type: BalanceItemType.RegistrationBundleDiscount,
3711
+ amount: 1,
3712
+ unitPrice: -5_00,
3713
+ status: BalanceItemStatus.Due,
3714
+ relations: new Map([
3715
+ [
3716
+ BalanceItemRelationType.Discount,
3717
+ BalanceItemRelation.create({
3718
+ id: bundleDiscount.id,
3719
+ name: bundleDiscount.name,
3720
+ }),
3721
+ ],
3722
+ ]),
3723
+ }).create();
3724
+
3725
+ await new BalanceItemFactory({
3726
+ userId: user.id,
3727
+ memberId: member.id,
3728
+ organizationId: organization.id,
3729
+ registrationId: registration2.id,
3730
+ type: BalanceItemType.Registration,
3731
+ amount: 1,
3732
+ unitPrice: 25_00,
3733
+ status: BalanceItemStatus.Due,
3734
+ }).create();
3735
+
3736
+ // No discount on 2 ( = first)
3737
+
3738
+ // Discount on third
3739
+ await new BalanceItemFactory({
3740
+ userId: user.id,
3741
+ memberId: member.id,
3742
+ organizationId: organization.id,
3743
+ registrationId: registration3.id,
3744
+ type: BalanceItemType.Registration,
3745
+ amount: 1,
3746
+ unitPrice: 25_00,
3747
+ status: BalanceItemStatus.Due,
3748
+ }).create();
3749
+
3750
+ await new BalanceItemFactory({
3751
+ userId: user.id,
3752
+ memberId: member.id,
3753
+ organizationId: organization.id,
3754
+ registrationId: registration3.id,
3755
+ type: BalanceItemType.RegistrationBundleDiscount,
3756
+ amount: 1,
3757
+ unitPrice: -5_00,
3758
+ status: BalanceItemStatus.Due,
3759
+ relations: new Map([
3760
+ [
3761
+ BalanceItemRelationType.Discount,
3762
+ BalanceItemRelation.create({
3763
+ id: bundleDiscount.id,
3764
+ name: bundleDiscount.name,
3765
+ }),
3766
+ ],
3767
+ ]),
3768
+ }).create();
3769
+
3770
+ await BalanceItemService.flushCaches(organization.id);
3771
+ await registration1.refresh();
3772
+ await registration2.refresh();
3773
+ await registration3.refresh();
3774
+
3775
+ // Check this is what we expect
3776
+ expect(registration2.discounts).toMatchMap(new Map([]));
3777
+ expect(registration1.discounts).toMatchMap(new Map([
3778
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3779
+ name: bundleDiscount.name,
3780
+ amount: 5_00,
3781
+ })],
3782
+ ]));
3783
+ expect(registration3.discounts).toMatchMap(new Map([
3784
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3785
+ name: bundleDiscount.name,
3786
+ amount: 5_00,
3787
+ })],
3788
+ ]));
3789
+
3790
+ // Now alter the third registration (the one which didn't had a discount)
3791
+ const checkout = IDRegisterCheckout.create({
3792
+ cart: IDRegisterCart.create({
3793
+ items: [
3794
+ IDRegisterItem.create({
3795
+ groupPrice: groups[0].settings.prices[0],
3796
+ groupId: groups[0].id,
3797
+ organizationId: organization.id,
3798
+ memberId: member.id,
3799
+ replaceRegistrationIds: [registration1.id],
3800
+ }),
3801
+ ],
3802
+ }),
3803
+ totalPrice: 0, // no balance change
3804
+ asOrganizationId: organization.id,
3805
+ });
3806
+
3807
+ const response = await post(checkout, organization, adminToken);
3808
+ expect(response.body.registrations.length).toBe(1);
3809
+ const registration1Struct = response.body.registrations[0];
3810
+
3811
+ expect(registration1Struct).toMatchObject({
3812
+ id: registration1.id,
3813
+ registeredAt: expect.any(Date),
3814
+ discounts: new Map([
3815
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3816
+ name: bundleDiscount.name,
3817
+ amount: 5_00,
3818
+ })],
3819
+ ]),
3820
+ });
3821
+
3822
+ // Verify that existing registrations still have their original discounts
3823
+ await registration1.refresh();
3824
+ await registration2.refresh();
3825
+ await registration3.refresh();
3826
+
3827
+ expect(registration2.discounts).toMatchMap(new Map([]));
3828
+ expect(registration1.discounts).toMatchMap(new Map([
3829
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3830
+ name: bundleDiscount.name,
3831
+ amount: 5_00,
3832
+ })],
3833
+ ]));
3834
+ expect(registration3.discounts).toMatchMap(new Map([
3835
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3836
+ name: bundleDiscount.name,
3837
+ amount: 5_00,
3838
+ })],
3839
+ ]));
3840
+ });
3841
+
3842
+ // Test repeats 5 times because it should be stable
3843
+ // This tests real edge cases that were fixed
3844
+ test.each([1, 2, 3, 4, 5])('If discounts are the same, they are not moved when the one with and one without discount are edited (%i th try)', async () => {
3845
+ const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
3846
+ const { adminToken } = await initAdmin({ organization });
3847
+
3848
+ const bundleDiscount = await initBundleDiscount({
3849
+ organizationRegistrationPeriod,
3850
+ discount: {
3851
+ discounts: [
3852
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3853
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3854
+ { value: 20_00, type: GroupPriceDiscountType.Percentage },
3855
+ ],
3856
+ },
3857
+ });
3858
+
3859
+ const groups = [
3860
+ await new GroupFactory({
3861
+ organization,
3862
+ price: 25_00, // 20% discount = 5_00
3863
+ bundleDiscount,
3864
+ }).create(),
3865
+ await new GroupFactory({
3866
+ organization,
3867
+ price: 25_00, // 20% discount = 5_00 (same as group 1)
3868
+ bundleDiscount,
3869
+ }).create(),
3870
+ await new GroupFactory({
3871
+ organization,
3872
+ price: 25_00, // 20% discount = 5_00 (same as groups 1 & 2)
3873
+ bundleDiscount,
3874
+ }).create(),
3875
+ ];
3876
+
3877
+ // Create existing registrations for group 1 & 2 with discounts already applied
3878
+ const registration1 = await new RegistrationFactory({
3879
+ organization,
3880
+ member,
3881
+ group: groups[0],
3882
+ }).create();
3883
+
3884
+ const registration2 = await new RegistrationFactory({
3885
+ organization,
3886
+ member,
3887
+ group: groups[1],
3888
+ }).create();
3889
+
3890
+ const registration3 = await new RegistrationFactory({
3891
+ organization,
3892
+ member,
3893
+ group: groups[2],
3894
+ }).create();
3895
+
3896
+ // Create balance items for existing registrations
3897
+ await new BalanceItemFactory({
3898
+ userId: user.id,
3899
+ memberId: member.id,
3900
+ organizationId: organization.id,
3901
+ registrationId: registration1.id,
3902
+ type: BalanceItemType.Registration,
3903
+ amount: 1,
3904
+ unitPrice: 25_00,
3905
+ status: BalanceItemStatus.Due,
3906
+ }).create();
3907
+
3908
+ await new BalanceItemFactory({
3909
+ userId: user.id,
3910
+ memberId: member.id,
3911
+ organizationId: organization.id,
3912
+ registrationId: registration1.id,
3913
+ type: BalanceItemType.RegistrationBundleDiscount,
3914
+ amount: 1,
3915
+ unitPrice: -5_00,
3916
+ status: BalanceItemStatus.Due,
3917
+ relations: new Map([
3918
+ [
3919
+ BalanceItemRelationType.Discount,
3920
+ BalanceItemRelation.create({
3921
+ id: bundleDiscount.id,
3922
+ name: bundleDiscount.name,
3923
+ }),
3924
+ ],
3925
+ ]),
3926
+ }).create();
3927
+
3928
+ await new BalanceItemFactory({
3929
+ userId: user.id,
3930
+ memberId: member.id,
3931
+ organizationId: organization.id,
3932
+ registrationId: registration2.id,
3933
+ type: BalanceItemType.Registration,
3934
+ amount: 1,
3935
+ unitPrice: 25_00,
3936
+ status: BalanceItemStatus.Due,
3937
+ }).create();
3938
+
3939
+ // No discount on 2 ( = first)
3940
+
3941
+ // Discount on third
3942
+ await new BalanceItemFactory({
3943
+ userId: user.id,
3944
+ memberId: member.id,
3945
+ organizationId: organization.id,
3946
+ registrationId: registration3.id,
3947
+ type: BalanceItemType.Registration,
3948
+ amount: 1,
3949
+ unitPrice: 25_00,
3950
+ status: BalanceItemStatus.Due,
3951
+ }).create();
3952
+
3953
+ await new BalanceItemFactory({
3954
+ userId: user.id,
3955
+ memberId: member.id,
3956
+ organizationId: organization.id,
3957
+ registrationId: registration3.id,
3958
+ type: BalanceItemType.RegistrationBundleDiscount,
3959
+ amount: 1,
3960
+ unitPrice: -5_00,
3961
+ status: BalanceItemStatus.Due,
3962
+ relations: new Map([
3963
+ [
3964
+ BalanceItemRelationType.Discount,
3965
+ BalanceItemRelation.create({
3966
+ id: bundleDiscount.id,
3967
+ name: bundleDiscount.name,
3968
+ }),
3969
+ ],
3970
+ ]),
3971
+ }).create();
3972
+
3973
+ await BalanceItemService.flushCaches(organization.id);
3974
+ await registration1.refresh();
3975
+ await registration2.refresh();
3976
+ await registration3.refresh();
3977
+
3978
+ // Check this is what we expect
3979
+ expect(registration2.discounts).toMatchMap(new Map([]));
3980
+ expect(registration1.discounts).toMatchMap(new Map([
3981
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3982
+ name: bundleDiscount.name,
3983
+ amount: 5_00,
3984
+ })],
3985
+ ]));
3986
+ expect(registration3.discounts).toMatchMap(new Map([
3987
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
3988
+ name: bundleDiscount.name,
3989
+ amount: 5_00,
3990
+ })],
3991
+ ]));
3992
+
3993
+ // Now alter the third registration (the one which didn't had a discount)
3994
+ const checkout = IDRegisterCheckout.create({
3995
+ cart: IDRegisterCart.create({
3996
+ items: [
3997
+ // Keep the one without last in the cart, since these normally get discount priority
3998
+ IDRegisterItem.create({
3999
+ groupPrice: groups[0].settings.prices[0],
4000
+ groupId: groups[0].id,
4001
+ organizationId: organization.id,
4002
+ memberId: member.id,
4003
+ replaceRegistrationIds: [registration1.id],
4004
+ }),
4005
+ IDRegisterItem.create({
4006
+ groupPrice: groups[1].settings.prices[0],
4007
+ groupId: groups[1].id,
4008
+ organizationId: organization.id,
4009
+ memberId: member.id,
4010
+ replaceRegistrationIds: [registration2.id],
4011
+ }),
4012
+ ],
4013
+ }),
4014
+ totalPrice: 0, // no balance change
4015
+ asOrganizationId: organization.id,
4016
+ });
4017
+
4018
+ const response = await post(checkout, organization, adminToken);
4019
+ expect(response.body.registrations.length).toBe(2);
4020
+
4021
+ // Check array matches
4022
+ expect(response.body.registrations).toIncludeSameMembers([
4023
+ expect.objectContaining({
4024
+ id: registration1.id,
4025
+ registeredAt: expect.any(Date),
4026
+ discounts: new Map([
4027
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
4028
+ name: bundleDiscount.name,
4029
+ amount: 5_00,
4030
+ })],
4031
+ ]),
4032
+ }),
4033
+ expect.objectContaining({
4034
+ id: registration2.id,
4035
+ registeredAt: expect.any(Date),
4036
+ discounts: new Map([]),
4037
+ }),
4038
+ ]);
4039
+
4040
+ // Verify that existing registrations still have their original discounts
4041
+ await registration1.refresh();
4042
+ await registration2.refresh();
4043
+ await registration3.refresh();
4044
+
4045
+ expect(registration2.discounts).toMatchMap(new Map([]));
4046
+ expect(registration1.discounts).toMatchMap(new Map([
4047
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
4048
+ name: bundleDiscount.name,
4049
+ amount: 5_00,
4050
+ })],
4051
+ ]));
4052
+ expect(registration3.discounts).toMatchMap(new Map([
4053
+ [bundleDiscount.id, AppliedRegistrationDiscount.create({
4054
+ name: bundleDiscount.name,
4055
+ amount: 5_00,
4056
+ })],
4057
+ ]));
4058
+ });
4059
+ });
4060
+ });