@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.
@@ -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
- if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
71
- if (!await Context.auth.canManageFinances(request.body.asOrganizationId)) {
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(member, PermissionLevel.Write)) {
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
- throw new SimpleError({
229
- code: 'changed_price',
230
- message: $t(`e424d549-2bb8-4103-9a14-ac4063d7d454`, { total: Formatter.price(totalPrice) }),
231
- });
232
- }
233
-
234
- // Who is going to pay?
235
- 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
236
-
237
- if (request.body.asOrganizationId && request.body.asOrganizationId === organization.id) {
238
- // Will get added to the outstanding amount of the member / already paying organization
239
- whoWillPayNow = 'nobody';
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
- const description = webshop.meta.name + ' - ' + payment.id;
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
- /* if ( // Causes issues with bundle discount calculations
472
- registration.group.settings.implicitlyAllowViewRegistrations
473
- || await Context.auth.canAccessRegistration(registration, PermissionLevel.Read)
474
- ) { */
475
- filtered.push(registration);
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
  });
@@ -3,3 +3,4 @@ export * from './initPlatformAdmin';
3
3
  export * from './initPayconiq';
4
4
  export * from './initBundleDiscount';
5
5
  export * from './initStripe';
6
+ export * from './initPermissionRole';
@@ -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();