@stamhoofd/backend 2.58.0 → 2.59.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 (48) hide show
  1. package/index.ts +6 -1
  2. package/package.json +12 -12
  3. package/src/audit-logs/EventLogger.ts +30 -0
  4. package/src/audit-logs/GroupLogger.ts +95 -0
  5. package/src/audit-logs/MemberLogger.ts +24 -0
  6. package/src/audit-logs/MemberPlatformMembershipLogger.ts +57 -0
  7. package/src/audit-logs/MemberResponsibilityRecordLogger.ts +69 -0
  8. package/src/audit-logs/ModelLogger.ts +218 -0
  9. package/src/audit-logs/OrderLogger.ts +57 -0
  10. package/src/audit-logs/OrganizationLogger.ts +26 -0
  11. package/src/audit-logs/OrganizationRegistrationPeriodLogger.ts +77 -0
  12. package/src/audit-logs/PaymentLogger.ts +43 -0
  13. package/src/audit-logs/PlatformLogger.ts +13 -0
  14. package/src/audit-logs/RegistrationLogger.ts +53 -0
  15. package/src/audit-logs/RegistrationPeriodLogger.ts +21 -0
  16. package/src/audit-logs/StripeAccountLogger.ts +47 -0
  17. package/src/audit-logs/WebshopLogger.ts +35 -0
  18. package/src/crons.ts +2 -1
  19. package/src/endpoints/global/events/PatchEventsEndpoint.ts +12 -24
  20. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +4 -18
  21. package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +5 -13
  22. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +3 -11
  23. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +0 -15
  24. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +5 -2
  25. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +0 -19
  26. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -12
  27. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +18 -33
  28. package/src/endpoints/organization/dashboard/stripe/ConnectStripeEndpoint.ts +0 -6
  29. package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +0 -6
  30. package/src/endpoints/organization/dashboard/stripe/UpdateStripeAccountEndpoint.ts +5 -14
  31. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +7 -4
  32. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -2
  33. package/src/helpers/AuthenticatedStructures.ts +16 -1
  34. package/src/helpers/Context.ts +8 -2
  35. package/src/helpers/MemberUserSyncer.ts +45 -40
  36. package/src/helpers/PeriodHelper.ts +3 -4
  37. package/src/helpers/TagHelper.ts +23 -20
  38. package/src/seeds/1722344162-update-membership.ts +2 -2
  39. package/src/seeds/1726572303-schedule-stock-updates.ts +2 -1
  40. package/src/services/AuditLogService.ts +83 -295
  41. package/src/services/BalanceItemPaymentService.ts +1 -1
  42. package/src/services/BalanceItemService.ts +14 -5
  43. package/src/services/MemberNumberService.ts +120 -0
  44. package/src/services/PaymentService.ts +199 -193
  45. package/src/services/PlatformMembershipService.ts +284 -0
  46. package/src/services/RegistrationService.ts +76 -27
  47. package/src/services/explainPatch.ts +110 -41
  48. package/src/helpers/MembershipHelper.ts +0 -54
@@ -0,0 +1,284 @@
1
+ import { Model } from '@simonbackx/simple-database';
2
+ import { logger } from '@simonbackx/simple-logging';
3
+ import { Group, Member, MemberPlatformMembership, Organization, Platform, Registration } from '@stamhoofd/models';
4
+ import { QueueHandler } from '@stamhoofd/queues';
5
+ import { SQL } from '@stamhoofd/sql';
6
+ import { AuditLogSource } from '@stamhoofd/structures';
7
+ import { Formatter, Sorter } from '@stamhoofd/utility';
8
+ import { AuditLogService } from './AuditLogService';
9
+ import { MemberNumberService } from './MemberNumberService';
10
+
11
+ export class PlatformMembershipService {
12
+ static listen() {
13
+ // Listen for group changes
14
+ Model.modelEventBus.addListener(this, (event) => {
15
+ if (!(event.model instanceof Group)) {
16
+ return;
17
+ }
18
+
19
+ // Check if group has been deleted
20
+ if (event.type === 'deleted' || (event.type === 'updated' && (event.changedFields['deletedAt'] !== undefined || event.changedFields['defaultAgeGroupId'] !== undefined))) {
21
+ PlatformMembershipService.updateMembershipsForGroupId(event.model.id);
22
+ }
23
+ });
24
+ }
25
+
26
+ static async updateAll() {
27
+ console.log('Scheduling updateAllMemberships');
28
+
29
+ let c = 0;
30
+ let id: string = '';
31
+ const tag = 'updateAllMemberships';
32
+ const batch = 100;
33
+
34
+ QueueHandler.cancel(tag);
35
+
36
+ await QueueHandler.schedule(tag, async () => {
37
+ console.log('Starting updateAllMemberships');
38
+ await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
39
+ while (true) {
40
+ const rawMembers = await Member.where({
41
+ id: {
42
+ value: id,
43
+ sign: '>',
44
+ },
45
+ }, { limit: batch, sort: ['id'] });
46
+
47
+ if (rawMembers.length === 0) {
48
+ break;
49
+ }
50
+
51
+ const promises: Promise<any>[] = [];
52
+
53
+ for (const member of rawMembers) {
54
+ promises.push((async () => {
55
+ await PlatformMembershipService.updateMembershipsForId(member.id, true);
56
+ c++;
57
+
58
+ if (c % 10000 === 0) {
59
+ process.stdout.write(c + ' members updated\n');
60
+ }
61
+ })());
62
+ }
63
+
64
+ await Promise.all(promises);
65
+ id = rawMembers[rawMembers.length - 1].id;
66
+
67
+ if (rawMembers.length < batch) {
68
+ break;
69
+ }
70
+ }
71
+ });
72
+ });
73
+ }
74
+
75
+ static updateMembershipsForGroupId(id: string) {
76
+ QueueHandler.schedule('bulk-update-memberships', async () => {
77
+ console.log('Bulk updating memberships for group id ', id);
78
+
79
+ // Get all members that are registered in this group
80
+ const memberIds = (await SQL.select(
81
+ SQL.column('members', 'id'),
82
+ )
83
+ .from(SQL.table(Member.table))
84
+ .join(
85
+ SQL.leftJoin(
86
+ SQL.table(Registration.table),
87
+ ).where(
88
+ SQL.column(Registration.table, 'memberId'),
89
+ SQL.column(Member.table, 'id'),
90
+ ),
91
+ ).where(
92
+ SQL.column(Registration.table, 'groupId'),
93
+ id,
94
+ ).fetch()).flatMap(r => (r.members && (typeof r.members.id) === 'string') ? [r.members.id as string] : []);
95
+
96
+ for (const id of memberIds) {
97
+ await PlatformMembershipService.updateMembershipsForId(id);
98
+ }
99
+ }).catch((e) => {
100
+ console.error('Failed to update memberships for group id ', id, e);
101
+ });
102
+ }
103
+
104
+ static async updateMembershipsForId(id: string, silent = false) {
105
+ return await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
106
+ await QueueHandler.schedule('updateMemberships-' + id, async function (this: undefined) {
107
+ if (!silent) {
108
+ console.log('update memberships for id ', id);
109
+ }
110
+
111
+ const me = await Member.getWithRegistrations(id);
112
+ if (!me) {
113
+ if (!silent) {
114
+ console.log('Skipping automatic membership for: ' + id, ' - member not found');
115
+ }
116
+ return;
117
+ }
118
+ const platform = await Platform.getShared();
119
+ const registrations = me.registrations.filter(r => r.group.periodId === platform.periodId && r.registeredAt && !r.deactivatedAt);
120
+ const now = new Date();
121
+
122
+ const defaultMemberships = registrations.flatMap((r) => {
123
+ if (!r.group.defaultAgeGroupId) {
124
+ return [];
125
+ }
126
+ const defaultAgeGroup = platform.config.defaultAgeGroups.find(g => g.id === r.group.defaultAgeGroupId);
127
+ if (!defaultAgeGroup || !defaultAgeGroup.defaultMembershipTypeId) {
128
+ return [];
129
+ }
130
+
131
+ const defaultMembership = platform.config.membershipTypes.find(m => m.id === defaultAgeGroup.defaultMembershipTypeId);
132
+ if (!defaultMembership) {
133
+ return [];
134
+ }
135
+ const periodConfig = defaultMembership.periods.get(platform.periodId);
136
+
137
+ if (periodConfig === undefined) {
138
+ console.warn('Found default membership without period configuration', defaultMembership.id, platform.periodId);
139
+ return [];
140
+ }
141
+
142
+ if (!(periodConfig.startDate <= now && periodConfig.endDate >= now)) {
143
+ // Do not add this membership automatically because it won't match as an active membership
144
+ // later on (edge case but otherwise we'll get duplicate memberships and add a new one every time)
145
+ return [];
146
+ }
147
+
148
+ return [{
149
+ registration: r,
150
+ membership: defaultMembership,
151
+ }];
152
+ });
153
+
154
+ // Get active memberships for this member that
155
+ const memberships = await MemberPlatformMembership.where({ memberId: me.id, periodId: platform.periodId });
156
+ const activeMemberships = memberships.filter(m => m.startDate <= now && m.endDate >= now && m.deletedAt === null);
157
+ const activeMembershipsUndeletable = activeMemberships.filter(m => !m.canDelete() || !m.generated);
158
+
159
+ if (defaultMemberships.length === 0) {
160
+ // Stop all active memberships that were added automatically
161
+ for (const membership of activeMemberships) {
162
+ if (membership.canDelete() && membership.generated) {
163
+ if (!silent) {
164
+ console.log('Removing membership because no longer registered member and not yet invoiced for: ' + me.id + ' - membership ' + membership.id);
165
+ }
166
+ membership.deletedAt = new Date();
167
+ await membership.save();
168
+ }
169
+ }
170
+
171
+ if (!silent) {
172
+ console.log('Skipping automatic membership for: ' + me.id, ' - no default memberships found');
173
+ }
174
+ return;
175
+ }
176
+
177
+ if (activeMembershipsUndeletable.length) {
178
+ // Skip automatic additions
179
+ for (const m of activeMembershipsUndeletable) {
180
+ try {
181
+ await m.calculatePrice(me);
182
+ }
183
+ catch (e) {
184
+ // Ignore error: membership might not be available anymore
185
+ if (!silent) {
186
+ console.error('Failed to calculate price for undeletable membership', m.id, e);
187
+ }
188
+ }
189
+ await m.save();
190
+ }
191
+ return;
192
+ }
193
+
194
+ // Add the cheapest available membership
195
+ const organizations = await Organization.getByIDs(...Formatter.uniqueArray(defaultMemberships.map(m => m.registration.organizationId)));
196
+
197
+ const defaultMembershipsWithOrganization = defaultMemberships.map(({ membership, registration }) => {
198
+ const organizationId = registration.organizationId;
199
+ const organization = organizations.find(o => o.id === organizationId);
200
+ return { membership, registration, organization };
201
+ });
202
+
203
+ const shouldApplyReducedPrice = me.details.shouldApplyReducedPrice;
204
+
205
+ const cheapestMembership = defaultMembershipsWithOrganization.sort(({ membership: a, registration: ar, organization: ao }, { membership: b, registration: br, organization: bo }) => {
206
+ const tagIdsA = ao?.meta.tags ?? [];
207
+ const tagIdsB = bo?.meta.tags ?? [];
208
+ const diff = a.getPrice(platform.periodId, now, tagIdsA, shouldApplyReducedPrice)! - b.getPrice(platform.periodId, now, tagIdsB, shouldApplyReducedPrice)!;
209
+ if (diff == 0) {
210
+ return Sorter.byDateValue(br.createdAt, ar.createdAt);
211
+ }
212
+ return diff;
213
+ })[0];
214
+ if (!cheapestMembership) {
215
+ throw new Error('No membership found');
216
+ }
217
+
218
+ // Check if already have the same membership
219
+ for (const m of activeMemberships) {
220
+ if (m.membershipTypeId === cheapestMembership.membership.id) {
221
+ // Update the price of this active membership (could have changed)
222
+ try {
223
+ await m.calculatePrice(me);
224
+ }
225
+ catch (e) {
226
+ // Ignore error: membership might not be available anymore
227
+ if (!silent) {
228
+ console.error('Failed to calculate price for active membership', m.id, e);
229
+ }
230
+ }
231
+ await m.save();
232
+ return;
233
+ }
234
+ }
235
+
236
+ const periodConfig = cheapestMembership.membership.periods.get(platform.periodId);
237
+ if (!periodConfig) {
238
+ console.error('Missing membership prices for membership type ' + cheapestMembership.membership.id + ' and period ' + platform.periodId);
239
+ return;
240
+ }
241
+
242
+ // Can we revive an earlier deleted membership?
243
+ if (!silent) {
244
+ console.log('Creating automatic membership for: ' + me.id + ' - membership type ' + cheapestMembership.membership.id);
245
+ }
246
+ const membership = new MemberPlatformMembership();
247
+ membership.memberId = me.id;
248
+ membership.membershipTypeId = cheapestMembership.membership.id;
249
+ membership.organizationId = cheapestMembership.registration.organizationId;
250
+ membership.periodId = platform.periodId;
251
+
252
+ membership.startDate = periodConfig.startDate;
253
+ membership.endDate = periodConfig.endDate;
254
+ membership.expireDate = periodConfig.expireDate;
255
+ membership.generated = true;
256
+
257
+ if (me.details.memberNumber === null) {
258
+ try {
259
+ await MemberNumberService.assignMemberNumber(me, membership);
260
+ }
261
+ catch (error) {
262
+ console.error(`Failed to assign member number for id ${me.id}: ${error.message}`);
263
+ // If the assignment of the member number fails the membership is not created but the member is registered
264
+ return;
265
+ }
266
+ }
267
+
268
+ await membership.calculatePrice(me);
269
+ await membership.save();
270
+
271
+ // This reasoning allows us to replace an existing membership with a cheaper one (not date based ones, but type based ones)
272
+ for (const toDelete of activeMemberships) {
273
+ if (toDelete.canDelete() && toDelete.generated) {
274
+ if (!silent) {
275
+ console.log('Removing membership because cheaper membership found for: ' + me.id + ' - membership ' + toDelete.id);
276
+ }
277
+ toDelete.deletedAt = new Date();
278
+ await toDelete.save();
279
+ }
280
+ }
281
+ });
282
+ });
283
+ }
284
+ }
@@ -1,8 +1,11 @@
1
1
  import { ManyToOneRelation } from '@simonbackx/simple-database';
2
2
  import { Document, Group, Member, Registration } from '@stamhoofd/models';
3
- import { AuditLogType, EmailTemplateType } from '@stamhoofd/structures';
4
- import { GroupService } from './GroupService';
3
+ import { AuditLogSource, EmailTemplateType, StockReservation } from '@stamhoofd/structures';
5
4
  import { AuditLogService } from './AuditLogService';
5
+ import { GroupService } from './GroupService';
6
+ import { PlatformMembershipService } from './PlatformMembershipService';
7
+ import { QueueHandler } from '@stamhoofd/queues';
8
+ import { Formatter } from '@stamhoofd/utility';
6
9
 
7
10
  export const RegistrationService = {
8
11
  async markValid(registrationId: string) {
@@ -20,9 +23,9 @@ export const RegistrationService = {
20
23
  registration.deactivatedAt = null;
21
24
  registration.canRegister = false;
22
25
  await registration.save();
23
- registration.scheduleStockUpdate();
26
+ RegistrationService.scheduleStockUpdate(registration.id);
24
27
 
25
- await Member.updateMembershipsForId(registration.memberId);
28
+ await PlatformMembershipService.updateMembershipsForId(registration.memberId);
26
29
 
27
30
  await registration.sendEmailTemplate({
28
31
  type: EmailTemplateType.RegistrationConfirmation,
@@ -36,22 +39,12 @@ export const RegistrationService = {
36
39
  }
37
40
 
38
41
  // Update group occupancy
39
- const group = await GroupService.updateOccupancy(registration.groupId);
40
-
41
- // Create a log
42
- if (member && group) {
43
- await AuditLogService.log({
44
- type: AuditLogType.MemberRegistered,
45
- member,
46
- group,
47
- registration,
48
- });
49
- }
42
+ await GroupService.updateOccupancy(registration.groupId);
50
43
 
51
44
  return true;
52
45
  },
53
46
 
54
- async deactivate(registration: Registration, group?: Group, member?: Member) {
47
+ async deactivate(registration: Registration, _group?: Group, _member?: Member) {
55
48
  if (registration.deactivatedAt !== null) {
56
49
  return;
57
50
  }
@@ -59,20 +52,76 @@ export const RegistrationService = {
59
52
  // Clear the registration
60
53
  registration.deactivatedAt = new Date();
61
54
  await registration.save();
62
- registration.scheduleStockUpdate();
55
+ RegistrationService.scheduleStockUpdate(registration.id);
56
+
57
+ await PlatformMembershipService.updateMembershipsForId(registration.memberId);
58
+ },
63
59
 
64
- await Member.updateMembershipsForId(registration.memberId);
60
+ /**
61
+ * Adds or removes the order to the stock of the webshop (if it wasn't already included). If amounts were changed, only those
62
+ * changes will get added
63
+ * Should always happen in the webshop-stock queue to prevent multiple webshop writes at the same time
64
+ * + in combination with validation and reading the webshop
65
+ */
66
+ scheduleStockUpdate(id: string) {
67
+ QueueHandler.cancel('registration-stock-update-' + id);
65
68
 
66
- const fetchedMember = member ?? await Member.getByID(registration.memberId);
67
- const fetchedGroup = group ?? await Group.getByID(registration.groupId);
69
+ AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
70
+ await QueueHandler.schedule('registration-stock-update-' + id, async function (this: undefined) {
71
+ const updated = await Registration.getByID(id);
68
72
 
69
- if (fetchedMember && fetchedGroup) {
70
- await AuditLogService.log({
71
- type: AuditLogType.MemberUnregistered,
72
- member: fetchedMember,
73
- group: fetchedGroup,
74
- registration,
73
+ if (!updated) {
74
+ return;
75
+ }
76
+
77
+ // Start with clearing all the stock reservations we've already made
78
+ if (updated.stockReservations) {
79
+ const groupIds = Formatter.uniqueArray(updated.stockReservations.flatMap(r => r.objectType === 'Group' ? [r.objectId] : []));
80
+ for (const groupId of groupIds) {
81
+ const stocks = StockReservation.filter('Group', groupId, updated.stockReservations);
82
+
83
+ // Technically we don't need to await this, but okay...
84
+ await Group.freeStockReservations(groupId, stocks);
85
+ }
86
+ }
87
+
88
+ if (updated.shouldIncludeStock()) {
89
+ const groupStockReservations: StockReservation[] = [
90
+ // Group level stock reservations (stored in the group)
91
+ StockReservation.create({
92
+ objectId: updated.groupPrice.id,
93
+ objectType: 'GroupPrice',
94
+ amount: 1,
95
+ }),
96
+ ...updated.options.map((o) => {
97
+ return StockReservation.create({
98
+ objectId: o.option.id,
99
+ objectType: 'GroupOption',
100
+ amount: o.amount,
101
+ });
102
+ }),
103
+ ];
104
+
105
+ await Group.applyStockReservations(updated.groupId, groupStockReservations);
106
+
107
+ updated.stockReservations = [
108
+ // Global level stock reservations (stored in each group)
109
+ StockReservation.create({
110
+ objectId: updated.groupId,
111
+ objectType: 'Group',
112
+ amount: 1,
113
+ children: groupStockReservations,
114
+ }),
115
+ ];
116
+ await updated.save();
117
+ }
118
+ else {
119
+ if (updated.stockReservations.length) {
120
+ updated.stockReservations = [];
121
+ await updated.save();
122
+ }
123
+ }
75
124
  });
76
- }
125
+ }).catch(console.error);
77
126
  },
78
127
  };