@stamhoofd/backend 2.101.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.
Files changed (29) hide show
  1. package/package.json +10 -10
  2. package/src/crons.ts +21 -26
  3. package/src/email-recipient-loaders/receivable-balances.ts +18 -11
  4. package/src/endpoints/global/events/PatchEventsEndpoint.ts +1 -0
  5. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +12 -8
  6. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +30 -7
  7. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +4 -4
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +382 -31
  9. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +47 -23
  10. package/src/endpoints/organization/dashboard/billing/GetOrganizationPayableBalanceEndpoint.ts +2 -2
  11. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +60 -8
  12. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +6 -1
  13. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +12 -9
  14. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -2
  15. package/src/excel-loaders/receivable-balances.ts +2 -2
  16. package/src/helpers/AdminPermissionChecker.ts +68 -10
  17. package/src/helpers/AuthenticatedStructures.ts +18 -10
  18. package/src/helpers/MemberUserSyncer.ts +2 -2
  19. package/src/seeds/{1735577912-update-cached-outstanding-balance-from-items.ts → 1760702454-update-cached-outstanding-balance-from-items.ts} +2 -0
  20. package/src/services/BalanceItemService.ts +12 -0
  21. package/src/services/PaymentService.ts +3 -0
  22. package/src/sql-filters/base-registration-filter-compilers.ts +59 -0
  23. package/src/sql-filters/orders.ts +97 -1
  24. package/src/sql-filters/receivable-balances.ts +7 -1
  25. package/tests/e2e/bundle-discounts.test.ts +327 -1
  26. package/tests/helpers/PayconiqMocker.ts +22 -0
  27. package/tests/init/index.ts +1 -0
  28. package/tests/init/initAdmin.ts +2 -2
  29. package/tests/init/initPermissionRole.ts +12 -0
@@ -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();
@@ -0,0 +1,12 @@
1
+ import { Organization } from '@stamhoofd/models';
2
+ import { AccessRight, PermissionRoleDetailed } from '@stamhoofd/structures';
3
+
4
+ export async function initPermissionRole({ organization, accessRights }: { organization: Organization; accessRights?: AccessRight[] }): Promise<PermissionRoleDetailed> {
5
+ const role = PermissionRoleDetailed.create({
6
+ name: 'Test role',
7
+ accessRights,
8
+ });
9
+ organization.privateMeta.roles.push(role);
10
+ await organization.save();
11
+ return role;
12
+ }