@stamhoofd/backend 2.137.4 → 2.138.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 +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- package/tsconfig.build.json +2 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { I18n } from '@stamhoofd/backend-i18n';
|
|
2
|
+
import type { Organization } from '@stamhoofd/models';
|
|
3
|
+
import { PasswordToken, Platform, User } from '@stamhoofd/models';
|
|
4
|
+
import { getAppHost } from '@stamhoofd/structures';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builds the password recovery links. Which app the link points to depends on whether the user is an
|
|
8
|
+
* administrator of the organization.
|
|
9
|
+
*/
|
|
10
|
+
export class PasswordForgotService {
|
|
11
|
+
/**
|
|
12
|
+
* Create a new password token for the user and build the recovery url for it.
|
|
13
|
+
*/
|
|
14
|
+
static async getPasswordRecoveryUrl(user: User, organization: Organization | null, i18n: I18n, validUntil?: Date) {
|
|
15
|
+
// Send an e-mail to say you already have an account + follow password forgot flow
|
|
16
|
+
const token = await PasswordToken.createToken(user, validUntil);
|
|
17
|
+
return await this.getPasswordRecoveryUrlForToken(token, organization, i18n, user);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the recovery url for an already created password token.
|
|
22
|
+
* Pass the user to avoid an extra query when it is already loaded.
|
|
23
|
+
*/
|
|
24
|
+
static async getPasswordRecoveryUrlForToken(token: PasswordToken, organization: Organization | null, i18n: I18n, user?: User) {
|
|
25
|
+
const tokenUser = user ?? await User.getByID(token.userId);
|
|
26
|
+
if (!tokenUser) {
|
|
27
|
+
throw new Error('PasswordToken without a valid user');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (tokenUser.organizationId !== null && ((tokenUser.organizationId ?? null) !== (organization?.id ?? null))) {
|
|
31
|
+
throw new Error('Unexpected mismatch in organization id for PasswordToken');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const hasOrganizationPermissions = organization ? tokenUser.permissions?.forOrganization(organization, await Platform.getSharedStruct())?.isEmpty === false : false;
|
|
35
|
+
const host = 'https://' + getAppHost(hasOrganizationPermissions ? 'dashboard' : 'registration', organization, hasOrganizationPermissions, i18n);
|
|
36
|
+
return host + '/reset-password?token=' + encodeURIComponent(token.token);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import type { Member, User } from '@stamhoofd/models';
|
|
3
|
-
import { BalanceItem, BalanceItemPayment, Group, MolliePayment, Organization, PayconiqPayment, Payment, Platform,
|
|
3
|
+
import { BalanceItem, BalanceItemPayment, Group, MolliePayment, Organization, PayconiqPayment, Payment, Platform, UsedRegisterCode } from '@stamhoofd/models';
|
|
4
|
+
import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
|
|
4
5
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
6
|
import type { Checkoutable, PaymentConfiguration, PrivatePaymentConfiguration } from '@stamhoofd/structures';
|
|
6
7
|
import { AuditLogSource, BalanceItemPaymentDetailed, BalanceItemType, EmailTemplateType, Invoice, PaymentCustomer, PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PaymentType, Recipient, Replacement, VATExcemptReason, Version } from '@stamhoofd/structures';
|
|
@@ -22,6 +23,7 @@ import { PaymentMandateService } from './PaymentMandateService.js';
|
|
|
22
23
|
import { ReferralService } from './ReferralService.js';
|
|
23
24
|
import { VATService } from './VATService.js';
|
|
24
25
|
import { STPackageService } from './STPackageService.js';
|
|
26
|
+
import { OrganizationAdminService } from './OrganizationAdminService.js';
|
|
25
27
|
|
|
26
28
|
export class PaymentService {
|
|
27
29
|
static async updateReversedPaymentsFor(payment: Payment) {
|
|
@@ -237,7 +239,7 @@ export class PaymentService {
|
|
|
237
239
|
const payingOrganization = await Organization.getByID(payment.payingOrganizationId, true);
|
|
238
240
|
|
|
239
241
|
await sendEmailTemplate(organization, {
|
|
240
|
-
recipients: await
|
|
242
|
+
recipients: await OrganizationAdminService.getFinanceAdminRecipients(payingOrganization),
|
|
241
243
|
defaultReplacements: [
|
|
242
244
|
Replacement.create({
|
|
243
245
|
token: 'payingOrganizationName',
|
|
@@ -274,7 +276,7 @@ export class PaymentService {
|
|
|
274
276
|
const payingOrganization = await Organization.getByID(payment.payingOrganizationId, true);
|
|
275
277
|
|
|
276
278
|
await sendEmailTemplate(organization, {
|
|
277
|
-
recipients: await
|
|
279
|
+
recipients: await OrganizationAdminService.getFinanceAdminRecipients(payingOrganization),
|
|
278
280
|
defaultReplacements: [
|
|
279
281
|
Replacement.create({
|
|
280
282
|
token: 'payingOrganizationName',
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { MemberFactory, MemberPlatformMembership, OrganizationFactory, Platform, RegistrationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
|
|
2
|
+
import { PlatformMembershipType, PlatformMembershipTypeBehaviour, PlatformMembershipTypeConfig, PlatformMembershipTypeConfigPrice, ReduceablePrice } from '@stamhoofd/structures';
|
|
3
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
4
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
import { PlatformMembershipService } from './PlatformMembershipService.js';
|
|
6
|
+
|
|
7
|
+
describe('PlatformMembershipService.calculatePrice', () => {
|
|
8
|
+
const membershipPrice = 25_00;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
12
|
+
vitest.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] }).setSystemTime(new Date(2024, 4, 1, 0, 0, 0, 0));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vitest.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function setupDaysMembership(maximumDays: number) {
|
|
20
|
+
const period = await new RegistrationPeriodFactory({
|
|
21
|
+
startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
|
|
22
|
+
endDate: new Date(2024, 11, 31, 23, 59, 59, 999),
|
|
23
|
+
}).create();
|
|
24
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
25
|
+
const member = await new MemberFactory({ organization }).create();
|
|
26
|
+
const membershipType = PlatformMembershipType.create({
|
|
27
|
+
name: 'Days membership',
|
|
28
|
+
behaviour: PlatformMembershipTypeBehaviour.Days,
|
|
29
|
+
periods: new Map([
|
|
30
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
31
|
+
startDate: period.startDate,
|
|
32
|
+
endDate: period.endDate,
|
|
33
|
+
maximumDays,
|
|
34
|
+
})],
|
|
35
|
+
]),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const platform = await Platform.getForEditing();
|
|
39
|
+
platform.periodId = period.id;
|
|
40
|
+
platform.config.membershipTypes = [membershipType];
|
|
41
|
+
await platform.save();
|
|
42
|
+
|
|
43
|
+
const membership = new MemberPlatformMembership();
|
|
44
|
+
membership.memberId = member.id;
|
|
45
|
+
membership.membershipTypeId = membershipType.id;
|
|
46
|
+
membership.organizationId = organization.id;
|
|
47
|
+
membership.periodId = period.id;
|
|
48
|
+
|
|
49
|
+
return { member, membership };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a period membership type with a single configured price, and a membership that is
|
|
54
|
+
* ready to be priced. The period has a previous period, which is required to become eligible
|
|
55
|
+
* for a trial.
|
|
56
|
+
*/
|
|
57
|
+
async function setupPeriodMembership(options: { trialDays?: number } = {}) {
|
|
58
|
+
const previousPeriod = await new RegistrationPeriodFactory({
|
|
59
|
+
startDate: new Date(2023, 0, 1, 0, 0, 0, 0),
|
|
60
|
+
endDate: new Date(2023, 11, 31, 23, 59, 59, 0),
|
|
61
|
+
}).create();
|
|
62
|
+
|
|
63
|
+
const period = await new RegistrationPeriodFactory({
|
|
64
|
+
startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
|
|
65
|
+
endDate: new Date(2024, 11, 31, 23, 59, 59, 0),
|
|
66
|
+
previousPeriodId: previousPeriod.id,
|
|
67
|
+
}).create();
|
|
68
|
+
|
|
69
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
70
|
+
const member = await new MemberFactory({ organization }).create();
|
|
71
|
+
|
|
72
|
+
const membershipType = PlatformMembershipType.create({
|
|
73
|
+
name: 'Period membership',
|
|
74
|
+
behaviour: PlatformMembershipTypeBehaviour.Period,
|
|
75
|
+
periods: new Map([
|
|
76
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
77
|
+
startDate: period.startDate,
|
|
78
|
+
endDate: period.endDate,
|
|
79
|
+
trialDays: options.trialDays ?? 0,
|
|
80
|
+
prices: [
|
|
81
|
+
PlatformMembershipTypeConfigPrice.create({
|
|
82
|
+
prices: new Map([['', ReduceablePrice.create({ price: membershipPrice })]]),
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
})],
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const platform = await Platform.getForEditing();
|
|
90
|
+
platform.periodId = period.id;
|
|
91
|
+
platform.config.membershipTypes = [membershipType];
|
|
92
|
+
await platform.save();
|
|
93
|
+
|
|
94
|
+
const membership = new MemberPlatformMembership();
|
|
95
|
+
membership.memberId = member.id;
|
|
96
|
+
membership.membershipTypeId = membershipType.id;
|
|
97
|
+
membership.organizationId = organization.id;
|
|
98
|
+
membership.periodId = period.id;
|
|
99
|
+
membership.startDate = period.startDate;
|
|
100
|
+
membership.endDate = period.endDate;
|
|
101
|
+
|
|
102
|
+
return { member, membership, organization, period };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('allows the inclusive maximum number of days', async () => {
|
|
106
|
+
const { member, membership } = await setupDaysMembership(2);
|
|
107
|
+
membership.startDate = new Date(2024, 4, 1, 0, 0, 0, 0);
|
|
108
|
+
membership.endDate = new Date(2024, 4, 2, 0, 0, 0, 0);
|
|
109
|
+
|
|
110
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
111
|
+
expect(membership.maximumFreeAmount).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('rejects days memberships that exceed maximum days', async () => {
|
|
115
|
+
const { member, membership } = await setupDaysMembership(2);
|
|
116
|
+
membership.startDate = new Date(2024, 4, 1, 0, 0, 0, 0);
|
|
117
|
+
membership.endDate = new Date(2024, 4, 3, 0, 0, 0, 0);
|
|
118
|
+
|
|
119
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).rejects.toMatchObject({
|
|
120
|
+
code: 'invalid_field',
|
|
121
|
+
field: 'endDate',
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('calculates the configured price of a period membership', async () => {
|
|
126
|
+
const { member, membership } = await setupPeriodMembership();
|
|
127
|
+
|
|
128
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
129
|
+
|
|
130
|
+
expect(membership.price).toBe(membershipPrice);
|
|
131
|
+
expect(membership.priceWithoutDiscount).toBe(membershipPrice);
|
|
132
|
+
expect(membership.freeAmount).toBe(0);
|
|
133
|
+
expect(membership.maximumFreeAmount).toBe(1);
|
|
134
|
+
expect(membership.trialUntil).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('does not recalculate the price of a locked membership', async () => {
|
|
138
|
+
const { member, membership } = await setupPeriodMembership();
|
|
139
|
+
membership.locked = true;
|
|
140
|
+
membership.price = 1_00;
|
|
141
|
+
membership.priceWithoutDiscount = 2_00;
|
|
142
|
+
|
|
143
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
144
|
+
|
|
145
|
+
expect(membership.price).toBe(1_00);
|
|
146
|
+
expect(membership.priceWithoutDiscount).toBe(2_00);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('rejects a membership with an unknown membership type', async () => {
|
|
150
|
+
const { member, membership } = await setupPeriodMembership();
|
|
151
|
+
membership.membershipTypeId = 'unknown-membership-type';
|
|
152
|
+
|
|
153
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).rejects.toMatchObject({
|
|
154
|
+
code: 'invalid_membership_type',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The price is not silently set to zero
|
|
158
|
+
expect(membership.price).toBe(0);
|
|
159
|
+
expect(membership.priceWithoutDiscount).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('grants a trial period only when the member has a registration with a trial', async () => {
|
|
163
|
+
const { member, membership, organization } = await setupPeriodMembership({ trialDays: 14 });
|
|
164
|
+
|
|
165
|
+
// Without a registration that had a trial, the member is not eligible for a trial
|
|
166
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
167
|
+
expect(membership.trialUntil).toBeNull();
|
|
168
|
+
|
|
169
|
+
const registration = await new RegistrationFactory({ member, organization }).create();
|
|
170
|
+
registration.trialUntil = new Date(2024, 4, 20, 12, 0, 0, 0);
|
|
171
|
+
await registration.save();
|
|
172
|
+
|
|
173
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
174
|
+
|
|
175
|
+
// The trial ends trialDays after the start date of the membership
|
|
176
|
+
expect(membership.trialUntil).not.toBeNull();
|
|
177
|
+
expect(Formatter.dateIso(membership.trialUntil!)).toBe('2024-01-15');
|
|
178
|
+
expect(membership.price).toBe(membershipPrice);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Model } from '@simonbackx/simple-database';
|
|
2
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
3
|
import { logger } from '@simonbackx/simple-logging';
|
|
3
|
-
import { Group, Member, MemberPlatformMembership, Organization, Platform, Registration, RegistrationPeriod } from '@stamhoofd/models';
|
|
4
|
+
import { BalanceItem, Group, Member, MemberPlatformMembership, Organization, Platform, Registration, RegistrationPeriod } from '@stamhoofd/models';
|
|
4
5
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
6
|
import { SQL, SQLWhereSign } from '@stamhoofd/sql';
|
|
6
7
|
import { AuditLogSource, PlatformMembershipTypeBehaviour } from '@stamhoofd/structures';
|
|
@@ -331,7 +332,7 @@ export class PlatformMembershipService {
|
|
|
331
332
|
membership.endDate = periodConfig.endDate;
|
|
332
333
|
membership.expireDate = periodConfig.expireDate;
|
|
333
334
|
membership.generated = true;
|
|
334
|
-
await
|
|
335
|
+
await PlatformMembershipService.calculatePrice(membership, me, cheapestMembership.registration);
|
|
335
336
|
|
|
336
337
|
// Check if we have a not-locked but non-generated one that is cheaper
|
|
337
338
|
// if so, we stop and don't create a new one (but still delete others if required)
|
|
@@ -353,7 +354,7 @@ export class PlatformMembershipService {
|
|
|
353
354
|
if (!m.locked && m.generated) {
|
|
354
355
|
// Update the price and dates of this active membership (could have changed)
|
|
355
356
|
try {
|
|
356
|
-
await
|
|
357
|
+
await PlatformMembershipService.calculatePrice(m, me, cheapestMembership.registration);
|
|
357
358
|
} catch (e) {
|
|
358
359
|
// Ignore error: membership might not be available anymore
|
|
359
360
|
if (!silent) {
|
|
@@ -381,7 +382,7 @@ export class PlatformMembershipService {
|
|
|
381
382
|
// Update price
|
|
382
383
|
if (!m.locked) {
|
|
383
384
|
try {
|
|
384
|
-
await
|
|
385
|
+
await PlatformMembershipService.calculatePrice(m, me);
|
|
385
386
|
} catch (e) {
|
|
386
387
|
// Ignore error: membership might not be available anymore
|
|
387
388
|
if (!silent) {
|
|
@@ -419,4 +420,280 @@ export class PlatformMembershipService {
|
|
|
419
420
|
});
|
|
420
421
|
});
|
|
421
422
|
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Returns the last trial until date for this member. Null if this member does not have any trials
|
|
426
|
+
*/
|
|
427
|
+
private static async isElegibleForTrial(membership: MemberPlatformMembership, member: Member) {
|
|
428
|
+
const period = await RegistrationPeriod.getByID(membership.periodId);
|
|
429
|
+
if (!period) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!period.previousPeriodId) {
|
|
434
|
+
// We have no previous period = no data = no trials
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const platform = await Platform.getSharedStruct();
|
|
439
|
+
const typeIds = platform.config.membershipTypes.filter(m => m.behaviour === PlatformMembershipTypeBehaviour.Period).map(m => m.id);
|
|
440
|
+
|
|
441
|
+
const previousMembership = await MemberPlatformMembership.select()
|
|
442
|
+
.where('memberId', member.id)
|
|
443
|
+
.where('deletedAt', null)
|
|
444
|
+
.where('periodId', period.previousPeriodId)
|
|
445
|
+
.where('membershipTypeId', typeIds)
|
|
446
|
+
.first(false);
|
|
447
|
+
|
|
448
|
+
const hasBlockingMemberships = !!previousMembership;
|
|
449
|
+
|
|
450
|
+
if (hasBlockingMemberships) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The member needs to be registered for at least one registration that had a trial
|
|
455
|
+
const registrations = await Registration.select()
|
|
456
|
+
.where('memberId', member.id)
|
|
457
|
+
.where('periodId', period.id)
|
|
458
|
+
.where('organizationId', membership.organizationId)
|
|
459
|
+
.where('deactivatedAt', null)
|
|
460
|
+
.where('registeredAt', '!=', null)
|
|
461
|
+
.where('trialUntil', '!=', null)
|
|
462
|
+
.fetch();
|
|
463
|
+
|
|
464
|
+
if (registrations.length === 0) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const latestTrialUntil = new Date(Math.max(...registrations.map(r => r.trialUntil!.getTime())));
|
|
469
|
+
return latestTrialUntil;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private static async correctDates(membership: MemberPlatformMembership, member: Member, registration?: Registration) {
|
|
473
|
+
if (membership.locked) {
|
|
474
|
+
// price of locked membership cannot be changed
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
479
|
+
const membershipType = platform.config.membershipTypes.find(m => m.id === membership.membershipTypeId);
|
|
480
|
+
|
|
481
|
+
if (!membershipType) {
|
|
482
|
+
throw new SimpleError({
|
|
483
|
+
code: 'invalid_membership_type',
|
|
484
|
+
message: 'Unknown membership type',
|
|
485
|
+
human: $t(`%GM`),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const periodConfig = membershipType.periods.get(membership.periodId);
|
|
490
|
+
|
|
491
|
+
if (!periodConfig) {
|
|
492
|
+
throw new SimpleError({
|
|
493
|
+
code: 'period_unavailable',
|
|
494
|
+
message: 'Membership not available for this period',
|
|
495
|
+
human: $t(`%GN`),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days) {
|
|
500
|
+
// Make sure time is equal between start and end date
|
|
501
|
+
let startBrussels = Formatter.luxon(membership.startDate);
|
|
502
|
+
let endBrussels = Formatter.luxon(membership.endDate);
|
|
503
|
+
startBrussels = startBrussels.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
|
504
|
+
endBrussels = endBrussels.set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
|
|
505
|
+
membership.startDate = startBrussels.toJSDate();
|
|
506
|
+
membership.endDate = endBrussels.toJSDate();
|
|
507
|
+
membership.expireDate = null;
|
|
508
|
+
} else {
|
|
509
|
+
membership.endDate = Formatter.luxon(periodConfig.endDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 }).toJSDate();
|
|
510
|
+
membership.expireDate = periodConfig.expireDate ? Formatter.luxon(periodConfig.expireDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 }).toJSDate() : null;
|
|
511
|
+
|
|
512
|
+
// Alter start date
|
|
513
|
+
if (registration) {
|
|
514
|
+
const preferredStartDate = registration.startDate ?? registration.registeredAt ?? membership.createdAt;
|
|
515
|
+
membership.startDate = periodConfig.startDate;
|
|
516
|
+
|
|
517
|
+
if (preferredStartDate > periodConfig.startDate && preferredStartDate < periodConfig.endDate) {
|
|
518
|
+
let startBrussels = Formatter.luxon(preferredStartDate);
|
|
519
|
+
startBrussels = startBrussels.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
|
520
|
+
membership.startDate = startBrussels.toJSDate();
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
// Keep the set date, but make sure it is at 0:00 in CET
|
|
524
|
+
let startBrussels = Formatter.luxon(membership.startDate);
|
|
525
|
+
startBrussels = startBrussels.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
|
526
|
+
membership.startDate = startBrussels.toJSDate();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let minimumStartDate = Formatter.luxon(periodConfig.startDate).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
|
|
531
|
+
|
|
532
|
+
if (!membership.existsInDatabase) {
|
|
533
|
+
if (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days) {
|
|
534
|
+
// Cannot create a membership in the past
|
|
535
|
+
minimumStartDate = Formatter.luxon(new Date()).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (membership.startDate < minimumStartDate) {
|
|
540
|
+
membership.startDate = minimumStartDate;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (membership.endDate < membership.startDate) {
|
|
544
|
+
membership.endDate = Formatter.luxon(membership.startDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 }).toJSDate();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const maximumEndDate = periodConfig.getMaximumEndDate(membership.startDate, membershipType.behaviour);
|
|
548
|
+
if (membership.endDate > maximumEndDate) {
|
|
549
|
+
throw new SimpleError({
|
|
550
|
+
code: 'invalid_field',
|
|
551
|
+
field: 'endDate',
|
|
552
|
+
message: 'End date is after the maximum allowed end date',
|
|
553
|
+
human: $t('%15C', { date: Formatter.date(maximumEndDate) }),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (periodConfig.trialDays) {
|
|
558
|
+
// Check whether you are elegible for a trial
|
|
559
|
+
const latestTrialDate = await PlatformMembershipService.isElegibleForTrial(membership, member);
|
|
560
|
+
if (latestTrialDate !== null) {
|
|
561
|
+
// Allowed to set trial until, maximum periodConfig.trialDays after startDate
|
|
562
|
+
let trialUntil = Formatter.luxon(membership.startDate).plus({ days: periodConfig.trialDays });
|
|
563
|
+
trialUntil = trialUntil.set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
|
|
564
|
+
|
|
565
|
+
if (trialUntil.toJSDate() > latestTrialDate) {
|
|
566
|
+
// Use the latest trial date instead
|
|
567
|
+
trialUntil = Formatter.luxon(latestTrialDate);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Max end date
|
|
571
|
+
if (trialUntil.toJSDate() > membership.endDate) {
|
|
572
|
+
trialUntil = Formatter.luxon(membership.endDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
membership.trialUntil = trialUntil.toJSDate();
|
|
576
|
+
} else {
|
|
577
|
+
membership.trialUntil = null;
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
// No trial
|
|
581
|
+
membership.trialUntil = null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
static async calculatePrice(membership: MemberPlatformMembership, member: Member, registration?: Registration) {
|
|
586
|
+
if (membership.locked) {
|
|
587
|
+
// price of locked membership cannot be changed
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
592
|
+
const membershipType = platform.config.membershipTypes.find(m => m.id === membership.membershipTypeId);
|
|
593
|
+
|
|
594
|
+
if (!membershipType) {
|
|
595
|
+
throw new SimpleError({
|
|
596
|
+
code: 'invalid_membership_type',
|
|
597
|
+
message: 'Unknown membership type',
|
|
598
|
+
human: $t(`%GM`),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const periodConfig = membershipType.periods.get(membership.periodId);
|
|
603
|
+
|
|
604
|
+
if (!periodConfig) {
|
|
605
|
+
throw new SimpleError({
|
|
606
|
+
code: 'period_unavailable',
|
|
607
|
+
message: 'Membership not available for this period',
|
|
608
|
+
human: $t(`%GN`),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const organization = await Organization.getByID(membership.organizationId);
|
|
613
|
+
if (!organization) {
|
|
614
|
+
throw new SimpleError({
|
|
615
|
+
// todo
|
|
616
|
+
code: 'not_found',
|
|
617
|
+
message: 'Organization not found',
|
|
618
|
+
human: $t(`%GO`),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const tagIds = organization.meta.tags;
|
|
623
|
+
const shouldApplyReducedPrice = member.details.shouldApplyReducedPrice;
|
|
624
|
+
|
|
625
|
+
// Correct dates so we get the correct price config
|
|
626
|
+
await PlatformMembershipService.correctDates(membership, member, registration);
|
|
627
|
+
const priceConfig = periodConfig.getPriceConfigForDate(membership.trialUntil ?? membership.startDate);
|
|
628
|
+
const earliestPriceConfig = periodConfig.getPriceConfigForDate(new Date(1950, 0, 1));
|
|
629
|
+
|
|
630
|
+
let freeDays = 0;
|
|
631
|
+
|
|
632
|
+
const d = membership.createdAt ?? new Date();
|
|
633
|
+
|
|
634
|
+
if (periodConfig.amountFree) {
|
|
635
|
+
// Check if this organization has rights to free memberships
|
|
636
|
+
const alreadyUsed = await MemberPlatformMembership.select()
|
|
637
|
+
.where('organizationId', membership.organizationId)
|
|
638
|
+
.where('membershipTypeId', membership.membershipTypeId)
|
|
639
|
+
.where('periodId', membership.periodId)
|
|
640
|
+
.where('deletedAt', null)
|
|
641
|
+
.whereNot('id', membership.id)
|
|
642
|
+
.where(
|
|
643
|
+
SQL.where('createdAt', SQLWhereSign.Less, d)
|
|
644
|
+
.or(
|
|
645
|
+
SQL.where('createdAt', SQLWhereSign.Equal, d)
|
|
646
|
+
.and('id', SQLWhereSign.Less, membership.id),
|
|
647
|
+
),
|
|
648
|
+
)
|
|
649
|
+
.sum(SQL.column('maximumFreeAmount'));
|
|
650
|
+
|
|
651
|
+
if (alreadyUsed < periodConfig.amountFree) {
|
|
652
|
+
freeDays = periodConfig.amountFree - alreadyUsed;
|
|
653
|
+
console.log('Free membership created for ', membership.id, periodConfig.amountFree, alreadyUsed);
|
|
654
|
+
} else {
|
|
655
|
+
console.log('No free membership created for', membership.id, periodConfig.amountFree, alreadyUsed);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days) {
|
|
660
|
+
const days = Math.round((membership.endDate.getTime() - membership.startDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
661
|
+
membership.maximumFreeAmount = days;
|
|
662
|
+
membership.priceWithoutDiscount = earliestPriceConfig.calculatePrice(tagIds, false, days);
|
|
663
|
+
membership.price = priceConfig.calculatePrice(tagIds, shouldApplyReducedPrice, Math.max(0, days - freeDays));
|
|
664
|
+
membership.freeAmount = Math.min(days, freeDays);
|
|
665
|
+
} else {
|
|
666
|
+
membership.priceWithoutDiscount = earliestPriceConfig.getBasePrice(tagIds, false);
|
|
667
|
+
membership.price = priceConfig.getBasePrice(tagIds, shouldApplyReducedPrice);
|
|
668
|
+
membership.maximumFreeAmount = membership.price > 0 ? 1 : 0;
|
|
669
|
+
membership.freeAmount = 0;
|
|
670
|
+
|
|
671
|
+
if (freeDays > 0) {
|
|
672
|
+
membership.price = 0;
|
|
673
|
+
membership.freeAmount = 1;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Never charge itself
|
|
678
|
+
const chargeVia = platform.membershipOrganizationId;
|
|
679
|
+
if (membership.organizationId === chargeVia) {
|
|
680
|
+
membership.price = 0;
|
|
681
|
+
membership.priceWithoutDiscount = 0;
|
|
682
|
+
membership.freeAmount = 0;
|
|
683
|
+
membership.maximumFreeAmount = 0;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (membership.balanceItemId) {
|
|
687
|
+
membership.maximumFreeAmount = membership.freeAmount;
|
|
688
|
+
|
|
689
|
+
// Also update the balance item
|
|
690
|
+
const balanceItem = await BalanceItem.getByID(membership.balanceItemId);
|
|
691
|
+
if (balanceItem) {
|
|
692
|
+
balanceItem.unitPrice = membership.price;
|
|
693
|
+
await balanceItem.save();
|
|
694
|
+
|
|
695
|
+
// await BalanceItem.updateOutstanding([balanceItem]);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
422
699
|
}
|
|
@@ -6,6 +6,7 @@ import { BalanceItem, Organization, Platform, RegisterCode, UsedRegisterCode } f
|
|
|
6
6
|
import { BalanceItemStatus, BalanceItemType } from '@stamhoofd/structures';
|
|
7
7
|
import { Formatter } from '@stamhoofd/utility';
|
|
8
8
|
import { VATService } from './VATService.js';
|
|
9
|
+
import { OrganizationAdminService } from './OrganizationAdminService.js';
|
|
9
10
|
|
|
10
11
|
export class ReferralService {
|
|
11
12
|
/**
|
|
@@ -76,7 +77,7 @@ export class ReferralService {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
if (otherOrganization) {
|
|
79
|
-
const admins = await
|
|
80
|
+
const admins = await OrganizationAdminService.getAdminToEmails(otherOrganization);
|
|
80
81
|
if (admins) {
|
|
81
82
|
if (code.invoiceValue) {
|
|
82
83
|
// Delay email until everything is validated and saved
|
|
@@ -196,7 +197,7 @@ export class ReferralService {
|
|
|
196
197
|
await item.save();
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
const admins = await
|
|
200
|
+
const admins = await OrganizationAdminService.getAdminRecipients(receivingOrganization);
|
|
200
201
|
if (admins) {
|
|
201
202
|
if (code.invoiceValue) {
|
|
202
203
|
Email.send({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ManyToOneRelation } from '@simonbackx/simple-database';
|
|
2
2
|
import { encodeObject } from '@simonbackx/simple-encoding';
|
|
3
|
-
import { BalanceItem, Document, Group, Member, Organization, Registration, RegistrationInvitation
|
|
3
|
+
import { BalanceItem, Document, Group, Member, Organization, Platform, Registration, RegistrationInvitation } from '@stamhoofd/models';
|
|
4
|
+
import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
|
|
4
5
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
6
|
import { AppliedRegistrationDiscount, AuditLogSource, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, EmailTemplateType, getAppHost, GroupType, Recipient, Replacement, StockReservation, Version } from '@stamhoofd/structures';
|
|
6
7
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -150,6 +151,7 @@ export const RegistrationService = {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
const allowedEmails = member.details.getNotificationEmails();
|
|
154
|
+
const platform = await Platform.getSharedStruct();
|
|
153
155
|
|
|
154
156
|
return member.users.map(user => Recipient.create({
|
|
155
157
|
firstName: user.firstName,
|
|
@@ -167,7 +169,7 @@ export const RegistrationService = {
|
|
|
167
169
|
}),
|
|
168
170
|
Replacement.create({
|
|
169
171
|
token: 'registerUrl',
|
|
170
|
-
value: 'https://' + getAppHost('registration', organization, user.permissions?.forOrganization(organization)?.isEmpty === false),
|
|
172
|
+
value: 'https://' + getAppHost('registration', organization, user.permissions?.forOrganization(organization, platform, { inheritFromPlatform: false })?.isEmpty === false),
|
|
171
173
|
}),
|
|
172
174
|
Replacement.create({
|
|
173
175
|
token: 'groupName',
|