@stamhoofd/backend 2.102.0 → 2.103.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/package.json +10 -10
- package/src/crons.ts +20 -25
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +382 -31
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +45 -21
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -2
- package/src/helpers/AdminPermissionChecker.ts +4 -4
- package/src/helpers/AuthenticatedStructures.ts +12 -9
- package/src/services/PaymentService.ts +3 -0
- package/tests/e2e/bundle-discounts.test.ts +327 -1
- package/tests/helpers/PayconiqMocker.ts +22 -0
- package/tests/init/index.ts +1 -0
- package/tests/init/initAdmin.ts +2 -2
- package/tests/init/initPermissionRole.ts +12 -0
- /package/src/seeds/{1760535589-update-cached-outstanding-balance-from-items.ts → 1760702454-update-cached-outstanding-balance-from-items.ts} +0 -0
|
@@ -67,8 +67,25 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
67
67
|
const organization = await Context.setOrganizationScope();
|
|
68
68
|
const { user } = await Context.authenticate();
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// Who is going to pay?
|
|
71
|
+
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
|
|
72
|
+
|
|
73
|
+
if (request.body.asOrganizationId && request.body.asOrganizationId === organization.id) {
|
|
74
|
+
// Fast fail
|
|
75
|
+
if (!await Context.auth.hasSomeAccess(request.body.asOrganizationId)) {
|
|
76
|
+
throw new SimpleError({
|
|
77
|
+
code: 'forbidden',
|
|
78
|
+
message: 'No permission to register members manually',
|
|
79
|
+
human: $t(`62fe6e39-f6b0-4836-b0f7-dc84d22a81e3`),
|
|
80
|
+
statusCode: 403,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// We won't create a payment. The balance will get added to the outstanding amount of the member / already paying organization
|
|
85
|
+
whoWillPayNow = 'nobody';
|
|
86
|
+
}
|
|
87
|
+
else if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
|
|
88
|
+
if (!await Context.auth.hasFullAccess(request.body.asOrganizationId)) {
|
|
72
89
|
throw new SimpleError({
|
|
73
90
|
code: 'forbidden',
|
|
74
91
|
message: 'No permission to register as this organization for a different organization',
|
|
@@ -76,6 +93,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
76
93
|
statusCode: 403,
|
|
77
94
|
});
|
|
78
95
|
}
|
|
96
|
+
|
|
97
|
+
// The organization will pay to the organizing organization, and it will get added to the outstanding amount of the member towards the paying organization
|
|
98
|
+
whoWillPayNow = 'organization';
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
// For non paid organizations, limit amount of tests
|
|
@@ -145,7 +165,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
145
165
|
}
|
|
146
166
|
|
|
147
167
|
for (const member of members) {
|
|
148
|
-
if (!await Context.auth.canAccessMember(
|
|
168
|
+
if (!await Context.auth.canAccessMember(
|
|
169
|
+
member,
|
|
170
|
+
request.body.asOrganizationId ? PermissionLevel.Read : PermissionLevel.Write,
|
|
171
|
+
)) {
|
|
149
172
|
throw new SimpleError({
|
|
150
173
|
code: 'forbidden',
|
|
151
174
|
message: 'No permission to register this member',
|
|
@@ -170,7 +193,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
170
193
|
for (const memberId of memberIds) {
|
|
171
194
|
const familyMembers = (await Member.getFamilyWithRegistrations(memberId));
|
|
172
195
|
members.push(...familyMembers);
|
|
173
|
-
const blob = await AuthenticatedStructures.membersBlob(familyMembers, true);
|
|
196
|
+
const blob = await AuthenticatedStructures.membersBlob(familyMembers, true, undefined, { forAdminCartCalculation: true });
|
|
174
197
|
const family = PlatformFamily.create(blob, {
|
|
175
198
|
platform: platformStruct,
|
|
176
199
|
contextOrganization,
|
|
@@ -225,22 +248,18 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
225
248
|
const totalPrice = checkout.totalPrice;
|
|
226
249
|
|
|
227
250
|
if (totalPrice !== request.body.totalPrice) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
else if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
|
|
242
|
-
// The organization will pay to the organizing organization, and it will get added to the outstanding amount of the member towards the paying organization
|
|
243
|
-
whoWillPayNow = 'organization';
|
|
251
|
+
if (whoWillPayNow === 'nobody') {
|
|
252
|
+
// Safe and important to ignore: we are only updating the outstanding amounts
|
|
253
|
+
// If we would throw here, that could leak personal data (e.g. that the user uses financial support)
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
// when whoWillPay = organization/member, we should throw or the payment amount could be different / incorrect.
|
|
257
|
+
// This never leaks information because in this case the user already has full access to the organization (asOrganizationId) or the member
|
|
258
|
+
throw new SimpleError({
|
|
259
|
+
code: 'changed_price',
|
|
260
|
+
message: $t(`e424d549-2bb8-4103-9a14-ac4063d7d454`, { total: Formatter.price(totalPrice) }),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
244
263
|
}
|
|
245
264
|
|
|
246
265
|
const registrationMemberRelation = new ManyToOneRelation(Member, 'member');
|
|
@@ -763,6 +782,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
763
782
|
}
|
|
764
783
|
|
|
765
784
|
let paymentUrl: string | null = null;
|
|
785
|
+
let paymentQRCode: string | null = null;
|
|
766
786
|
let payment: Payment | null = null;
|
|
767
787
|
|
|
768
788
|
// Delay marking as valid as late as possible so any errors will prevent creating valid balance items
|
|
@@ -814,6 +834,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
814
834
|
|
|
815
835
|
if (response) {
|
|
816
836
|
paymentUrl = response.paymentUrl;
|
|
837
|
+
paymentQRCode = response.paymentQRCode;
|
|
817
838
|
payment = response.payment;
|
|
818
839
|
}
|
|
819
840
|
}
|
|
@@ -841,6 +862,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
841
862
|
members: await AuthenticatedStructures.membersBlob(updatedMembers),
|
|
842
863
|
registrations: registrations.map(r => Member.getRegistrationWithTinyMemberStructure(r)),
|
|
843
864
|
paymentUrl,
|
|
865
|
+
paymentQRCode,
|
|
844
866
|
}));
|
|
845
867
|
}
|
|
846
868
|
|
|
@@ -1047,6 +1069,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
1047
1069
|
const description = $t(`33a926ea-9bc7-444e-becc-c0f2f70e1f0e`) + ' ' + organization.name;
|
|
1048
1070
|
|
|
1049
1071
|
let paymentUrl: string | null = null;
|
|
1072
|
+
let paymentQRCode: string | null = null;
|
|
1050
1073
|
|
|
1051
1074
|
try {
|
|
1052
1075
|
// Update balance items
|
|
@@ -1144,7 +1167,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
1144
1167
|
await dbPayment.save();
|
|
1145
1168
|
}
|
|
1146
1169
|
else if (payment.provider === PaymentProvider.Payconiq) {
|
|
1147
|
-
paymentUrl = await PayconiqPayment.createPayment(payment, organization, description, redirectUrl, webhookUrl);
|
|
1170
|
+
({ paymentUrl, paymentQRCode } = await PayconiqPayment.createPayment(payment, organization, description, redirectUrl, webhookUrl));
|
|
1148
1171
|
}
|
|
1149
1172
|
else if (payment.provider == PaymentProvider.Buckaroo) {
|
|
1150
1173
|
// Increase request timeout because buckaroo is super slow (in development)
|
|
@@ -1175,6 +1198,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
1175
1198
|
provider,
|
|
1176
1199
|
stripeAccount,
|
|
1177
1200
|
paymentUrl,
|
|
1201
|
+
paymentQRCode,
|
|
1178
1202
|
};
|
|
1179
1203
|
}
|
|
1180
1204
|
}
|
|
@@ -224,7 +224,8 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
224
224
|
balanceItemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem));
|
|
225
225
|
|
|
226
226
|
let paymentUrl: string | null = null;
|
|
227
|
-
|
|
227
|
+
let paymentQRCode: string | null = null;
|
|
228
|
+
const description = webshop.meta.name + ' - ' + organization.name;
|
|
228
229
|
|
|
229
230
|
if (payment.method === PaymentMethod.Transfer) {
|
|
230
231
|
await order.markValid(payment, []);
|
|
@@ -324,7 +325,7 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
324
325
|
await dbPayment.save();
|
|
325
326
|
}
|
|
326
327
|
else if (payment.provider == PaymentProvider.Payconiq) {
|
|
327
|
-
paymentUrl = await PayconiqPayment.createPayment(payment, organization, description, redirectUrl, exchangeUrl);
|
|
328
|
+
({ paymentUrl, paymentQRCode } = await PayconiqPayment.createPayment(payment, organization, description, redirectUrl, exchangeUrl));
|
|
328
329
|
}
|
|
329
330
|
else if (payment.provider == PaymentProvider.Buckaroo) {
|
|
330
331
|
// Increase request timeout because buckaroo is super slow
|
|
@@ -351,6 +352,7 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
351
352
|
|
|
352
353
|
return new Response(OrderResponse.create({
|
|
353
354
|
paymentUrl: paymentUrl,
|
|
355
|
+
paymentQRCode,
|
|
354
356
|
order: OrderStruct.create({ ...order, payment: PaymentStruct.create(payment) }),
|
|
355
357
|
}));
|
|
356
358
|
}
|
|
@@ -424,7 +424,7 @@ export class AdminPermissionChecker {
|
|
|
424
424
|
/**
|
|
425
425
|
* Note: only checks admin permissions. Users that 'own' this member can also access it but that does not use the AdminPermissionChecker
|
|
426
426
|
*/
|
|
427
|
-
async canAccessRegistration(registration: Registration, permissionLevel: PermissionLevel = PermissionLevel.Read, checkMember = true) {
|
|
427
|
+
async canAccessRegistration(registration: Registration, permissionLevel: PermissionLevel = PermissionLevel.Read, checkMember: boolean | MemberWithRegistrations = true) {
|
|
428
428
|
if (registration.deactivatedAt || !registration.registeredAt) {
|
|
429
429
|
// No full access: cannot access deactivated registrations
|
|
430
430
|
return false;
|
|
@@ -461,7 +461,7 @@ export class AdminPermissionChecker {
|
|
|
461
461
|
|
|
462
462
|
if (permissionLevel === PermissionLevel.Read && checkMember && group.settings.implicitlyAllowViewRegistrations) {
|
|
463
463
|
// We can also view this registration if we have access to the member
|
|
464
|
-
const members = await Member.getBlobByIds(registration.memberId);
|
|
464
|
+
const members = checkMember === true ? await Member.getBlobByIds(registration.memberId) : [checkMember];
|
|
465
465
|
|
|
466
466
|
if (members.length === 1) {
|
|
467
467
|
if (await this.canAccessMember(members[0], permissionLevel)) {
|
|
@@ -1416,7 +1416,7 @@ export class AdminPermissionChecker {
|
|
|
1416
1416
|
/**
|
|
1417
1417
|
* Changes data inline
|
|
1418
1418
|
*/
|
|
1419
|
-
async filterMemberData(member: MemberWithRegistrations, data: MemberWithRegistrationsBlob): Promise<MemberWithRegistrationsBlob> {
|
|
1419
|
+
async filterMemberData(member: MemberWithRegistrations, data: MemberWithRegistrationsBlob, options?: { forAdminCartCalculation?: boolean }): Promise<MemberWithRegistrationsBlob> {
|
|
1420
1420
|
const cloned = data.clone();
|
|
1421
1421
|
|
|
1422
1422
|
for (const [key, value] of cloned.details.recordAnswers.entries()) {
|
|
@@ -1444,7 +1444,7 @@ export class AdminPermissionChecker {
|
|
|
1444
1444
|
}
|
|
1445
1445
|
|
|
1446
1446
|
// Has financial read access?
|
|
1447
|
-
if (!await this.hasFinancialMemberAccess(member, PermissionLevel.Read)) {
|
|
1447
|
+
if (!options?.forAdminCartCalculation && !await this.hasFinancialMemberAccess(member, PermissionLevel.Read)) {
|
|
1448
1448
|
cloned.details.requiresFinancialSupport = null;
|
|
1449
1449
|
cloned.details.uitpasNumber = null;
|
|
1450
1450
|
cloned.outstandingBalance = 0;
|
|
@@ -388,7 +388,7 @@ export class AuthenticatedStructures {
|
|
|
388
388
|
return (await this.membersBlob(members, false)).members;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
static async membersBlob(members: MemberWithRegistrations[], includeContextOrganization = false, includeUser?: User): Promise<MembersBlob> {
|
|
391
|
+
static async membersBlob(members: MemberWithRegistrations[], includeContextOrganization = false, includeUser?: User, options?: { forAdminCartCalculation?: boolean }): Promise<MembersBlob> {
|
|
392
392
|
if (members.length === 0 && !includeUser) {
|
|
393
393
|
return MembersBlob.create({ members: [], organizations: [] });
|
|
394
394
|
}
|
|
@@ -459,6 +459,7 @@ export class AuthenticatedStructures {
|
|
|
459
459
|
const filtered: (Registration & {
|
|
460
460
|
group: Group;
|
|
461
461
|
})[] = [];
|
|
462
|
+
const userManager = Context.auth.isUserManager(member);
|
|
462
463
|
for (const registration of member.registrations) {
|
|
463
464
|
if (includeContextOrganization || registration.organizationId !== Context.auth.organization?.id) {
|
|
464
465
|
const found = organizations.get(registration.id);
|
|
@@ -468,16 +469,18 @@ export class AuthenticatedStructures {
|
|
|
468
469
|
}
|
|
469
470
|
}
|
|
470
471
|
if (organizations.get(registration.organizationId)?.active || (Context.auth.organization && Context.auth.organization.active && registration.organizationId === Context.auth.organization.id) || await Context.auth.hasFullAccess(registration.organizationId)) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
||
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
472
|
+
if (
|
|
473
|
+
!!options?.forAdminCartCalculation
|
|
474
|
+
|| registration.group.settings.implicitlyAllowViewRegistrations
|
|
475
|
+
|| userManager
|
|
476
|
+
|| await Context.auth.canAccessRegistration(registration, PermissionLevel.Read, member)
|
|
477
|
+
) {
|
|
478
|
+
filtered.push(registration);
|
|
479
|
+
}
|
|
477
480
|
}
|
|
478
481
|
}
|
|
479
482
|
member.registrations = filtered;
|
|
480
|
-
const balancesPermission = await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id);
|
|
483
|
+
const balancesPermission = (!!options?.forAdminCartCalculation) || await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id);
|
|
481
484
|
|
|
482
485
|
let memberBalances: GenericBalance[] = [];
|
|
483
486
|
|
|
@@ -512,7 +515,7 @@ export class AuthenticatedStructures {
|
|
|
512
515
|
});
|
|
513
516
|
|
|
514
517
|
memberBlobs.push(
|
|
515
|
-
await Context.auth.filterMemberData(member, blob),
|
|
518
|
+
await Context.auth.filterMemberData(member, blob, { forAdminCartCalculation: options?.forAdminCartCalculation ?? false }),
|
|
516
519
|
);
|
|
517
520
|
}
|
|
518
521
|
|
|
@@ -260,6 +260,9 @@ export const PaymentService = {
|
|
|
260
260
|
if (await payconiqPayment.cancel(organization)) {
|
|
261
261
|
status = PaymentStatus.Failed;
|
|
262
262
|
}
|
|
263
|
+
else {
|
|
264
|
+
console.error('Failed to manually cancel payment');
|
|
265
|
+
}
|
|
263
266
|
}
|
|
264
267
|
|
|
265
268
|
if (this.isManualExpired(status, payment)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
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';
|
|
3
|
+
import { AccessRight, AppliedRegistrationDiscount, BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BooleanStatus, GroupPriceDiscount, GroupPriceDiscountType, IDRegisterCart, IDRegisterCheckout, IDRegisterItem, PaymentMethod, PermissionLevel, Permissions, PermissionsResourceType, ReduceablePrice, ResourcePermissions } from '@stamhoofd/structures';
|
|
4
4
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
5
5
|
import { RegisterMembersEndpoint } from '../../src/endpoints/global/registration/RegisterMembersEndpoint';
|
|
6
6
|
import { assertBalances } from '../assertions/assertBalances';
|
|
@@ -9,6 +9,7 @@ import { initBundleDiscount } from '../init/initBundleDiscount';
|
|
|
9
9
|
import { initStripe } from '../init/initStripe';
|
|
10
10
|
import { initAdmin } from '../init/initAdmin';
|
|
11
11
|
import { BalanceItemService } from '../../src/services/BalanceItemService';
|
|
12
|
+
import { initPermissionRole } from '../init';
|
|
12
13
|
|
|
13
14
|
const baseUrl = `/members/register`;
|
|
14
15
|
|
|
@@ -3388,10 +3389,335 @@ describe('E2E.Bundle Discounts', () => {
|
|
|
3388
3389
|
const response2 = await post(checkout, organization, adminToken);
|
|
3389
3390
|
expect(response2.body.registrations.length).toBe(1);
|
|
3390
3391
|
|
|
3392
|
+
const registration2 = response2.body.registrations[0];
|
|
3393
|
+
expect(registration2.registeredAt).not.toBeNull();
|
|
3394
|
+
expect(registration2.discounts).toMatchMap(new Map()); // note: not super reliable, as this is permission checked
|
|
3395
|
+
|
|
3396
|
+
const registration2Model = (await Registration.getByID(registration2.id))!;
|
|
3397
|
+
expect(registration2).toBeDefined();
|
|
3398
|
+
expect(registration2Model.discounts).toMatchMap(new Map());
|
|
3399
|
+
|
|
3400
|
+
// Get registration 1 again, it should now have the bundle discount applied
|
|
3401
|
+
await registration1.refresh();
|
|
3402
|
+
expect(registration1.discounts).toMatchMap(new Map([
|
|
3403
|
+
[bundleDiscount.id, AppliedRegistrationDiscount.create({
|
|
3404
|
+
name: bundleDiscount.name,
|
|
3405
|
+
amount: 5_00,
|
|
3406
|
+
})],
|
|
3407
|
+
]));
|
|
3408
|
+
|
|
3409
|
+
await assertBalances({ member: otherMember }, [
|
|
3410
|
+
{
|
|
3411
|
+
type: BalanceItemType.Registration,
|
|
3412
|
+
registrationId: registration1.id,
|
|
3413
|
+
amount: 1,
|
|
3414
|
+
price: 25_00,
|
|
3415
|
+
status: BalanceItemStatus.Due,
|
|
3416
|
+
priceOpen: 25_00,
|
|
3417
|
+
pricePending: 0,
|
|
3418
|
+
userId: user.id,
|
|
3419
|
+
},
|
|
3420
|
+
{
|
|
3421
|
+
type: BalanceItemType.RegistrationBundleDiscount,
|
|
3422
|
+
registrationId: registration1.id,
|
|
3423
|
+
amount: 1,
|
|
3424
|
+
price: -5_00,
|
|
3425
|
+
status: BalanceItemStatus.Due,
|
|
3426
|
+
priceOpen: -5_00,
|
|
3427
|
+
pricePending: 0,
|
|
3428
|
+
userId: null,
|
|
3429
|
+
},
|
|
3430
|
+
]);
|
|
3431
|
+
|
|
3432
|
+
await assertBalances({ member }, [
|
|
3433
|
+
{
|
|
3434
|
+
type: BalanceItemType.Registration,
|
|
3435
|
+
registrationId: registration2.id,
|
|
3436
|
+
amount: 1,
|
|
3437
|
+
price: 15_00,
|
|
3438
|
+
status: BalanceItemStatus.Due,
|
|
3439
|
+
priceOpen: 15_00,
|
|
3440
|
+
pricePending: 0,
|
|
3441
|
+
userId: null,
|
|
3442
|
+
},
|
|
3443
|
+
]);
|
|
3444
|
+
});
|
|
3445
|
+
|
|
3446
|
+
test('Discounts work across family members when admins register members, even when the admin does not have access to the family members', async () => {
|
|
3447
|
+
const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
|
|
3448
|
+
const bundleDiscount = await initBundleDiscount({
|
|
3449
|
+
organizationRegistrationPeriod,
|
|
3450
|
+
discount: {
|
|
3451
|
+
countWholeFamily: true,
|
|
3452
|
+
discounts: [
|
|
3453
|
+
{
|
|
3454
|
+
value: 20_00,
|
|
3455
|
+
type: GroupPriceDiscountType.Percentage,
|
|
3456
|
+
},
|
|
3457
|
+
],
|
|
3458
|
+
},
|
|
3459
|
+
});
|
|
3460
|
+
|
|
3461
|
+
const groups = [
|
|
3462
|
+
await new GroupFactory({
|
|
3463
|
+
organization,
|
|
3464
|
+
price: 25_00,
|
|
3465
|
+
bundleDiscount,
|
|
3466
|
+
}).create(),
|
|
3467
|
+
await new GroupFactory({
|
|
3468
|
+
organization,
|
|
3469
|
+
price: 15_00,
|
|
3470
|
+
bundleDiscount,
|
|
3471
|
+
}).create(),
|
|
3472
|
+
];
|
|
3473
|
+
|
|
3474
|
+
// Create an unrelated group and registration so admin has access to the member
|
|
3475
|
+
const randomGroup = await new GroupFactory({
|
|
3476
|
+
organization,
|
|
3477
|
+
price: 0,
|
|
3478
|
+
}).create();
|
|
3479
|
+
|
|
3480
|
+
await new RegistrationFactory({
|
|
3481
|
+
organization,
|
|
3482
|
+
member: member,
|
|
3483
|
+
group: randomGroup,
|
|
3484
|
+
}).create();
|
|
3485
|
+
|
|
3486
|
+
// Make sure the user has financial access so we can check the responses
|
|
3487
|
+
const role = await initPermissionRole({
|
|
3488
|
+
organization,
|
|
3489
|
+
accessRights: [
|
|
3490
|
+
AccessRight.MemberReadFinancialData,
|
|
3491
|
+
],
|
|
3492
|
+
});
|
|
3493
|
+
|
|
3494
|
+
const { adminToken } = await initAdmin({
|
|
3495
|
+
organization,
|
|
3496
|
+
permissions: Permissions.create({
|
|
3497
|
+
level: PermissionLevel.None,
|
|
3498
|
+
resources: new Map([[
|
|
3499
|
+
PermissionsResourceType.Groups, new Map([
|
|
3500
|
+
[
|
|
3501
|
+
randomGroup.id, ResourcePermissions.create({
|
|
3502
|
+
level: PermissionLevel.Write,
|
|
3503
|
+
}),
|
|
3504
|
+
], [
|
|
3505
|
+
groups[1].id, ResourcePermissions.create({
|
|
3506
|
+
level: PermissionLevel.Write,
|
|
3507
|
+
}),
|
|
3508
|
+
],
|
|
3509
|
+
// No permission for group 0
|
|
3510
|
+
]),
|
|
3511
|
+
]]),
|
|
3512
|
+
roles: [role],
|
|
3513
|
+
}),
|
|
3514
|
+
});
|
|
3515
|
+
|
|
3516
|
+
const otherMember = await new MemberFactory({
|
|
3517
|
+
organization,
|
|
3518
|
+
user,
|
|
3519
|
+
}).create();
|
|
3520
|
+
|
|
3521
|
+
// First register the otherMember for group 1. No discount should be applied yet
|
|
3522
|
+
const registration1 = await new RegistrationFactory({
|
|
3523
|
+
organization,
|
|
3524
|
+
member: otherMember,
|
|
3525
|
+
group: groups[0],
|
|
3526
|
+
}).create();
|
|
3527
|
+
|
|
3528
|
+
await new BalanceItemFactory({
|
|
3529
|
+
userId: user.id,
|
|
3530
|
+
memberId: otherMember.id,
|
|
3531
|
+
organizationId: organization.id,
|
|
3532
|
+
type: BalanceItemType.Registration,
|
|
3533
|
+
amount: 1,
|
|
3534
|
+
unitPrice: 25_00,
|
|
3535
|
+
status: BalanceItemStatus.Due,
|
|
3536
|
+
registrationId: registration1.id,
|
|
3537
|
+
}).create();
|
|
3538
|
+
|
|
3539
|
+
const checkout = IDRegisterCheckout.create({
|
|
3540
|
+
cart: IDRegisterCart.create({
|
|
3541
|
+
items: [
|
|
3542
|
+
IDRegisterItem.create({
|
|
3543
|
+
groupPrice: groups[1].settings.prices[0],
|
|
3544
|
+
groupId: groups[1].id,
|
|
3545
|
+
organizationId: organization.id,
|
|
3546
|
+
memberId: member.id,
|
|
3547
|
+
}),
|
|
3548
|
+
],
|
|
3549
|
+
}),
|
|
3550
|
+
totalPrice: 15_00, // Admin does not know there should be discount
|
|
3551
|
+
asOrganizationId: organization.id,
|
|
3552
|
+
});
|
|
3553
|
+
|
|
3554
|
+
const response2 = await post(checkout, organization, adminToken);
|
|
3555
|
+
expect(response2.body.registrations.length).toBe(1);
|
|
3556
|
+
|
|
3557
|
+
const registration2 = response2.body.registrations[0];
|
|
3558
|
+
expect(registration2.registeredAt).not.toBeNull();
|
|
3559
|
+
expect(registration2.discounts).toMatchMap(new Map());
|
|
3560
|
+
|
|
3561
|
+
const registration2Model = (await Registration.getByID(registration2.id))!;
|
|
3562
|
+
expect(registration2).toBeDefined();
|
|
3563
|
+
expect(registration2Model.discounts).toMatchMap(new Map());
|
|
3564
|
+
|
|
3565
|
+
// Get registration 1 again, it should now have the bundle discount applied
|
|
3566
|
+
await registration1.refresh();
|
|
3567
|
+
expect(registration1.discounts).toMatchMap(new Map([
|
|
3568
|
+
[bundleDiscount.id, AppliedRegistrationDiscount.create({
|
|
3569
|
+
name: bundleDiscount.name,
|
|
3570
|
+
amount: 5_00,
|
|
3571
|
+
})],
|
|
3572
|
+
]));
|
|
3573
|
+
|
|
3574
|
+
await assertBalances({ member: otherMember }, [
|
|
3575
|
+
{
|
|
3576
|
+
type: BalanceItemType.Registration,
|
|
3577
|
+
registrationId: registration1.id,
|
|
3578
|
+
amount: 1,
|
|
3579
|
+
price: 25_00,
|
|
3580
|
+
status: BalanceItemStatus.Due,
|
|
3581
|
+
priceOpen: 25_00,
|
|
3582
|
+
pricePending: 0,
|
|
3583
|
+
userId: user.id,
|
|
3584
|
+
},
|
|
3585
|
+
{
|
|
3586
|
+
type: BalanceItemType.RegistrationBundleDiscount,
|
|
3587
|
+
registrationId: registration1.id,
|
|
3588
|
+
amount: 1,
|
|
3589
|
+
price: -5_00,
|
|
3590
|
+
status: BalanceItemStatus.Due,
|
|
3591
|
+
priceOpen: -5_00,
|
|
3592
|
+
pricePending: 0,
|
|
3593
|
+
userId: null,
|
|
3594
|
+
},
|
|
3595
|
+
]);
|
|
3596
|
+
|
|
3597
|
+
await assertBalances({ member }, [
|
|
3598
|
+
{
|
|
3599
|
+
type: BalanceItemType.Registration,
|
|
3600
|
+
registrationId: registration2.id,
|
|
3601
|
+
amount: 1,
|
|
3602
|
+
price: 15_00,
|
|
3603
|
+
status: BalanceItemStatus.Due,
|
|
3604
|
+
priceOpen: 15_00,
|
|
3605
|
+
pricePending: 0,
|
|
3606
|
+
userId: null,
|
|
3607
|
+
},
|
|
3608
|
+
]);
|
|
3609
|
+
});
|
|
3610
|
+
|
|
3611
|
+
test('Discounts work across family members when admins register members, even when the admin does not have access to the family members nor financial access rights', async () => {
|
|
3612
|
+
const { organizationRegistrationPeriod, organization, member, token, user } = await initData();
|
|
3613
|
+
const bundleDiscount = await initBundleDiscount({
|
|
3614
|
+
organizationRegistrationPeriod,
|
|
3615
|
+
discount: {
|
|
3616
|
+
countWholeFamily: true,
|
|
3617
|
+
discounts: [
|
|
3618
|
+
{
|
|
3619
|
+
value: 20_00,
|
|
3620
|
+
type: GroupPriceDiscountType.Percentage,
|
|
3621
|
+
},
|
|
3622
|
+
],
|
|
3623
|
+
},
|
|
3624
|
+
});
|
|
3625
|
+
|
|
3626
|
+
const groups = [
|
|
3627
|
+
await new GroupFactory({
|
|
3628
|
+
organization,
|
|
3629
|
+
price: 25_00,
|
|
3630
|
+
bundleDiscount,
|
|
3631
|
+
}).create(),
|
|
3632
|
+
await new GroupFactory({
|
|
3633
|
+
organization,
|
|
3634
|
+
price: 15_00,
|
|
3635
|
+
bundleDiscount,
|
|
3636
|
+
}).create(),
|
|
3637
|
+
];
|
|
3638
|
+
|
|
3639
|
+
// Create an unrelated group and registration so admin has access to the member
|
|
3640
|
+
const randomGroup = await new GroupFactory({
|
|
3641
|
+
organization,
|
|
3642
|
+
price: 0,
|
|
3643
|
+
}).create();
|
|
3644
|
+
|
|
3645
|
+
await new RegistrationFactory({
|
|
3646
|
+
organization,
|
|
3647
|
+
member: member,
|
|
3648
|
+
group: randomGroup,
|
|
3649
|
+
}).create();
|
|
3650
|
+
|
|
3651
|
+
const { adminToken } = await initAdmin({
|
|
3652
|
+
organization,
|
|
3653
|
+
permissions: Permissions.create({
|
|
3654
|
+
level: PermissionLevel.None,
|
|
3655
|
+
resources: new Map([[
|
|
3656
|
+
PermissionsResourceType.Groups, new Map([
|
|
3657
|
+
[
|
|
3658
|
+
randomGroup.id, ResourcePermissions.create({
|
|
3659
|
+
level: PermissionLevel.Write,
|
|
3660
|
+
}),
|
|
3661
|
+
], [
|
|
3662
|
+
groups[1].id, ResourcePermissions.create({
|
|
3663
|
+
level: PermissionLevel.Write,
|
|
3664
|
+
}),
|
|
3665
|
+
],
|
|
3666
|
+
// No permission for group 0
|
|
3667
|
+
]),
|
|
3668
|
+
]]),
|
|
3669
|
+
}),
|
|
3670
|
+
});
|
|
3671
|
+
|
|
3672
|
+
const otherMember = await new MemberFactory({
|
|
3673
|
+
organization,
|
|
3674
|
+
user,
|
|
3675
|
+
}).create();
|
|
3676
|
+
|
|
3677
|
+
// First register the otherMember for group 1. No discount should be applied yet
|
|
3678
|
+
const registration1 = await new RegistrationFactory({
|
|
3679
|
+
organization,
|
|
3680
|
+
member: otherMember,
|
|
3681
|
+
group: groups[0],
|
|
3682
|
+
}).create();
|
|
3683
|
+
|
|
3684
|
+
await new BalanceItemFactory({
|
|
3685
|
+
userId: user.id,
|
|
3686
|
+
memberId: otherMember.id,
|
|
3687
|
+
organizationId: organization.id,
|
|
3688
|
+
type: BalanceItemType.Registration,
|
|
3689
|
+
amount: 1,
|
|
3690
|
+
unitPrice: 25_00,
|
|
3691
|
+
status: BalanceItemStatus.Due,
|
|
3692
|
+
registrationId: registration1.id,
|
|
3693
|
+
}).create();
|
|
3694
|
+
|
|
3695
|
+
const checkout = IDRegisterCheckout.create({
|
|
3696
|
+
cart: IDRegisterCart.create({
|
|
3697
|
+
items: [
|
|
3698
|
+
IDRegisterItem.create({
|
|
3699
|
+
groupPrice: groups[1].settings.prices[0],
|
|
3700
|
+
groupId: groups[1].id,
|
|
3701
|
+
organizationId: organization.id,
|
|
3702
|
+
memberId: member.id,
|
|
3703
|
+
}),
|
|
3704
|
+
],
|
|
3705
|
+
}),
|
|
3706
|
+
totalPrice: 15_00, // Admin does not know there should be discount
|
|
3707
|
+
asOrganizationId: organization.id,
|
|
3708
|
+
});
|
|
3709
|
+
|
|
3710
|
+
const response2 = await post(checkout, organization, adminToken);
|
|
3711
|
+
expect(response2.body.registrations.length).toBe(1);
|
|
3712
|
+
|
|
3391
3713
|
const registration2 = response2.body.registrations[0];
|
|
3392
3714
|
expect(registration2.registeredAt).not.toBeNull();
|
|
3393
3715
|
expect(registration2.discounts).toMatchMap(new Map());
|
|
3394
3716
|
|
|
3717
|
+
const registration2Model = (await Registration.getByID(registration2.id))!;
|
|
3718
|
+
expect(registration2).toBeDefined();
|
|
3719
|
+
expect(registration2Model.discounts).toMatchMap(new Map());
|
|
3720
|
+
|
|
3395
3721
|
// Get registration 1 again, it should now have the bundle discount applied
|
|
3396
3722
|
await registration1.refresh();
|
|
3397
3723
|
expect(registration1.discounts).toMatchMap(new Map([
|
|
@@ -44,6 +44,28 @@ export class PayconiqMocker {
|
|
|
44
44
|
checkout: {
|
|
45
45
|
href: 'https://payconiq.com/pay/2/5bdb1685b93d1c000bde96f2?token=530ea8a4ec8ded7d87620c8637354022cd965b143f257f8f8cb118e7f4a22d8f&returnUrl=https%3A%2F%2Fummy.webshop%2Fcheckout%2Fsuccess',
|
|
46
46
|
},
|
|
47
|
+
qrcode: {
|
|
48
|
+
href: 'https://payconiq.com/pay/2/5bdb1685b93d1c000bde96f2?token=530ea8a4ec8ded7d87620c8637354022cd965b143f257f8f8cb118e7f4a22d8f&returnUrl=https%3A%2F%2Fummy.webshop%2Fcheckout%2Fsuccess',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}];
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
nock('https://merchant.api.preprod.bancontact.net')
|
|
55
|
+
.persist()
|
|
56
|
+
.post('/v3/payments')
|
|
57
|
+
.reply((uri, body) => {
|
|
58
|
+
// todo: do something smarter with the body
|
|
59
|
+
|
|
60
|
+
return [200, {
|
|
61
|
+
paymentId: uuidv4(),
|
|
62
|
+
_links: {
|
|
63
|
+
checkout: {
|
|
64
|
+
href: 'https://payconiq.com/pay/2/5bdb1685b93d1c000bde96f2?token=530ea8a4ec8ded7d87620c8637354022cd965b143f257f8f8cb118e7f4a22d8f&returnUrl=https%3A%2F%2Fummy.webshop%2Fcheckout%2Fsuccess',
|
|
65
|
+
},
|
|
66
|
+
qrcode: {
|
|
67
|
+
href: 'https://payconiq.com/pay/2/5bdb1685b93d1c000bde96f2?token=530ea8a4ec8ded7d87620c8637354022cd965b143f257f8f8cb118e7f4a22d8f&returnUrl=https%3A%2F%2Fummy.webshop%2Fcheckout%2Fsuccess',
|
|
68
|
+
},
|
|
47
69
|
},
|
|
48
70
|
}];
|
|
49
71
|
});
|
package/tests/init/index.ts
CHANGED
package/tests/init/initAdmin.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Organization, Token, UserFactory } from '@stamhoofd/models';
|
|
2
2
|
import { PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
3
3
|
|
|
4
|
-
export async function initAdmin({ organization }: { organization: Organization }) {
|
|
4
|
+
export async function initAdmin({ organization, permissions }: { organization: Organization; permissions?: Permissions }) {
|
|
5
5
|
const admin = await new UserFactory({
|
|
6
6
|
organization,
|
|
7
|
-
permissions: Permissions.create({
|
|
7
|
+
permissions: permissions ?? Permissions.create({
|
|
8
8
|
level: PermissionLevel.Full,
|
|
9
9
|
}),
|
|
10
10
|
}).create();
|