@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
@@ -4,8 +4,8 @@ import { Decoder } from '@simonbackx/simple-encoding';
4
4
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { Email } from '@stamhoofd/email';
7
- import { BalanceItem, BalanceItemPayment, Group, Member, MemberWithRegistrations, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
8
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItem as BalanceItemStruct, BalanceItemType, IDRegisterCheckout, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PaymentType, PermissionLevel, PlatformFamily, PlatformMember, RegisterItem, RegisterResponse, Version } from '@stamhoofd/structures';
7
+ import { BalanceItem, BalanceItemPayment, CachedBalance, Group, Member, MemberWithRegistrations, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
8
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItem as BalanceItemStruct, BalanceItemType, IDRegisterCheckout, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PaymentType, PermissionLevel, PlatformFamily, PlatformMember, RegisterItem, RegisterResponse, TranslatedString, Version } from '@stamhoofd/structures';
9
9
  import { Formatter } from '@stamhoofd/utility';
10
10
 
11
11
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -99,6 +99,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
99
99
  }
100
100
  }
101
101
 
102
+ // Update balances before we start
103
+ await BalanceItemService.flushCaches(organization.id);
104
+
102
105
  const deleteRegistrationIds = request.body.cart.deleteRegistrationIds;
103
106
  const deleteRegistrationModels = (deleteRegistrationIds.length ? (await Registration.getByIDs(...deleteRegistrationIds)) : []).filter(r => r.organizationId === organization.id);
104
107
 
@@ -117,10 +120,17 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
117
120
  memberBalanceItemsStructs = balanceItemsModels.map(i => i.getStructure());
118
121
  }
119
122
 
120
- const memberIds = Formatter.uniqueArray(
121
- [...request.body.memberIds, ...deleteRegistrationModels.map(i => i.memberId), ...balanceItemsModels.map(i => i.memberId).filter(m => m !== null)],
122
- );
123
- const members = await Member.getBlobByIds(...memberIds);
123
+ let members: MemberWithRegistrations[] = [];
124
+ if (request.body.asOrganizationId) {
125
+ const memberIds = Formatter.uniqueArray(
126
+ [...request.body.memberIds, ...deleteRegistrationModels.map(i => i.memberId), ...balanceItemsModels.map(i => i.memberId).filter(m => m !== null)],
127
+ );
128
+ members = await Member.getBlobByIds(...memberIds);
129
+ }
130
+ else {
131
+ // Load the user family (required to correctly calculate discounts across family members)
132
+ members = await Member.getMembersWithRegistrationForUser(user);
133
+ }
124
134
  const groupIds = request.body.groupIds;
125
135
  const groups = await Group.getByIDs(...groupIds);
126
136
 
@@ -144,17 +154,38 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
144
154
  }
145
155
  }
146
156
 
147
- const blob = await AuthenticatedStructures.membersBlob(members, true);
148
157
  const platformMembers: PlatformMember[] = [];
149
158
 
150
159
  if (request.body.asOrganizationId) {
151
- const _m = PlatformFamily.createSingles(blob, {
152
- platform: await Platform.getSharedStruct(),
153
- contextOrganization: await AuthenticatedStructures.organization(organization),
154
- });
155
- platformMembers.push(..._m);
160
+ const memberIds = Formatter.uniqueArray(
161
+ [...request.body.memberIds, ...deleteRegistrationModels.map(i => i.memberId), ...balanceItemsModels.map(i => i.memberId).filter(m => m !== null)],
162
+ );
163
+
164
+ // todo: optimize performance of this
165
+ // Load family for each member
166
+ // this is required for family based discounts
167
+ const platformStruct = await Platform.getSharedStruct();
168
+ const contextOrganization = await AuthenticatedStructures.organization(organization);
169
+ for (const memberId of memberIds) {
170
+ const familyMembers = (await Member.getFamilyWithRegistrations(memberId));
171
+ members.push(...familyMembers);
172
+ const blob = await AuthenticatedStructures.membersBlob(familyMembers, true);
173
+ const family = PlatformFamily.create(blob, {
174
+ platform: platformStruct,
175
+ contextOrganization,
176
+ });
177
+ const platformMember = family.members.find(m => m.id === memberId);
178
+ if (!platformMember) {
179
+ throw new SimpleError({
180
+ code: 'invalid_data',
181
+ message: 'Something went wrong while configuring the data',
182
+ });
183
+ }
184
+ platformMembers.push(platformMember);
185
+ }
156
186
  }
157
187
  else {
188
+ const blob = await AuthenticatedStructures.membersBlob(members, true);
158
189
  const family = PlatformFamily.create(blob, {
159
190
  platform: await Platform.getSharedStruct(),
160
191
  contextOrganization: await AuthenticatedStructures.organization(organization),
@@ -190,9 +221,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
190
221
  // Validate the cart
191
222
  checkout.validate({ memberBalanceItems: memberBalanceItemsStructs });
192
223
 
193
- // Recalculate the price
194
- checkout.updatePrices();
195
-
196
224
  const totalPrice = checkout.totalPrice;
197
225
 
198
226
  if (totalPrice !== request.body.totalPrice) {
@@ -202,13 +230,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
202
230
  });
203
231
  }
204
232
 
205
- if (totalPrice < 0) {
206
- throw new SimpleError({
207
- code: 'empty_data',
208
- message: $t(`725715e5-b0ac-43c1-adef-dd42b8907327`),
209
- });
210
- }
211
-
212
233
  // Who is going to pay?
213
234
  let whoWillPayNow: 'member' | 'organization' | 'nobody' = 'member'; // if this is set to 'organization', there will also be created separate balance items so the member can pay back the paying organization
214
235
 
@@ -277,7 +298,8 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
277
298
 
278
299
  let reuseRegistration: Registration | null = null;
279
300
 
280
- if (item.replaceRegistrations.length === 1) {
301
+ // For now don't reuse replace registrations - it has too many side effects and not a lot of added value
302
+ if (item.replaceRegistrations.length === 1 && item.replaceRegistrations[0].group.id === item.group.id) {
281
303
  // Try to reuse this specific one
282
304
  reuseRegistration = (await Registration.getByID(item.replaceRegistrations[0].registration.id)) ?? null;
283
305
  }
@@ -286,9 +308,20 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
286
308
 
287
309
  if (!reuseRegistration) {
288
310
  // Otherwise try to reuse a registration in the same period, for the same group that has been deactived for less than 7 days since the start of the new registration
289
- reuseRegistration = existingRegistrations.find(r => r.deactivatedAt !== null && r.deactivatedAt.getTime() > startDate.getTime() - 7 * 24 * 60 * 60 * 1000) ?? null;
311
+ const possibleReuseRegistrations = existingRegistrations.filter(r =>
312
+ r.deactivatedAt !== null
313
+ && r.deactivatedAt.getTime() > startDate.getTime() - 7 * 24 * 60 * 60 * 1000,
314
+ );
315
+
316
+ // Never reuse a registration that has a balance - that means they had a cancellation fee and not all balance items were canceled (we don't want to merge in that state)
317
+ const balances = await CachedBalance.getForObjects(possibleReuseRegistrations.map(r => r.id), null);
318
+
319
+ reuseRegistration = possibleReuseRegistrations.find((r) => {
320
+ const balance = balances.filter(b => b.objectId === r.id).reduce((a, b) => a + b.amountOpen + b.amountPaid + b.amountPending, 0);
321
+ return balance === 0;
322
+ }) ?? null;
290
323
 
291
- if (reuseRegistration && reuseRegistration.startDate && reuseRegistration.startDate < startDate && reuseRegistration.startDate >= group.settings.startDate && !item.trial) {
324
+ if (!item.customStartDate && reuseRegistration && reuseRegistration.startDate && reuseRegistration.startDate < startDate && reuseRegistration.startDate >= group.settings.startDate && !item.trial) {
292
325
  startDate = reuseRegistration.startDate;
293
326
  }
294
327
  }
@@ -370,7 +403,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
370
403
  console.log('Registering members using whoWillPayNow', whoWillPayNow, checkout.paymentMethod, totalPrice);
371
404
 
372
405
  const createdBalanceItems: BalanceItem[] = [];
373
- const unrelatedCreatedBalanceItems: BalanceItem[] = [];
374
406
  const deletedBalanceItems: BalanceItem[] = [];
375
407
  const shouldMarkValid = whoWillPayNow === 'nobody' || checkout.paymentMethod === PaymentMethod.Transfer || checkout.paymentMethod === PaymentMethod.PointOfSale || checkout.paymentMethod === PaymentMethod.Unknown;
376
408
 
@@ -415,12 +447,14 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
415
447
  }
416
448
 
417
449
  // We can alter right away since whoWillPayNow is nobody, and shouldMarkValid will always be true
418
- // Find all balance items of this registration and set them to zero
419
- deletedBalanceItems.push(...(await BalanceItem.deleteForDeletedRegistration(existingRegistration.id, {
420
- cancellationFeePercentage: deleted ? checkout.cancellationFeePercentage : 0,
421
- })));
422
-
423
- // todo: add cancelation fee
450
+ // Find all balance items of this registration and set them to Canceled
451
+ if ((deleted ? checkout.cancellationFeePercentage : 0) !== 100_00) {
452
+ // Only cancel balances if we don't charge 100% cancellation fee
453
+ // Also - this avoid creating a new cancellation fee balance item together with canceling the registration balance item, which is more complicated
454
+ deletedBalanceItems.push(...(await BalanceItem.deleteForDeletedRegistration(existingRegistration.id, {
455
+ cancellationFeePercentage: deleted ? checkout.cancellationFeePercentage : 0,
456
+ })));
457
+ }
424
458
 
425
459
  // Clear the registration
426
460
  let group = groups.find(g => g.id === existingRegistration.groupId);
@@ -436,7 +470,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
436
470
  deactivatedRegistrationGroupIds.push(existingRegistration.groupId);
437
471
  }
438
472
 
439
- async function createBalanceItem({ registration, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: RegistrationWithMemberAndGroup; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
473
+ async function createBalanceItem({ registration, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: { id: string; payingOrganizationId: string | null; memberId: string; trialUntil: Date | null }; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
440
474
  // NOTE: We also need to save zero-price balance items because for online payments, we need to know which registrations to activate after payment
441
475
  if (skipZero === true) {
442
476
  if (unitPrice === 0 || amount === 0) {
@@ -463,7 +497,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
463
497
  }
464
498
  else {
465
499
  balanceItem.memberId = registration.memberId;
466
- balanceItem.userId = user.id;
500
+
501
+ if (!checkout.asOrganizationId) {
502
+ balanceItem.userId = user.id;
503
+ }
467
504
  }
468
505
 
469
506
  balanceItem.status = BalanceItemStatus.Hidden;
@@ -485,7 +522,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
485
522
  // Reserve registration for 30 minutes (if needed)
486
523
  const group = groups.find(g => g.id === registration.groupId);
487
524
 
488
- if (group && group.settings.maxMembers !== null) {
525
+ if (group && group.settings.maxMembers !== null && whoWillPayNow !== 'nobody') {
489
526
  registration.reservedUntil = new Date(new Date().getTime() + 1000 * 60 * 30);
490
527
  }
491
528
 
@@ -501,14 +538,14 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
501
538
  BalanceItemRelationType.Member,
502
539
  BalanceItemRelation.create({
503
540
  id: item.member.id,
504
- name: item.member.patchedMember.name,
541
+ name: new TranslatedString(item.member.patchedMember.name),
505
542
  }),
506
543
  ],
507
544
  [
508
545
  BalanceItemRelationType.Group,
509
546
  BalanceItemRelation.create({
510
547
  id: item.group.id,
511
- name: item.group.settings.name.toString(),
548
+ name: item.group.settings.name,
512
549
  }),
513
550
  ],
514
551
  ];
@@ -518,7 +555,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
518
555
  BalanceItemRelationType.GroupPrice,
519
556
  BalanceItemRelation.create({
520
557
  id: item.groupPrice.id,
521
- name: item.groupPrice.name.toString(),
558
+ name: item.groupPrice.name,
522
559
  }),
523
560
  ]);
524
561
  }
@@ -550,14 +587,106 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
550
587
  BalanceItemRelationType.GroupOptionMenu,
551
588
  BalanceItemRelation.create({
552
589
  id: option.optionMenu.id,
553
- name: option.optionMenu.name,
590
+ name: new TranslatedString(option.optionMenu.name),
554
591
  }),
555
592
  ],
556
593
  [
557
594
  BalanceItemRelationType.GroupOption,
558
595
  BalanceItemRelation.create({
559
596
  id: option.option.id,
560
- name: option.option.name,
597
+ name: new TranslatedString(option.option.name),
598
+ }),
599
+ ],
600
+ ]),
601
+ });
602
+ }
603
+
604
+ // Discounts
605
+ for (const discount of checkout.cart.bundleDiscounts) {
606
+ const discountValue = discount.getTotalFor(item);
607
+
608
+ if (discountValue !== 0) {
609
+ // Base price
610
+ await createBalanceItem({
611
+ registration,
612
+ unitPrice: -discountValue,
613
+ type: BalanceItemType.RegistrationBundleDiscount,
614
+ description: discount.name,
615
+ relations: new Map([
616
+ ...sharedRelations,
617
+ [
618
+ BalanceItemRelationType.Discount,
619
+ BalanceItemRelation.create({
620
+ id: discount.bundle.id,
621
+ name: discount.bundle.name,
622
+ }),
623
+ ],
624
+ ]),
625
+ });
626
+ }
627
+ }
628
+ }
629
+
630
+ // Discounts for existing registrations that have changed
631
+ for (const discount of checkout.cart.bundleDiscounts) {
632
+ const loopMap = new Map(discount.registrations);
633
+
634
+ // We'll just cancel discount balance items for deleted registrations instead of creating a counter balance item
635
+ // for (const deleteRegistration of discount.deleteRegistrations) {
636
+ // loopMap.set(deleteRegistration, 0);
637
+ // }
638
+
639
+ for (const [registration, newDiscountValue] of loopMap) {
640
+ const oldDiscountValue = registration.registration.discounts.get(discount.bundle.id)?.amount ?? 0;
641
+
642
+ // Saving the discount change directly on the registration now is not safe , because it can only be applied after the payment has succeededs)
643
+ // Solution: let the balance item handle it in its 'paid' handlers
644
+ const difference = newDiscountValue - oldDiscountValue;
645
+
646
+ if (difference === 0) {
647
+ continue;
648
+ }
649
+
650
+ // Create balance items
651
+ const sharedRelations: [BalanceItemRelationType, BalanceItemRelation][] = [
652
+ [
653
+ BalanceItemRelationType.Member,
654
+ BalanceItemRelation.create({
655
+ id: registration.member.id,
656
+ name: new TranslatedString(registration.member.patchedMember.name),
657
+ }),
658
+ ],
659
+ [
660
+ BalanceItemRelationType.Group,
661
+ BalanceItemRelation.create({
662
+ id: registration.group.id,
663
+ name: registration.group.settings.name,
664
+ }),
665
+ ],
666
+ ];
667
+
668
+ if (registration.group.settings.prices.length > 1) {
669
+ sharedRelations.push([
670
+ BalanceItemRelationType.GroupPrice,
671
+ BalanceItemRelation.create({
672
+ id: registration.registration.groupPrice.id,
673
+ name: registration.registration.groupPrice.name,
674
+ }),
675
+ ]);
676
+ }
677
+
678
+ await createBalanceItem({
679
+ registration: registration.registration,
680
+ unitPrice: -difference,
681
+ type: BalanceItemType.RegistrationBundleDiscount,
682
+ description: discount.name,
683
+ relations: new Map([
684
+ ...sharedRelations,
685
+ [
686
+ BalanceItemRelationType.Discount,
687
+ BalanceItemRelation.create({
688
+ id: discount.bundle.id,
689
+ name: discount.bundle.name,
561
690
  }),
562
691
  ],
563
692
  ]),
@@ -600,6 +729,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
600
729
  }
601
730
  else {
602
731
  balanceItem.userId = user.id;
732
+
603
733
  // Connect this to the oldest member
604
734
  if (oldestMember) {
605
735
  balanceItem.memberId = oldestMember.id;
@@ -614,7 +744,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
614
744
 
615
745
  if (checkout.cart.balanceItems.length && whoWillPayNow === 'nobody') {
616
746
  throw new SimpleError({
617
- code: 'invalid_data',
747
+ code: 'cannot_pay_balance_items',
618
748
  message: 'Not possible to pay balance items as the organization',
619
749
  statusCode: 400,
620
750
  });
@@ -625,7 +755,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
625
755
 
626
756
  // Delay marking as valid as late as possible so any errors will prevent creating valid balance items
627
757
  // Keep a copy because createdBalanceItems will be altered - and we don't want to mark added items as valid
628
- const markValidList = [...createdBalanceItems, ...unrelatedCreatedBalanceItems];
758
+ const markValidList = [...createdBalanceItems];
629
759
 
630
760
  async function markValidIfNeeded() {
631
761
  if (shouldMarkValid) {
@@ -633,6 +763,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
633
763
  // Mark valid
634
764
  await BalanceItemService.markPaid(balanceItem, payment, organization);
635
765
  }
766
+
767
+ // Flush balance caches so we return an up-to-date balance
768
+ await BalanceItemService.flushRegistrationDiscountsCache();
769
+
770
+ // We'll need to update the returned registrations as their values will have changed by marking the registration as valid
771
+ await Registration.refreshAll(registrations);
636
772
  }
637
773
  }
638
774
 
@@ -654,8 +790,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
654
790
  createdBalanceItems.push(balanceItem);
655
791
  }
656
792
 
657
- // Make sure every price is accurate before creating a payment
658
- await BalanceItem.updateOutstanding([...createdBalanceItems, ...unrelatedCreatedBalanceItems]);
659
793
  try {
660
794
  const response = await this.createPayment({
661
795
  balanceItems: mappedBalanceItems,
@@ -673,17 +807,13 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
673
807
  }
674
808
  finally {
675
809
  // Update cached balance items pending amount (only created balance items, because those are involved in the payment)
676
- await BalanceItem.updateOutstanding(createdBalanceItems);
810
+ await BalanceItemService.updatePaidAndPending(createdBalanceItems);
677
811
  }
678
812
  }
679
813
  else {
680
814
  await markValidIfNeeded();
681
- await BalanceItem.updateOutstanding([...createdBalanceItems, ...unrelatedCreatedBalanceItems]);
682
815
  }
683
816
 
684
- // Reallocate
685
- await BalanceItemService.reallocate([...createdBalanceItems, ...unrelatedCreatedBalanceItems, ...deletedBalanceItems], organization.id);
686
-
687
817
  // Update occupancy
688
818
  for (const group of groups) {
689
819
  if (registrations.some(r => r.groupId === group.id) || deactivatedRegistrationGroupIds.some(id => id === group.id)) {
@@ -692,12 +822,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
692
822
  }
693
823
  }
694
824
 
695
- const updatedMembers = await Member.getBlobByIds(...memberIds);
825
+ const updatedMembers = await Member.getBlobByIds(...members.map(m => m.id));
696
826
 
697
827
  return new Response(RegisterResponse.create({
698
828
  payment: payment ? PaymentStruct.create(payment) : null,
699
829
  members: await AuthenticatedStructures.membersBlob(updatedMembers),
700
- registrations: registrations.map(r => Member.getRegistrationWithMemberStructure(r)),
830
+ registrations: registrations.map(r => Member.getRegistrationWithTinyMemberStructure(r)),
701
831
  paymentUrl,
702
832
  }));
703
833
  }
@@ -748,10 +878,19 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
748
878
  if (totalPrice < 0) {
749
879
  // todo: try to make it non-negative by reducing some balance items
750
880
  throw new SimpleError({
751
- code: 'empty_data',
881
+ code: 'negative_price',
752
882
  message: $t(`725715e5-b0ac-43c1-adef-dd42b8907327`),
753
883
  });
754
884
  }
885
+
886
+ if (totalPrice !== checkout.totalPrice) {
887
+ // Changed!
888
+ throw new SimpleError({
889
+ code: 'changed_price',
890
+ message: $t(`e424d549-2bb8-4103-9a14-ac4063d7d454`, { total: Formatter.price(totalPrice) }),
891
+ });
892
+ }
893
+
755
894
  const payment = new Payment();
756
895
  payment.method = checkout.paymentMethod ?? PaymentMethod.Unknown;
757
896
 
@@ -1,18 +1,24 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { RegistrationPeriod as RegistrationPeriodStruct } from '@stamhoofd/structures';
2
+ import { assertSort, CountFilteredRequest, getSortFilter, LimitedFilteredRequest, RegistrationPeriod as RegistrationPeriodStruct, PaginatedResponse, StamhoofdFilter } from '@stamhoofd/structures';
3
3
 
4
+ import { SimpleError } from '@simonbackx/simple-errors';
4
5
  import { RegistrationPeriod } from '@stamhoofd/models';
6
+ import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
7
+ import { registrationPeriodFilterCompilers } from '../../../sql-filters/registration-periods';
8
+ import { registrationPeriodSorters } from '../../../sql-sorters/registration-periods';
9
+ import { Decoder } from '@simonbackx/simple-encoding';
5
10
 
6
11
  type Params = Record<string, never>;
7
- type Query = undefined;
12
+ type Query = LimitedFilteredRequest;
8
13
  type Body = undefined;
9
- type ResponseBody = RegistrationPeriodStruct[];
14
+ type ResponseBody = PaginatedResponse<RegistrationPeriodStruct[], LimitedFilteredRequest>;
10
15
 
11
- /**
12
- * One endpoint to create, patch and delete members and their registrations and payments
13
- */
16
+ const filterCompilers: SQLFilterDefinitions = registrationPeriodFilterCompilers;
17
+ const sorters: SQLSortDefinitions<RegistrationPeriod> = registrationPeriodSorters;
18
+
19
+ export class GetRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
20
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
14
21
 
15
- export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
16
22
  protected doesMatch(request: Request): [true, Params] | [false] {
17
23
  if (request.method !== 'GET') {
18
24
  return [false];
@@ -26,11 +32,103 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
26
32
  return [false];
27
33
  }
28
34
 
35
+ static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
36
+ const scopeFilter: StamhoofdFilter | undefined = undefined;
37
+ const query = RegistrationPeriod.select();
38
+
39
+ if (scopeFilter) {
40
+ query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
41
+ }
42
+
43
+ if (q.filter) {
44
+ query.where(await compileToSQLFilter(q.filter, filterCompilers));
45
+ }
46
+
47
+ if (q.search) {
48
+ let searchFilter: StamhoofdFilter | null = null;
49
+
50
+ searchFilter = {
51
+ id: q.search,
52
+ };
53
+
54
+ if (searchFilter) {
55
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
56
+ }
57
+ }
58
+
59
+ if (q instanceof LimitedFilteredRequest) {
60
+ if (q.pageFilter) {
61
+ query.where(await compileToSQLFilter(q.pageFilter, filterCompilers));
62
+ }
63
+
64
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
65
+ applySQLSorter(query, q.sort, sorters);
66
+ query.limit(q.limit);
67
+ }
68
+
69
+ return query;
70
+ }
71
+
72
+ static async buildData(requestQuery: LimitedFilteredRequest) {
73
+ const query = await GetRegistrationPeriodsEndpoint.buildQuery(requestQuery);
74
+ const registrationPeriods = await query.fetch();
75
+
76
+ let next: LimitedFilteredRequest | undefined;
77
+
78
+ if (registrationPeriods.length >= requestQuery.limit) {
79
+ const lastObject = registrationPeriods[registrationPeriods.length - 1];
80
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
81
+
82
+ next = new LimitedFilteredRequest({
83
+ filter: requestQuery.filter,
84
+ pageFilter: nextFilter,
85
+ sort: requestQuery.sort,
86
+ limit: requestQuery.limit,
87
+ search: requestQuery.search,
88
+ });
89
+
90
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
91
+ console.error('Found infinite loading loop for', requestQuery);
92
+ next = undefined;
93
+ }
94
+ }
95
+
96
+ return new PaginatedResponse<RegistrationPeriodStruct[], LimitedFilteredRequest>({
97
+ results: registrationPeriods.map(p => p.getStructure()),
98
+ next,
99
+ });
100
+ }
101
+
29
102
  async handle(request: DecodedRequest<Params, Query, Body>) {
30
- const periods = await RegistrationPeriod.all();
103
+ if (request.request.getVersion() < 371) {
104
+ throw new SimpleError({
105
+ code: 'client_update_required',
106
+ statusCode: 400,
107
+ message: 'Er is een noodzakelijke update beschikbaar. Herlaad de pagina en wis indien nodig de cache van jouw browser.',
108
+ human: $t(`adb0e7c8-aed7-43f5-bfcc-a350f03aaabe`),
109
+ });
110
+ }
111
+
112
+ const maxLimit = 100;
113
+
114
+ if (request.query.limit > maxLimit) {
115
+ throw new SimpleError({
116
+ code: 'invalid_field',
117
+ field: 'limit',
118
+ message: 'Limit can not be more than ' + maxLimit,
119
+ });
120
+ }
121
+
122
+ if (request.query.limit < 1) {
123
+ throw new SimpleError({
124
+ code: 'invalid_field',
125
+ field: 'limit',
126
+ message: 'Limit can not be less than 1',
127
+ });
128
+ }
31
129
 
32
130
  return new Response(
33
- periods.map(p => p.getStructure()),
131
+ await GetRegistrationPeriodsEndpoint.buildData(request.query),
34
132
  );
35
133
  }
36
134
  }
@@ -0,0 +1,89 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { EmailTemplate, GroupFactory, Organization, OrganizationFactory, Platform, RegistrationPeriod, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
3
+ import { EmailTemplateType, PermissionLevel, PermissionRoleDetailed, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import { testServer } from '../../../../../tests/helpers/TestServer';
6
+ import { GetEmailTemplatesEndpoint } from './GetEmailTemplatesEndpoint';
7
+
8
+ const baseUrl = `/v${Version}/email-templates`;
9
+
10
+ describe('Endpoint.GetEmailTemplatesEndpoint', () => {
11
+ const endpoint = new GetEmailTemplatesEndpoint();
12
+ let period: RegistrationPeriod;
13
+
14
+ const getEmailTemplates = async ({ token, organization = undefined, types = null, groupIds = null, webshopId = null }: { token: Token; organization?: Organization; types?: EmailTemplateType[] | null; groupIds?: string[] | null; webshopId?: string | null }) => {
15
+ const request = Request.buildJson('GET', baseUrl, organization?.getApiHost());
16
+
17
+ request.query = {
18
+ types,
19
+ groupIds,
20
+ webshopId,
21
+ };
22
+
23
+ request.headers.authorization = 'Bearer ' + token.accessToken;
24
+ return await testServer.test(endpoint, request);
25
+ };
26
+
27
+ beforeEach(async () => {
28
+ TestUtils.setEnvironment('userMode', 'platform');
29
+ });
30
+
31
+ beforeAll(async () => {
32
+ period = await new RegistrationPeriodFactory({
33
+ startDate: new Date(2023, 0, 1),
34
+ endDate: new Date(2023, 11, 31),
35
+ }).create();
36
+ });
37
+
38
+ test('User with platform role who has full permission for all organizations should see templates for organizations', async () => {
39
+ const role = PermissionRoleDetailed.create({
40
+ name: 'Beroepsmedewerker',
41
+ resources: new Map([[PermissionsResourceType.OrganizationTags, new Map([[
42
+ '',
43
+ ResourcePermissions.create({
44
+ level: PermissionLevel.Full,
45
+ }),
46
+ ]])]]),
47
+ });
48
+
49
+ const globalPermissions = Permissions.create({
50
+ level: PermissionLevel.None,
51
+ roles: [
52
+ role,
53
+ ],
54
+ });
55
+
56
+ const platform = await Platform.getForEditing();
57
+ platform.privateConfig.roles.push(role);
58
+ await platform.save();
59
+
60
+ const user = await new UserFactory({
61
+ globalPermissions,
62
+ })
63
+ .create();
64
+
65
+ const organization = await new OrganizationFactory({ period }).create();
66
+ const group = await new GroupFactory({ organization }).create();
67
+
68
+ const token = await Token.createToken(user);
69
+
70
+ const template = new EmailTemplate();
71
+ template.subject = 'test template 1';
72
+ template.type = EmailTemplateType.RegistrationConfirmation;
73
+ template.json = {};
74
+ template.html = 'html test';
75
+ template.text = 'text test';
76
+ template.organizationId = organization.id;
77
+ template.groupId = group.id;
78
+ await template.save();
79
+
80
+ const response = await getEmailTemplates({
81
+ token,
82
+ types: [EmailTemplateType.RegistrationConfirmation],
83
+ groupIds: [group.id],
84
+ });
85
+
86
+ expect(response.body).toBeDefined();
87
+ expect(response.body).toHaveLength(1);
88
+ });
89
+ });