@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.
- package/index.ts +8 -6
- package/package.json +11 -11
- package/src/audit-logs/PaymentLogger.ts +1 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/update-cached-balances.ts +39 -0
- package/src/email-recipient-loaders/receivable-balances.ts +5 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +41 -16
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +2 -0
- package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +6 -3
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +85 -25
- package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +15 -1
- package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +89 -30
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +62 -17
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +52 -2
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +8 -2
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +10 -2
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +19 -8
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +10 -5
- package/src/helpers/AdminPermissionChecker.ts +3 -2
- package/src/helpers/AuthenticatedStructures.ts +127 -9
- package/src/helpers/MembershipCharger.ts +4 -0
- package/src/helpers/OrganizationCharger.ts +4 -0
- package/src/seeds/1733994455-balance-item-status-open.ts +30 -0
- package/src/seeds/1734596144-fill-previous-period-id.ts +55 -0
- package/src/seeds/1734700082-update-cached-outstanding-balance-from-items.ts +40 -0
- package/src/services/BalanceItemPaymentService.ts +8 -4
- package/src/services/BalanceItemService.ts +22 -3
- package/src/services/PaymentReallocationService.test.ts +746 -0
- package/src/services/PaymentReallocationService.ts +339 -0
- package/src/services/PaymentService.ts +13 -0
- package/src/services/PlatformMembershipService.ts +167 -137
- package/src/sql-filters/receivable-balances.ts +2 -1
- 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.
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
160
|
+
if (periodConfig === undefined) {
|
|
161
|
+
console.warn('Found default membership without period configuration', defaultMembership.id, period.id);
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
147
164
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
214
|
+
continue;
|
|
190
215
|
}
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
216
|
|
|
194
|
-
|
|
195
|
-
|
|
217
|
+
// Add the cheapest available membership
|
|
218
|
+
const organizations = await Organization.getByIDs(...Formatter.uniqueArray(defaultMemberships.map(m => m.registration.organizationId)));
|
|
196
219
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
226
|
+
const shouldApplyReducedPrice = me.details.shouldApplyReducedPrice;
|
|
204
227
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
269
|
-
|
|
297
|
+
await membership.calculatePrice(me, cheapestMembership.registration);
|
|
298
|
+
await membership.save();
|
|
270
299
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
amountOpen: {
|
|
25
25
|
getValue(a) {
|
|
26
|
-
return a.
|
|
26
|
+
return a.amountOpen;
|
|
27
27
|
},
|
|
28
28
|
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
29
29
|
return new SQLOrderBy({
|
|
30
|
-
column: SQL.column('
|
|
30
|
+
column: SQL.column('amountOpen'),
|
|
31
31
|
direction,
|
|
32
32
|
});
|
|
33
33
|
},
|