@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.
Files changed (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. 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, sendEmailTemplate, UsedRegisterCode } from '@stamhoofd/models';
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 payingOrganization.getFinanceAdminRecipients(),
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 payingOrganization.getFinanceAdminRecipients(),
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 membership.calculatePrice(me, cheapestMembership.registration);
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 m.calculatePrice(me, cheapestMembership.registration);
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 m.calculatePrice(me);
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 otherOrganization.getAdminToEmails();
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 receivingOrganization.getAdminRecipients();
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, sendEmailTemplate } from '@stamhoofd/models';
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',