@stamhoofd/backend 2.83.5 → 2.84.0
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.
- package/index.ts +19 -4
- package/package.json +18 -14
- package/src/crons/amazon-ses.ts +26 -5
- package/src/crons/balance-emails.ts +18 -17
- package/src/email-recipient-loaders/registrations.ts +87 -0
- package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +5 -2
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +40 -40
- package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +28 -22
- package/src/endpoints/global/events/PatchEventsEndpoint.ts +81 -49
- package/src/endpoints/global/files/UploadFile.ts +11 -16
- package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +234 -0
- package/src/endpoints/global/groups/GetGroupsEndpoint.ts +117 -43
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +1054 -0
- package/src/endpoints/global/members/GetMembersEndpoint.ts +163 -141
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -6
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +0 -16
- package/src/endpoints/global/members/helpers/validateGroupFilter.ts +73 -0
- package/src/endpoints/global/registration/GetPaymentRegistrations.ts +1 -2
- package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +43 -0
- package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +1016 -0
- package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +234 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +5 -5
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +474 -554
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +191 -52
- package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +107 -9
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +89 -0
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +9 -6
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +88 -0
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +0 -6
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +10 -6
- package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +10 -25
- package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +0 -5
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +0 -5
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +4 -0
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +44 -19
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +140 -25
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +40 -10
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +2 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +2 -2
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +2 -2
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -2
- package/src/excel-loaders/members.ts +233 -232
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +153 -0
- package/src/helpers/AdminPermissionChecker.ts +65 -37
- package/src/helpers/AuthenticatedStructures.ts +43 -3
- package/src/helpers/Context.ts +29 -1
- package/src/helpers/GlobalHelper.ts +3 -1
- package/src/helpers/GroupedThrottledQueue.test.ts +219 -0
- package/src/helpers/GroupedThrottledQueue.ts +108 -0
- package/src/helpers/LimitedFilteredRequestHelper.ts +26 -1
- package/src/helpers/MemberCharger.ts +0 -5
- package/src/helpers/MembershipCharger.ts +3 -9
- package/src/helpers/OrganizationCharger.ts +0 -5
- package/src/helpers/ThrottledQueue.test.ts +194 -0
- package/src/helpers/ThrottledQueue.ts +145 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +44 -1
- package/src/middleware/ContextMiddleware.ts +1 -1
- package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +2 -1
- package/src/seeds/1735577912-update-cached-outstanding-balance-from-items.ts +2 -1
- package/src/services/BalanceItemPaymentService.ts +1 -33
- package/src/services/BalanceItemService.ts +167 -48
- package/src/services/FileSignService.ts +18 -13
- package/src/services/MemberRecordStore.ts +28 -19
- package/src/services/PaymentReallocationService.test.ts +25 -14
- package/src/services/PaymentReallocationService.ts +29 -10
- package/src/services/PaymentService.ts +4 -16
- package/src/services/PlatformMembershipService.ts +8 -4
- package/src/services/RegistrationService.ts +66 -2
- package/src/sql-filters/base-registration-filter-compilers.ts +43 -0
- package/src/sql-filters/groups.ts +67 -0
- package/src/sql-filters/members.ts +33 -58
- package/src/sql-filters/organization-registration-periods.ts +8 -0
- package/src/sql-filters/registration-periods.ts +8 -0
- package/src/sql-filters/registrations.ts +11 -22
- package/src/sql-sorters/groups.ts +24 -0
- package/src/sql-sorters/organization-registration-periods.ts +24 -0
- package/src/sql-sorters/registration-periods.ts +47 -0
- package/src/sql-sorters/registrations.ts +77 -0
- package/tests/actions/patchOrganizationMember.ts +27 -0
- package/tests/actions/patchPaymentStatus.ts +45 -0
- package/tests/actions/patchUserMember.ts +27 -0
- package/tests/assertions/assertBalances.ts +49 -0
- package/tests/e2e/api-rate-limits.test.ts +5 -5
- package/tests/e2e/bundle-discounts.test.ts +4060 -0
- package/tests/e2e/charge-members.test.ts +27 -24
- package/tests/e2e/documents.test.ts +398 -0
- package/tests/e2e/register.test.ts +292 -312
- package/tests/helpers/PayconiqMocker.ts +55 -0
- package/tests/init/index.ts +5 -0
- package/tests/init/initAdmin.ts +14 -0
- package/tests/init/initBundleDiscount.ts +47 -0
- package/tests/init/initPayconiq.ts +9 -0
- package/tests/init/initPlatformAdmin.ts +13 -0
- package/tests/init/initStripe.ts +21 -0
- package/tests/jest.setup.ts +29 -0
- 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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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: '
|
|
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
|
|
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
|
|
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(...
|
|
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.
|
|
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: '
|
|
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 =
|
|
12
|
+
type Query = LimitedFilteredRequest;
|
|
8
13
|
type Body = undefined;
|
|
9
|
-
type ResponseBody = RegistrationPeriodStruct[]
|
|
14
|
+
type ResponseBody = PaginatedResponse<RegistrationPeriodStruct[], LimitedFilteredRequest>;
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
await GetRegistrationPeriodsEndpoint.buildData(request.query),
|
|
34
132
|
);
|
|
35
133
|
}
|
|
36
134
|
}
|
package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts
ADDED
|
@@ -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
|
+
});
|