@stamhoofd/backend 2.133.0 → 2.135.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 +18 -18
- package/src/crons/invoices.ts +5 -3
- package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +224 -2
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +190 -2
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +72 -14
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +183 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
- package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +158 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
- package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +70 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
- package/src/helpers/GlobalHelper.ts +1 -1
- package/src/helpers/MembershipCharger.ts +2 -1
- package/src/helpers/StripeInvoicer.ts +13 -2
- package/src/helpers/UitpasTokenRepository.ts +12 -11
- package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
- package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
- package/src/services/PaymentService.ts +33 -8
- package/src/services/PlatformMembershipService.ts +2 -1
- package/src/services/RegistrationService.ts +5 -0
- package/src/services/uitpas/UitpasService.ts +3 -4
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/sql-filters/balance-item-payments.ts +4 -56
- package/src/sql-filters/balance-items.ts +69 -10
- package/src/sql-filters/members.ts +5 -0
- package/src/sql-sorters/members.ts +12 -1
|
@@ -3,10 +3,10 @@ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
|
3
3
|
import { PatchableArray, PatchMap } from '@simonbackx/simple-encoding';
|
|
4
4
|
import type { Endpoint } from '@simonbackx/simple-endpoints';
|
|
5
5
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
6
|
-
import { GroupFactory, Member, MemberFactory, OrganizationFactory, OrganizationTagFactory, Platform, RegistrationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
6
|
+
import { GroupFactory, Member, MemberFactory, MemberPlatformMembership, OrganizationFactory, OrganizationTagFactory, Platform, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
7
7
|
import { SQL } from '@stamhoofd/sql';
|
|
8
8
|
import type { PatchAnswers } from '@stamhoofd/structures';
|
|
9
|
-
import { Address, EmergencyContact, MemberDetails, MemberWithRegistrationsBlob, OrganizationMetaData, OrganizationRecordsConfiguration, Parent, PermissionLevel, Permissions, PermissionsResourceType, RecordCategory, RecordSettings, RecordTextAnswer, ResourcePermissions, ReviewTime, ReviewTimes, TranslatedString, UitpasNumberDetails, UitpasSocialTariff, UitpasSocialTariffStatus, Version } from '@stamhoofd/structures';
|
|
9
|
+
import { Address, EmergencyContact, MemberDetails, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, OrganizationMetaData, OrganizationRecordsConfiguration, Parent, PermissionLevel, Permissions, PermissionsResourceType, PlatformMembershipType, PlatformMembershipTypeConfig, RecordCategory, RecordSettings, RecordTextAnswer, ResourcePermissions, ReviewTime, ReviewTimes, TranslatedString, UitpasNumberDetails, UitpasSocialTariff, UitpasSocialTariffStatus, Version } from '@stamhoofd/structures';
|
|
10
10
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
11
11
|
import { Country } from '@stamhoofd/types/Country';
|
|
12
12
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
@@ -4034,4 +4034,192 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
|
|
|
4034
4034
|
});
|
|
4035
4035
|
});
|
|
4036
4036
|
});
|
|
4037
|
+
|
|
4038
|
+
describe('Overlapping and incompatible platform memberships', () => {
|
|
4039
|
+
// Two non-overlapping halves of the same period (werkjaar).
|
|
4040
|
+
const firstHalf = { startDate: new Date(2024, 0, 1, 0, 0, 0, 0), endDate: new Date(2024, 5, 30, 23, 59, 59, 0) };
|
|
4041
|
+
const secondHalf = { startDate: new Date(2024, 6, 1, 0, 0, 0, 0), endDate: new Date(2024, 11, 31, 23, 59, 59, 0) };
|
|
4042
|
+
|
|
4043
|
+
async function setup({ incompatibleWithB = false, existingRange, putRange, existingLocked = false, existingGenerated = false }: {
|
|
4044
|
+
incompatibleWithB?: boolean;
|
|
4045
|
+
existingRange?: { startDate: Date; endDate: Date };
|
|
4046
|
+
putRange?: { startDate: Date; endDate: Date };
|
|
4047
|
+
existingLocked?: boolean;
|
|
4048
|
+
existingGenerated?: boolean;
|
|
4049
|
+
}) {
|
|
4050
|
+
const period = await new RegistrationPeriodFactory({
|
|
4051
|
+
startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
|
|
4052
|
+
endDate: new Date(2024, 11, 31, 23, 59, 59, 0),
|
|
4053
|
+
}).create();
|
|
4054
|
+
|
|
4055
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
4056
|
+
|
|
4057
|
+
const typeB = PlatformMembershipType.create({
|
|
4058
|
+
name: 'Type B',
|
|
4059
|
+
periods: new Map([
|
|
4060
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
4061
|
+
startDate: period.startDate,
|
|
4062
|
+
endDate: period.endDate,
|
|
4063
|
+
})],
|
|
4064
|
+
]),
|
|
4065
|
+
});
|
|
4066
|
+
|
|
4067
|
+
const typeA = PlatformMembershipType.create({
|
|
4068
|
+
name: 'Type A',
|
|
4069
|
+
incompatibleMembershipTypeIds: incompatibleWithB ? [typeB.id] : [],
|
|
4070
|
+
periods: new Map([
|
|
4071
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
4072
|
+
startDate: period.startDate,
|
|
4073
|
+
endDate: period.endDate,
|
|
4074
|
+
})],
|
|
4075
|
+
]),
|
|
4076
|
+
});
|
|
4077
|
+
|
|
4078
|
+
const platform = await Platform.getForEditing();
|
|
4079
|
+
platform.periodId = period.id;
|
|
4080
|
+
platform.config.membershipTypes = [typeA, typeB];
|
|
4081
|
+
await platform.save();
|
|
4082
|
+
|
|
4083
|
+
const user = await new UserFactory({
|
|
4084
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
4085
|
+
organization,
|
|
4086
|
+
}).create();
|
|
4087
|
+
const token = await Token.createToken(user);
|
|
4088
|
+
|
|
4089
|
+
const member = await new MemberFactory({ organization }).create();
|
|
4090
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
4091
|
+
await new RegistrationFactory({ member, group }).create();
|
|
4092
|
+
|
|
4093
|
+
// The member already has a membership of type B in this period
|
|
4094
|
+
const existing = new MemberPlatformMembership();
|
|
4095
|
+
existing.memberId = member.id;
|
|
4096
|
+
existing.membershipTypeId = typeB.id;
|
|
4097
|
+
existing.organizationId = organization.id;
|
|
4098
|
+
existing.periodId = period.id;
|
|
4099
|
+
existing.startDate = existingRange?.startDate ?? period.startDate;
|
|
4100
|
+
existing.endDate = existingRange?.endDate ?? period.endDate;
|
|
4101
|
+
existing.locked = existingLocked;
|
|
4102
|
+
existing.generated = existingGenerated;
|
|
4103
|
+
await existing.save();
|
|
4104
|
+
|
|
4105
|
+
const arr: Body = new PatchableArray();
|
|
4106
|
+
const memberPatch = MemberWithRegistrationsBlob.patch({ id: member.id });
|
|
4107
|
+
memberPatch.platformMemberships.addPut(MemberPlatformMembershipStruct.create({
|
|
4108
|
+
memberId: member.id,
|
|
4109
|
+
membershipTypeId: typeA.id,
|
|
4110
|
+
organizationId: organization.id,
|
|
4111
|
+
periodId: period.id,
|
|
4112
|
+
startDate: putRange?.startDate ?? period.startDate,
|
|
4113
|
+
endDate: putRange?.endDate ?? period.endDate,
|
|
4114
|
+
}));
|
|
4115
|
+
arr.addPatch(memberPatch);
|
|
4116
|
+
|
|
4117
|
+
const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
|
|
4118
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
4119
|
+
|
|
4120
|
+
return { period, organization, typeA, typeB, member, request };
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
async function getTypeAMemberships(memberId: string, membershipTypeId: string) {
|
|
4124
|
+
return await MemberPlatformMembership.select()
|
|
4125
|
+
.where('memberId', memberId)
|
|
4126
|
+
.where('membershipTypeId', membershipTypeId)
|
|
4127
|
+
.where('deletedAt', null)
|
|
4128
|
+
.fetch();
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
describe('Overlapping memberships of a different type are never allowed', () => {
|
|
4132
|
+
test('Blocks an overlapping membership of an incompatible type', async () => {
|
|
4133
|
+
const { typeA, member, request } = await setup({ incompatibleWithB: true });
|
|
4134
|
+
|
|
4135
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4136
|
+
code: 'invalid_field',
|
|
4137
|
+
field: 'membershipTypeId',
|
|
4138
|
+
message: 'Overlapping membership type',
|
|
4139
|
+
}));
|
|
4140
|
+
|
|
4141
|
+
// The overlapping membership was not created
|
|
4142
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4143
|
+
});
|
|
4144
|
+
|
|
4145
|
+
test('Blocks an overlapping membership even when the type is not marked incompatible', async () => {
|
|
4146
|
+
const { typeA, member, request } = await setup({ incompatibleWithB: false });
|
|
4147
|
+
|
|
4148
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4149
|
+
code: 'invalid_field',
|
|
4150
|
+
field: 'membershipTypeId',
|
|
4151
|
+
message: 'Overlapping membership type',
|
|
4152
|
+
}));
|
|
4153
|
+
|
|
4154
|
+
// The overlapping membership was not created
|
|
4155
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4156
|
+
});
|
|
4157
|
+
});
|
|
4158
|
+
|
|
4159
|
+
describe('Non-overlapping memberships in the same period', () => {
|
|
4160
|
+
test('Blocks an incompatible type even when the dates do not overlap', async () => {
|
|
4161
|
+
const { typeA, member, request } = await setup({
|
|
4162
|
+
incompatibleWithB: true,
|
|
4163
|
+
existingRange: firstHalf,
|
|
4164
|
+
putRange: secondHalf,
|
|
4165
|
+
});
|
|
4166
|
+
|
|
4167
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4168
|
+
code: 'invalid_field',
|
|
4169
|
+
field: 'membershipTypeId',
|
|
4170
|
+
message: 'Incompatible membership type',
|
|
4171
|
+
}));
|
|
4172
|
+
|
|
4173
|
+
// The incompatible membership was not created
|
|
4174
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4175
|
+
});
|
|
4176
|
+
|
|
4177
|
+
test('Allows a compatible type when the dates do not overlap', async () => {
|
|
4178
|
+
const { typeA, member, request } = await setup({
|
|
4179
|
+
incompatibleWithB: false,
|
|
4180
|
+
existingRange: firstHalf,
|
|
4181
|
+
putRange: secondHalf,
|
|
4182
|
+
});
|
|
4183
|
+
|
|
4184
|
+
const result = await testServer.test(endpoint, request);
|
|
4185
|
+
expect(result.status).toBe(200);
|
|
4186
|
+
|
|
4187
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(1);
|
|
4188
|
+
});
|
|
4189
|
+
|
|
4190
|
+
test('Reports a locked incompatible membership with a specific message', async () => {
|
|
4191
|
+
const { typeA, member, request } = await setup({
|
|
4192
|
+
incompatibleWithB: true,
|
|
4193
|
+
existingRange: firstHalf,
|
|
4194
|
+
putRange: secondHalf,
|
|
4195
|
+
existingLocked: true,
|
|
4196
|
+
});
|
|
4197
|
+
|
|
4198
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4199
|
+
code: 'invalid_field',
|
|
4200
|
+
field: 'membershipTypeId',
|
|
4201
|
+
message: 'Incompatible locked membership type',
|
|
4202
|
+
}));
|
|
4203
|
+
|
|
4204
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4205
|
+
});
|
|
4206
|
+
|
|
4207
|
+
test('Reports a generated incompatible membership with a specific message', async () => {
|
|
4208
|
+
const { typeA, member, request } = await setup({
|
|
4209
|
+
incompatibleWithB: true,
|
|
4210
|
+
existingRange: firstHalf,
|
|
4211
|
+
putRange: secondHalf,
|
|
4212
|
+
existingGenerated: true,
|
|
4213
|
+
});
|
|
4214
|
+
|
|
4215
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4216
|
+
code: 'invalid_field',
|
|
4217
|
+
field: 'membershipTypeId',
|
|
4218
|
+
message: 'Incompatible generated membership type',
|
|
4219
|
+
}));
|
|
4220
|
+
|
|
4221
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4222
|
+
});
|
|
4223
|
+
});
|
|
4224
|
+
});
|
|
4037
4225
|
});
|
|
@@ -66,6 +66,25 @@ export const duplicateCheckLimiter = new RateLimiter({
|
|
|
66
66
|
],
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A where clause that matches memberships whose [startDate, endDate] range overlaps
|
|
71
|
+
* with the given [startDate, endDate] range.
|
|
72
|
+
*/
|
|
73
|
+
function membershipDateRangeOverlaps(startDate: Date, endDate: Date) {
|
|
74
|
+
return SQL.where(
|
|
75
|
+
SQL.where('startDate', SQLWhereSign.LessEqual, startDate)
|
|
76
|
+
.and('endDate', SQLWhereSign.GreaterEqual, startDate),
|
|
77
|
+
)
|
|
78
|
+
.or(
|
|
79
|
+
SQL.where('startDate', SQLWhereSign.LessEqual, endDate)
|
|
80
|
+
.and('endDate', SQLWhereSign.GreaterEqual, endDate),
|
|
81
|
+
)
|
|
82
|
+
.or(
|
|
83
|
+
SQL.where('startDate', SQLWhereSign.GreaterEqual, startDate)
|
|
84
|
+
.and('endDate', SQLWhereSign.LessEqual, endDate),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
/**
|
|
70
89
|
* One endpoint to create, patch and delete members and their registrations and payments
|
|
71
90
|
*/
|
|
@@ -568,26 +587,65 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
568
587
|
// Correct price and dates
|
|
569
588
|
await membership.calculatePrice(member);
|
|
570
589
|
|
|
590
|
+
const incompatible = await MemberPlatformMembership.select()
|
|
591
|
+
.where('memberId', member.id)
|
|
592
|
+
.whereNot('membershipTypeId', put.membershipTypeId) // same type is checked later and might be auto-deleted
|
|
593
|
+
.where('periodId', put.periodId)
|
|
594
|
+
.where('deletedAt', null)
|
|
595
|
+
.where(membershipDateRangeOverlaps(put.startDate, put.endDate))
|
|
596
|
+
.first(false);
|
|
597
|
+
|
|
598
|
+
if (incompatible) {
|
|
599
|
+
throw new SimpleError({
|
|
600
|
+
code: 'invalid_field',
|
|
601
|
+
field: 'membershipTypeId',
|
|
602
|
+
message: 'Overlapping membership type',
|
|
603
|
+
human: $t('%Zd9'),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Not overlapping, but in same period.
|
|
608
|
+
if (membershipType.incompatibleMembershipTypeIds.length > 0) {
|
|
609
|
+
const incompatible = await MemberPlatformMembership.select()
|
|
610
|
+
.where('memberId', member.id)
|
|
611
|
+
.where('membershipTypeId', membershipType.incompatibleMembershipTypeIds)
|
|
612
|
+
.where('periodId', put.periodId)
|
|
613
|
+
.where('deletedAt', null)
|
|
614
|
+
.first(false);
|
|
615
|
+
|
|
616
|
+
if (incompatible) {
|
|
617
|
+
if (incompatible.locked) {
|
|
618
|
+
throw new SimpleError({
|
|
619
|
+
code: 'invalid_field',
|
|
620
|
+
field: 'membershipTypeId',
|
|
621
|
+
message: 'Incompatible locked membership type',
|
|
622
|
+
human: $t('%Zd1'),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (incompatible.generated) {
|
|
626
|
+
throw new SimpleError({
|
|
627
|
+
code: 'invalid_field',
|
|
628
|
+
field: 'membershipTypeId',
|
|
629
|
+
message: 'Incompatible generated membership type',
|
|
630
|
+
human: $t('%Zcx'),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
throw new SimpleError({
|
|
634
|
+
code: 'invalid_field',
|
|
635
|
+
field: 'membershipTypeId',
|
|
636
|
+
message: 'Incompatible membership type',
|
|
637
|
+
human: $t('%ZdA'),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
571
642
|
// Check duplicate memberships after correcting the dates
|
|
572
643
|
const existing = await MemberPlatformMembership.select()
|
|
573
644
|
.where('memberId', member.id)
|
|
574
645
|
.where('membershipTypeId', put.membershipTypeId)
|
|
575
646
|
.where('periodId', put.periodId)
|
|
576
647
|
.where('deletedAt', null)
|
|
577
|
-
.where(
|
|
578
|
-
SQL.where(
|
|
579
|
-
SQL.where('startDate', SQLWhereSign.LessEqual, put.startDate)
|
|
580
|
-
.and('endDate', SQLWhereSign.GreaterEqual, put.startDate),
|
|
581
|
-
)
|
|
582
|
-
.or(
|
|
583
|
-
SQL.where('startDate', SQLWhereSign.LessEqual, put.endDate)
|
|
584
|
-
.and('endDate', SQLWhereSign.GreaterEqual, put.endDate),
|
|
585
|
-
)
|
|
586
|
-
.or(
|
|
587
|
-
SQL.where('startDate', SQLWhereSign.GreaterEqual, put.startDate)
|
|
588
|
-
.and('endDate', SQLWhereSign.LessEqual, put.endDate),
|
|
589
|
-
),
|
|
590
|
-
)
|
|
648
|
+
.where(membershipDateRangeOverlaps(put.startDate, put.endDate))
|
|
591
649
|
.first(false);
|
|
592
650
|
|
|
593
651
|
if (existing && (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days || !existing.generated || existing.locked || existing.price < membership.price)) {
|
|
@@ -240,6 +240,53 @@ describe('Endpoint.RegisterMembers', () => {
|
|
|
240
240
|
]);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
test('Should pay a balance item that is due soon (dueAt in the near future)', async () => {
|
|
244
|
+
const { member, user, organization, token } = await initData();
|
|
245
|
+
|
|
246
|
+
// Items that are due within 7 days are part of the outstanding balance, so a member
|
|
247
|
+
// can pay them before their due date.
|
|
248
|
+
const dueAt = new Date(new Date().getTime() + 3 * 24 * 60 * 60 * 1000);
|
|
249
|
+
|
|
250
|
+
const balanceItem1 = await new BalanceItemFactory({
|
|
251
|
+
organizationId: organization.id,
|
|
252
|
+
memberId: member.id,
|
|
253
|
+
userId: user.id,
|
|
254
|
+
type: BalanceItemType.Registration,
|
|
255
|
+
amount: 10,
|
|
256
|
+
unitPrice: 200,
|
|
257
|
+
dueAt,
|
|
258
|
+
status: BalanceItemStatus.Due,
|
|
259
|
+
}).create();
|
|
260
|
+
|
|
261
|
+
const body = IDRegisterCheckout.create({
|
|
262
|
+
cart: IDRegisterCart.create({
|
|
263
|
+
items: [],
|
|
264
|
+
balanceItems: [
|
|
265
|
+
BalanceItemCartItem.create({
|
|
266
|
+
item: balanceItem1.getStructure(),
|
|
267
|
+
price: 2000,
|
|
268
|
+
}),
|
|
269
|
+
],
|
|
270
|
+
deleteRegistrationIds: [],
|
|
271
|
+
}),
|
|
272
|
+
administrationFee: 0,
|
|
273
|
+
freeContribution: 0,
|
|
274
|
+
paymentMethod: PaymentMethod.PointOfSale,
|
|
275
|
+
totalPrice: 2000,
|
|
276
|
+
customer: null,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const response = await post(body, organization, token);
|
|
280
|
+
|
|
281
|
+
// The member really has to pay it: it must be part of the payment
|
|
282
|
+
expect(response.body.payment).not.toBeNull();
|
|
283
|
+
expect(response.body.payment!.price).toBe(2000);
|
|
284
|
+
|
|
285
|
+
// Point of sale isn't paid right away, but the amount is pending in the payment
|
|
286
|
+
await balanceItem1.refresh();
|
|
287
|
+
expect(balanceItem1.pricePending).toBe(2000);
|
|
288
|
+
});
|
|
289
|
+
|
|
243
290
|
test('Should fail if balance item deleted', async () => {
|
|
244
291
|
const { member, user, organization, token } = await initData();
|
|
245
292
|
|
|
@@ -960,17 +1007,120 @@ describe('Endpoint.RegisterMembers', () => {
|
|
|
960
1007
|
// assert
|
|
961
1008
|
expect(response.body).toBeDefined();
|
|
962
1009
|
expect(response.body.registrations.length).toBe(1);
|
|
1010
|
+
// Nothing is due during a trial, but the registration should still be activated
|
|
1011
|
+
// (marked valid) so it is visible in the registration lists and member portal.
|
|
1012
|
+
expect(response.body.registrations[0]).toMatchObject({
|
|
1013
|
+
registeredAt: expect.any(Date),
|
|
1014
|
+
deactivatedAt: null,
|
|
1015
|
+
});
|
|
1016
|
+
const dbRegistration = await Registration.getByID(response.body.registrations[0].id);
|
|
1017
|
+
expect(dbRegistration?.registeredAt).not.toBeNull();
|
|
1018
|
+
expect(dbRegistration?.deactivatedAt).toBeNull();
|
|
963
1019
|
const trialUntil = response.body.registrations[0].trialUntil;
|
|
964
1020
|
expect(trialUntil).not.toBeNull();
|
|
965
1021
|
// 2023-05-14
|
|
966
1022
|
expect(trialUntil!.getFullYear()).toBe(2023);
|
|
967
1023
|
expect(trialUntil!.getMonth()).toBe(4);
|
|
968
1024
|
expect(trialUntil!.getDate()).toBe(19);
|
|
1025
|
+
|
|
1026
|
+
// Nothing is paid during the trial, but the balance item should be due (not hidden)
|
|
1027
|
+
// so it is still billed once the trial ends.
|
|
1028
|
+
const balanceItems = await BalanceItem.where({ registrationId: dbRegistration!.id });
|
|
1029
|
+
expect(balanceItems.length).toBe(1);
|
|
1030
|
+
expect(balanceItems[0]).toMatchObject({
|
|
1031
|
+
status: BalanceItemStatus.Due,
|
|
1032
|
+
dueAt: dbRegistration!.trialUntil,
|
|
1033
|
+
pricePaid: 0,
|
|
1034
|
+
});
|
|
969
1035
|
} finally {
|
|
970
1036
|
vitest.useRealTimers();
|
|
971
1037
|
}
|
|
972
1038
|
}, 20_00000);
|
|
973
1039
|
|
|
1040
|
+
test('A trial registration is activated immediately, while a paid registration in the same cart waits for the online payment', async () => {
|
|
1041
|
+
// #region arrange
|
|
1042
|
+
const { member, group, groupPrice, organization, token } = await initData();
|
|
1043
|
+
await initPayconiq({ organization });
|
|
1044
|
+
|
|
1045
|
+
// The trial group also limits its members, so a reservedUntil is set and has to be cleared again
|
|
1046
|
+
group.settings.trialDays = 5;
|
|
1047
|
+
group.settings.maxMembers = 1;
|
|
1048
|
+
await group.save();
|
|
1049
|
+
|
|
1050
|
+
const paidGroup = await new GroupFactory({
|
|
1051
|
+
organization,
|
|
1052
|
+
price: 1500,
|
|
1053
|
+
maxMembers: 1,
|
|
1054
|
+
}).create();
|
|
1055
|
+
|
|
1056
|
+
const body = IDRegisterCheckout.create({
|
|
1057
|
+
cart: IDRegisterCart.create({
|
|
1058
|
+
items: [
|
|
1059
|
+
IDRegisterItem.create({
|
|
1060
|
+
id: uuidv4(),
|
|
1061
|
+
groupPrice,
|
|
1062
|
+
organizationId: organization.id,
|
|
1063
|
+
groupId: group.id,
|
|
1064
|
+
memberId: member.id,
|
|
1065
|
+
trial: true,
|
|
1066
|
+
}),
|
|
1067
|
+
IDRegisterItem.create({
|
|
1068
|
+
id: uuidv4(),
|
|
1069
|
+
groupPrice: paidGroup.settings.prices[0],
|
|
1070
|
+
organizationId: organization.id,
|
|
1071
|
+
groupId: paidGroup.id,
|
|
1072
|
+
memberId: member.id,
|
|
1073
|
+
}),
|
|
1074
|
+
],
|
|
1075
|
+
}),
|
|
1076
|
+
paymentMethod: PaymentMethod.Payconiq,
|
|
1077
|
+
redirectUrl: new URL('https://www.example.com'),
|
|
1078
|
+
cancelUrl: new URL('https://www.example.com'),
|
|
1079
|
+
// Only the non-trial registration has to be paid now
|
|
1080
|
+
totalPrice: 1500,
|
|
1081
|
+
});
|
|
1082
|
+
// #endregion
|
|
1083
|
+
|
|
1084
|
+
// act
|
|
1085
|
+
const response = await post(body, organization, token);
|
|
1086
|
+
|
|
1087
|
+
// assert
|
|
1088
|
+
expect(response.body.registrations.length).toBe(2);
|
|
1089
|
+
expect(response.body.paymentUrl).toMatch(/payconiq-checkout\.test/);
|
|
1090
|
+
|
|
1091
|
+
// The trial is free, so it is activated right away and its reservation is released
|
|
1092
|
+
const trialRegistration = response.body.registrations.find(r => r.group.id === group.id)!;
|
|
1093
|
+
expect(trialRegistration).toMatchObject({
|
|
1094
|
+
registeredAt: expect.any(Date),
|
|
1095
|
+
deactivatedAt: null,
|
|
1096
|
+
reservedUntil: null,
|
|
1097
|
+
trialUntil: expect.any(Date),
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const trialBalanceItems = await BalanceItem.where({ registrationId: trialRegistration.id });
|
|
1101
|
+
expect(trialBalanceItems.length).toBe(1);
|
|
1102
|
+
expect(trialBalanceItems[0]).toMatchObject({
|
|
1103
|
+
status: BalanceItemStatus.Due,
|
|
1104
|
+
pricePaid: 0,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// The paid registration is not activated: it still waits for the online payment
|
|
1108
|
+
const paidRegistration = response.body.registrations.find(r => r.group.id === paidGroup.id)!;
|
|
1109
|
+
expect(paidRegistration).toMatchObject({
|
|
1110
|
+
registeredAt: null,
|
|
1111
|
+
deactivatedAt: null,
|
|
1112
|
+
reservedUntil: expect.any(Date),
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
await group.refresh();
|
|
1116
|
+
expect(group.settings.registeredMembers).toBe(1);
|
|
1117
|
+
expect(group.settings.reservedMembers).toBe(0);
|
|
1118
|
+
|
|
1119
|
+
await paidGroup.refresh();
|
|
1120
|
+
expect(paidGroup.settings.registeredMembers).toBe(0);
|
|
1121
|
+
expect(paidGroup.settings.reservedMembers).toBe(1);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
974
1124
|
test('Should update group stock reservations', async () => {
|
|
975
1125
|
// #region arrange
|
|
976
1126
|
const { organization, group, groupPrice, token, member } = await initData();
|
|
@@ -835,9 +835,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
835
835
|
const mappedBalanceItems = new Map<BalanceItem, number>();
|
|
836
836
|
|
|
837
837
|
for (const item of createdBalanceItems) {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
838
|
+
// Items that aren't due yet (the trial period) are not paid now, so they are passed
|
|
839
|
+
// along with an amount of 0: createPayment keeps them out of the payment itself, but
|
|
840
|
+
// does mark them due + valid (which activates the registration).
|
|
841
|
+
mappedBalanceItems.set(item, item.dueAt !== null ? 0 : item.price);
|
|
841
842
|
}
|
|
842
843
|
|
|
843
844
|
for (const item of checkout.cart.balanceItems) {
|