@stamhoofd/backend 2.62.0 → 2.64.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 (33) hide show
  1. package/index.ts +8 -6
  2. package/package.json +11 -11
  3. package/src/audit-logs/PaymentLogger.ts +1 -1
  4. package/src/crons/index.ts +1 -0
  5. package/src/crons/update-cached-balances.ts +39 -0
  6. package/src/email-recipient-loaders/receivable-balances.ts +5 -0
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +41 -16
  8. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +2 -0
  9. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +6 -3
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +85 -25
  11. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +15 -1
  12. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +89 -30
  13. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +62 -17
  14. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +52 -2
  15. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +8 -2
  16. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +10 -2
  17. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +19 -8
  18. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +10 -5
  19. package/src/helpers/AdminPermissionChecker.ts +3 -2
  20. package/src/helpers/AuthenticatedStructures.ts +127 -9
  21. package/src/helpers/MembershipCharger.ts +4 -0
  22. package/src/helpers/OrganizationCharger.ts +4 -0
  23. package/src/seeds/1733994455-balance-item-status-open.ts +30 -0
  24. package/src/seeds/1734596144-fill-previous-period-id.ts +55 -0
  25. package/src/seeds/1734700082-update-cached-outstanding-balance-from-items.ts +40 -0
  26. package/src/services/BalanceItemPaymentService.ts +8 -4
  27. package/src/services/BalanceItemService.ts +22 -3
  28. package/src/services/PaymentReallocationService.test.ts +746 -0
  29. package/src/services/PaymentReallocationService.ts +339 -0
  30. package/src/services/PaymentService.ts +13 -0
  31. package/src/services/PlatformMembershipService.ts +167 -137
  32. package/src/sql-filters/receivable-balances.ts +2 -1
  33. package/src/sql-sorters/receivable-balances.ts +3 -3
@@ -1,15 +1,19 @@
1
1
  import { Model } from '@simonbackx/simple-database';
2
2
  import { logger } from '@simonbackx/simple-logging';
3
- import { Group, Member, MemberPlatformMembership, Organization, Platform, Registration } from '@stamhoofd/models';
3
+ import { Group, Member, MemberPlatformMembership, Organization, Platform, Registration, RegistrationPeriod } from '@stamhoofd/models';
4
4
  import { QueueHandler } from '@stamhoofd/queues';
5
- import { SQL } from '@stamhoofd/sql';
6
- import { AuditLogSource } from '@stamhoofd/structures';
5
+ import { SQL, SQLWhereSign } from '@stamhoofd/sql';
6
+ import { AuditLogSource, PlatformMembershipTypeBehaviour } from '@stamhoofd/structures';
7
7
  import { Formatter, Sorter } from '@stamhoofd/utility';
8
8
  import { AuditLogService } from './AuditLogService';
9
9
  import { MemberNumberService } from './MemberNumberService';
10
10
 
11
11
  export class PlatformMembershipService {
12
12
  static listen() {
13
+ if (STAMHOOFD.userMode === 'organization') {
14
+ return;
15
+ }
16
+
13
17
  // Listen for group changes
14
18
  Model.modelEventBus.addListener(this, (event) => {
15
19
  if (!(event.model instanceof Group)) {
@@ -24,6 +28,10 @@ export class PlatformMembershipService {
24
28
  }
25
29
 
26
30
  static async updateAll() {
31
+ if (STAMHOOFD.userMode === 'organization') {
32
+ return;
33
+ }
34
+
27
35
  console.log('Scheduling updateAllMemberships');
28
36
 
29
37
  let c = 0;
@@ -73,6 +81,9 @@ export class PlatformMembershipService {
73
81
  }
74
82
 
75
83
  static updateMembershipsForGroupId(id: string) {
84
+ if (STAMHOOFD.userMode === 'organization') {
85
+ return;
86
+ }
76
87
  QueueHandler.schedule('bulk-update-memberships', async () => {
77
88
  console.log('Bulk updating memberships for group id ', id);
78
89
 
@@ -102,6 +113,10 @@ export class PlatformMembershipService {
102
113
  }
103
114
 
104
115
  static async updateMembershipsForId(id: string, silent = false) {
116
+ if (STAMHOOFD.userMode === 'organization') {
117
+ return;
118
+ }
119
+
105
120
  return await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
106
121
  await QueueHandler.schedule('updateMemberships-' + id, async function (this: undefined) {
107
122
  if (!silent) {
@@ -115,167 +130,182 @@ export class PlatformMembershipService {
115
130
  }
116
131
  return;
117
132
  }
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);
133
+ const platform = await Platform.getSharedStruct();
134
+ const periods = await RegistrationPeriod.select()
135
+ .where('locked', false)
136
+ .where('organizationId', me.organizationId)
137
+ .where('endDate', SQLWhereSign.GreaterEqual, new Date()) // Avoid updating the price of past periods that were not yet locked
138
+ .fetch();
139
+
140
+ // Every (not-locked) period can have a generated membership
141
+ for (const period of periods) {
142
+ const registrations = me.registrations.filter(r => r.group.periodId === period.id && r.registeredAt && !r.deactivatedAt);
143
+ const now = new Date();
144
+
145
+ const defaultMemberships = registrations.flatMap((r) => {
146
+ if (!r.group.defaultAgeGroupId) {
147
+ return [];
148
+ }
149
+ const defaultAgeGroup = platform.config.defaultAgeGroups.find(g => g.id === r.group.defaultAgeGroupId);
150
+ if (!defaultAgeGroup || !defaultAgeGroup.defaultMembershipTypeId) {
151
+ return [];
152
+ }
136
153
 
137
- if (periodConfig === undefined) {
138
- console.warn('Found default membership without period configuration', defaultMembership.id, platform.periodId);
139
- return [];
140
- }
154
+ const defaultMembership = platform.config.membershipTypes.find(m => m.id === defaultAgeGroup.defaultMembershipTypeId);
155
+ if (!defaultMembership) {
156
+ return [];
157
+ }
158
+ const periodConfig = defaultMembership.periods.get(period.id);
141
159
 
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
- }
160
+ if (periodConfig === undefined) {
161
+ console.warn('Found default membership without period configuration', defaultMembership.id, period.id);
162
+ return [];
163
+ }
147
164
 
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
+ return [{
166
+ registration: r,
167
+ membership: defaultMembership,
168
+ }];
169
+ });
170
+
171
+ const types = platform.config.membershipTypes.filter(m => m.behaviour === PlatformMembershipTypeBehaviour.Period).map(m => m.id);
172
+
173
+ // Get active memberships for this member that
174
+ const activeMemberships = await MemberPlatformMembership.where({
175
+ memberId: me.id,
176
+ periodId: period.id,
177
+ membershipTypeId: { sign: 'IN', value: types },
178
+ deletedAt: null,
179
+ });
180
+ const activeMembershipsUndeletable = activeMemberships.filter(m => !m.canDelete() || !m.generated);
181
+
182
+ if (defaultMemberships.length === 0) {
183
+ // Stop all active memberships that were added automatically
184
+ for (const membership of activeMemberships) {
185
+ if (membership.canDelete() && membership.generated) {
186
+ if (!silent) {
187
+ console.log('Removing membership because no longer registered member and not yet invoiced for: ' + me.id + ' - membership ' + membership.id);
188
+ }
189
+ membership.deletedAt = new Date();
190
+ await membership.save();
165
191
  }
166
- membership.deletedAt = new Date();
167
- await membership.save();
168
192
  }
169
- }
170
193
 
171
- if (!silent) {
172
- console.log('Skipping automatic membership for: ' + me.id, ' - no default memberships found');
194
+ if (!silent) {
195
+ console.log('Skipping automatic membership for: ' + me.id, ' - no default memberships found');
196
+ }
197
+ continue;
173
198
  }
174
- return;
175
- }
176
199
 
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);
200
+ if (activeMembershipsUndeletable.length) {
201
+ // Skip automatic additions
202
+ for (const m of activeMembershipsUndeletable) {
203
+ try {
204
+ await m.calculatePrice(me);
205
+ }
206
+ catch (e) {
207
+ // Ignore error: membership might not be available anymore
208
+ if (!silent) {
209
+ console.error('Failed to calculate price for undeletable membership', m.id, e);
210
+ }
187
211
  }
212
+ await m.save();
188
213
  }
189
- await m.save();
214
+ continue;
190
215
  }
191
- return;
192
- }
193
216
 
194
- // Add the cheapest available membership
195
- const organizations = await Organization.getByIDs(...Formatter.uniqueArray(defaultMemberships.map(m => m.registration.organizationId)));
217
+ // Add the cheapest available membership
218
+ const organizations = await Organization.getByIDs(...Formatter.uniqueArray(defaultMemberships.map(m => m.registration.organizationId)));
196
219
 
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
- });
220
+ const defaultMembershipsWithOrganization = defaultMemberships.map(({ membership, registration }) => {
221
+ const organizationId = registration.organizationId;
222
+ const organization = organizations.find(o => o.id === organizationId);
223
+ return { membership, registration, organization };
224
+ });
202
225
 
203
- const shouldApplyReducedPrice = me.details.shouldApplyReducedPrice;
226
+ const shouldApplyReducedPrice = me.details.shouldApplyReducedPrice;
204
227
 
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);
228
+ const cheapestMembership = defaultMembershipsWithOrganization.sort(({ membership: a, registration: ar, organization: ao }, { membership: b, registration: br, organization: bo }) => {
229
+ const tagIdsA = ao?.meta.tags ?? [];
230
+ const tagIdsB = bo?.meta.tags ?? [];
231
+ const diff = a.getPrice(period.id, ar.startDate ?? ar.registeredAt ?? now, tagIdsA, shouldApplyReducedPrice)! - b.getPrice(period.id, ar.startDate ?? ar.registeredAt ?? now, tagIdsB, shouldApplyReducedPrice)!;
232
+ if (diff === 0) {
233
+ return Sorter.byDateValue(br.createdAt, ar.createdAt);
234
+ }
235
+ return diff;
236
+ })[0];
237
+ if (!cheapestMembership) {
238
+ console.error('No membership found');
239
+ continue;
211
240
  }
212
- return diff;
213
- })[0];
214
- if (!cheapestMembership) {
215
- throw new Error('No membership found');
216
- }
217
241
 
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);
242
+ // Check if already have the same membership
243
+ for (const m of activeMemberships) {
244
+ let didFind = false;
245
+ if (m.membershipTypeId === cheapestMembership.membership.id) {
246
+ // Update the price of this active membership (could have changed)
247
+ try {
248
+ await m.calculatePrice(me, cheapestMembership.registration);
249
+ }
250
+ catch (e) {
251
+ // Ignore error: membership might not be available anymore
252
+ if (!silent) {
253
+ console.error('Failed to calculate price for active membership', m.id, e);
254
+ }
229
255
  }
256
+ await m.save();
257
+ didFind = true;
258
+ break;
259
+ }
260
+
261
+ if (didFind) {
262
+ continue;
230
263
  }
231
- await m.save();
232
- return;
233
264
  }
234
- }
235
265
 
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
- }
266
+ const periodConfig = cheapestMembership.membership.periods.get(period.id);
267
+ if (!periodConfig) {
268
+ console.error('Missing membership prices for membership type ' + cheapestMembership.membership.id + ' and period ' + period.id);
269
+ continue;
270
+ }
241
271
 
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);
272
+ // Can we revive an earlier deleted membership?
273
+ if (!silent) {
274
+ console.log('Creating automatic membership for: ' + me.id + ' - membership type ' + cheapestMembership.membership.id);
260
275
  }
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;
276
+ const membership = new MemberPlatformMembership();
277
+ membership.memberId = me.id;
278
+ membership.membershipTypeId = cheapestMembership.membership.id;
279
+ membership.organizationId = cheapestMembership.registration.organizationId;
280
+ membership.periodId = period.id;
281
+ membership.startDate = periodConfig.startDate;
282
+ membership.endDate = periodConfig.endDate;
283
+ membership.expireDate = periodConfig.expireDate;
284
+ membership.generated = true;
285
+
286
+ if (me.details.memberNumber === null) {
287
+ try {
288
+ await MemberNumberService.assignMemberNumber(me, membership);
289
+ }
290
+ catch (error) {
291
+ console.error(`Failed to assign member number for id ${me.id}: ${error.message}`);
292
+ // If the assignment of the member number fails the membership is not created but the member is registered
293
+ continue;
294
+ }
265
295
  }
266
- }
267
296
 
268
- await membership.calculatePrice(me);
269
- await membership.save();
297
+ await membership.calculatePrice(me, cheapestMembership.registration);
298
+ await membership.save();
270
299
 
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);
300
+ // This reasoning allows us to replace an existing membership with a cheaper one (not date based ones, but type based ones)
301
+ for (const toDelete of activeMemberships) {
302
+ if (toDelete.canDelete() && toDelete.generated) {
303
+ if (!silent) {
304
+ console.log('Removing membership because cheaper membership found for: ' + me.id + ' - membership ' + toDelete.id);
305
+ }
306
+ toDelete.deletedAt = new Date();
307
+ await toDelete.save();
276
308
  }
277
- toDelete.deletedAt = new Date();
278
- await toDelete.save();
279
309
  }
280
310
  }
281
311
  });
@@ -8,6 +8,7 @@ export const receivableBalanceFilterCompilers: SQLFilterDefinitions = {
8
8
  id: createSQLColumnFilterCompiler('id'),
9
9
  organizationId: createSQLColumnFilterCompiler('organizationId'),
10
10
  objectType: createSQLColumnFilterCompiler('objectType'),
11
- amount: createSQLColumnFilterCompiler('amount'),
11
+ amountOpen: createSQLColumnFilterCompiler('amountOpen'),
12
12
  amountPending: createSQLColumnFilterCompiler('amountPending'),
13
+ nextDueAt: createSQLColumnFilterCompiler('nextDueAt'),
13
14
  };
@@ -21,13 +21,13 @@ export const receivableBalanceSorters: SQLSortDefinitions<CachedBalance> = {
21
21
  });
22
22
  },
23
23
  },
24
- amount: {
24
+ amountOpen: {
25
25
  getValue(a) {
26
- return a.amount;
26
+ return a.amountOpen;
27
27
  },
28
28
  toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
29
29
  return new SQLOrderBy({
30
- column: SQL.column('amount'),
30
+ column: SQL.column('amountOpen'),
31
31
  direction,
32
32
  });
33
33
  },