@stamhoofd/backend 2.63.0 → 2.65.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 (49) hide show
  1. package/index.ts +8 -6
  2. package/package.json +10 -10
  3. package/src/audit-logs/EmailLogger.ts +7 -1
  4. package/src/audit-logs/ModelLogger.ts +17 -2
  5. package/src/crons/balance-emails.ts +232 -0
  6. package/src/crons/index.ts +2 -0
  7. package/src/crons/update-cached-balances.ts +39 -0
  8. package/src/email-recipient-loaders/members.ts +14 -4
  9. package/src/email-recipient-loaders/receivable-balances.ts +29 -15
  10. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +47 -12
  11. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -18
  12. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +41 -16
  13. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +2 -0
  14. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +6 -3
  15. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +48 -3
  16. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +15 -1
  17. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +15 -1
  18. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  19. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +9 -2
  20. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +3 -0
  21. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +23 -2
  22. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +0 -12
  23. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +10 -2
  24. package/src/helpers/AdminPermissionChecker.ts +2 -1
  25. package/src/helpers/AuthenticatedStructures.ts +73 -3
  26. package/src/helpers/EmailResumer.ts +1 -5
  27. package/src/helpers/MemberUserSyncer.ts +22 -1
  28. package/src/helpers/MembershipCharger.ts +5 -0
  29. package/src/helpers/OrganizationCharger.ts +4 -0
  30. package/src/helpers/TagHelper.ts +7 -14
  31. package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +4 -14
  32. package/src/seeds/1729253172-update-orders.ts +7 -18
  33. package/src/seeds/{1733996431-update-cached-outstanding-balance-from-items.ts → 1734596144-fill-previous-period-id.ts} +21 -6
  34. package/src/seeds/{1726494420-update-cached-outstanding-balance-from-items.ts → 1735577912-update-cached-outstanding-balance-from-items.ts} +1 -14
  35. package/src/services/BalanceItemService.ts +22 -3
  36. package/src/services/PaymentReallocationService.test.ts +746 -0
  37. package/src/services/PaymentReallocationService.ts +339 -0
  38. package/src/services/PaymentService.ts +13 -0
  39. package/src/services/PlatformMembershipService.ts +167 -137
  40. package/src/sql-filters/members.ts +1 -0
  41. package/src/sql-filters/receivable-balances.ts +16 -2
  42. package/src/sql-filters/shared/EmailRelationFilterCompilers.ts +19 -0
  43. package/src/sql-sorters/receivable-balances.ts +3 -3
  44. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +0 -253
  45. package/src/helpers/ModelHelper.ts +0 -32
  46. package/src/seeds/1726055544-balance-item-paid.ts +0 -11
  47. package/src/seeds/1726055545-balance-item-pending.ts +0 -11
  48. package/src/seeds/1726494419-update-cached-outstanding-balance.ts +0 -53
  49. package/src/seeds/1728928973-balance-item-pending.ts +0 -11
@@ -2,7 +2,7 @@ import { OneToManyRelation } from '@simonbackx/simple-database';
2
2
  import { ConvertArrayToPatchableArray, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
- import { BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, mergeTwoMembers, Organization, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
5
+ import { BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, mergeTwoMembers, Organization, Platform, RateLimiter, Registration, RegistrationPeriod, User } from '@stamhoofd/models';
6
6
  import { GroupType, MembersBlob, MemberWithRegistrationsBlob, PermissionLevel } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
 
@@ -218,7 +218,6 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
218
218
  });
219
219
  }
220
220
 
221
- const platform = await Platform.getShared();
222
221
  const responsibility = platform.config.responsibilities.find(r => r.id === patchResponsibility.responsibilityId);
223
222
 
224
223
  if (responsibility && !responsibility.organizationBased && !Context.auth.hasPlatformFullAccess()) {
@@ -261,7 +260,6 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
261
260
  throw Context.auth.error('Je hebt niet voldoende rechten om functies van leden aan te passen');
262
261
  }
263
262
 
264
- const platform = await Platform.getShared();
265
263
  const platformResponsibility = platform.config.responsibilities.find(r => r.id === put.responsibilityId);
266
264
  const org = organization ?? (put.organizationId ? await Organization.getByID(put.organizationId) : null);
267
265
 
@@ -414,12 +412,25 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
414
412
  // Add platform memberships
415
413
  for (const { put } of patch.platformMemberships.getPuts()) {
416
414
  if (put.periodId !== platform.periodId) {
417
- throw new SimpleError({
418
- code: 'invalid_field',
419
- message: 'Invalid period',
420
- human: 'Je kan geen aansluitingen maken voor een andere werkjaar dan het actieve werkjaar',
421
- field: 'periodId',
422
- });
415
+ const period = await RegistrationPeriod.getByID(put.periodId);
416
+
417
+ if (!period) {
418
+ throw new SimpleError({
419
+ code: 'invalid_field',
420
+ message: 'Invalid period',
421
+ human: Context.i18n.$t(`Je kan geen aansluitingen meer toevoegen in dit werkjaar`),
422
+ field: 'periodId',
423
+ });
424
+ }
425
+
426
+ if (period?.locked) {
427
+ throw new SimpleError({
428
+ code: 'invalid_field',
429
+ message: 'Invalid period',
430
+ human: Context.i18n.$t(`Je kan geen aansluitingen meer toevoegen in {period} (vergrendeld)`, { period: period?.getBaseStructure().name }),
431
+ field: 'periodId',
432
+ });
433
+ }
423
434
  }
424
435
 
425
436
  if (organization && put.organizationId !== organization.id) {
@@ -449,6 +460,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
449
460
  .where('memberId', member.id)
450
461
  .where('membershipTypeId', put.membershipTypeId)
451
462
  .where('periodId', put.periodId)
463
+ .where('deletedAt', null)
452
464
  .where(
453
465
  SQL.where(
454
466
  SQL.where('startDate', SQLWhereSign.LessEqual, put.startDate)
@@ -481,7 +493,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
481
493
  membership.organizationId = put.organizationId;
482
494
  membership.periodId = put.periodId;
483
495
 
484
- membership.startDate = put.startDate;
496
+ membership.startDate = new Date(Math.max(Date.now(), put.startDate.getTime()));
485
497
  membership.endDate = put.endDate;
486
498
  membership.expireDate = put.expireDate;
487
499
 
@@ -509,12 +521,25 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
509
521
  }
510
522
 
511
523
  if (membership.periodId !== platform.periodId) {
512
- throw new SimpleError({
513
- code: 'invalid_field',
514
- message: 'Invalid period',
515
- human: 'Je kan geen aansluitingen meer verwijderen voor een ander werkjaar dan het actieve werkjaar',
516
- field: 'periodId',
517
- });
524
+ const period = await RegistrationPeriod.getByID(membership.periodId);
525
+
526
+ if (!period) {
527
+ throw new SimpleError({
528
+ code: 'invalid_field',
529
+ message: 'Invalid period',
530
+ human: Context.i18n.$t(`Je kan geen aansluitingen meer verwijderen in dit werkjaar`),
531
+ field: 'periodId',
532
+ });
533
+ }
534
+
535
+ if (period?.locked) {
536
+ throw new SimpleError({
537
+ code: 'invalid_field',
538
+ message: 'Invalid period',
539
+ human: Context.i18n.$t(`Je kan geen aansluitingen meer verwijderen in {period} (vergrendeld)`, { period: period?.getBaseStructure().name }),
540
+ field: 'periodId',
541
+ });
542
+ }
518
543
  }
519
544
 
520
545
  if (!membership.canDelete() && !Context.auth.hasPlatformFullAccess()) {
@@ -157,6 +157,8 @@ export class PatchPlatformEndpoint extends Endpoint<
157
157
  platform.periodId = period.id;
158
158
  shouldUpdateSetupSteps = true;
159
159
  shouldMoveToPeriod = period;
160
+
161
+ await platform.setPreviousPeriodId();
160
162
  }
161
163
 
162
164
  if (request.body.membershipOrganizationId !== undefined) {
@@ -62,18 +62,21 @@ export class GetUserPayableBalanceEndpoint extends Endpoint<Params, Query, Body,
62
62
  for (const organization of authenticatedOrganizations) {
63
63
  const items = receivableBalances.filter(b => b.organizationId === organization.id);
64
64
 
65
- let amount = 0;
65
+ let amountOpen = 0;
66
66
  let amountPending = 0;
67
+ let amountPaid = 0;
67
68
 
68
69
  for (const item of items) {
69
- amount += item.amount;
70
+ amountOpen += item.amountOpen;
70
71
  amountPending += item.amountPending;
72
+ amountPaid += item.amountPaid;
71
73
  }
72
74
 
73
75
  billingStatus.organizations.push(PayableBalance.create({
74
76
  organization,
75
- amount,
77
+ amountOpen,
76
78
  amountPending,
79
+ amountPaid,
77
80
  }));
78
81
  }
79
82
 
@@ -242,7 +242,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
242
242
  }
243
243
 
244
244
  // Check if this member is already registered in this group?
245
- const existingRegistrations = await Registration.where({ memberId: member.id, groupId: item.groupId, cycle: group.cycle });
245
+ const existingRegistrations = await Registration.where({ memberId: member.id, groupId: item.groupId, cycle: group.cycle, periodId: group.periodId, registeredAt: { sign: '!=', value: null } });
246
246
 
247
247
  for (const existingRegistration of existingRegistrations) {
248
248
  if (item.replaceRegistrations.some(r => r.id === existingRegistration.id)) {
@@ -263,7 +263,25 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
263
263
  }
264
264
  }
265
265
 
266
- const registration = new Registration()
266
+ let reuseRegistration: Registration | null = null;
267
+
268
+ if (item.replaceRegistrations.length === 1) {
269
+ // Try to reuse this specific one
270
+ reuseRegistration = (await Registration.getByID(item.replaceRegistrations[0].id)) ?? null;
271
+ }
272
+
273
+ let startDate = item.calculatedStartDate;
274
+
275
+ if (!reuseRegistration) {
276
+ // Otherwise try to reuse a registration in the same period, for the same group that has been deactived for less than 7 days since the start of the new registration
277
+ reuseRegistration = existingRegistrations.find(r => r.deactivatedAt !== null && r.deactivatedAt.getTime() > startDate.getTime() - 7 * 24 * 60 * 60 * 1000) ?? null;
278
+
279
+ if (reuseRegistration && reuseRegistration.startDate && reuseRegistration.startDate < startDate && reuseRegistration.startDate >= group.settings.startDate && !item.trial) {
280
+ startDate = reuseRegistration.startDate;
281
+ }
282
+ }
283
+
284
+ const registration = (reuseRegistration ?? new Registration())
267
285
  .setRelation(registrationMemberRelation, member as Member)
268
286
  .setRelation(Registration.group, group);
269
287
  registration.organizationId = organization.id;
@@ -275,6 +293,20 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
275
293
  registration.groupPrice = item.groupPrice;
276
294
  registration.options = item.options;
277
295
  registration.recordAnswers = item.recordAnswers;
296
+ registration.startDate = startDate;
297
+
298
+ // Clear if we are reusing an existing registration
299
+ registration.trialUntil = null;
300
+ registration.pricePaid = 0;
301
+ registration.payingOrganizationId = null;
302
+
303
+ // NOTE: we don't reset deactivatedAt - registeredAt, because those will get reset when markValid is called later on (while keeping the original registeredAt date)
304
+ // registration.deactivatedAt = null;
305
+ // registration.registeredAt = null; // this is required to trigger platform membership updates
306
+
307
+ if (item.trial) {
308
+ registration.trialUntil = item.calculatedTrialUntil;
309
+ }
278
310
 
279
311
  if (whoWillPayNow === 'organization' && request.body.asOrganizationId) {
280
312
  registration.payingOrganizationId = request.body.asOrganizationId;
@@ -428,6 +460,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
428
460
  // Who is responsible for payment?
429
461
  balanceItem2.memberId = registration.memberId;
430
462
 
463
+ if (registration.trialUntil) {
464
+ balanceItem2.dueAt = registration.trialUntil;
465
+ }
466
+
431
467
  // If the paying organization hasn't paid yet, this should be hidden and move to pending as soon as the paying organization has paid
432
468
  balanceItem2.status = BalanceItemStatus.Hidden;
433
469
  await balanceItem2.save();
@@ -446,6 +482,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
446
482
  // Connect the 'pay back' balance item to this balance item. As soon as this balance item is paid, we'll mark the other one as pending so the outstanding balance for the member increases
447
483
  balanceItem.dependingBalanceItemId = balanceItem2?.id ?? null;
448
484
 
485
+ if (registration.trialUntil) {
486
+ balanceItem.dueAt = registration.trialUntil;
487
+ }
488
+
449
489
  await balanceItem.save();
450
490
  createdBalanceItems.push(balanceItem);
451
491
  }
@@ -613,7 +653,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
613
653
  const mappedBalanceItems = new Map<BalanceItem, number>();
614
654
 
615
655
  for (const item of createdBalanceItems) {
616
- mappedBalanceItems.set(item, item.price);
656
+ if (item.dueAt === null) {
657
+ mappedBalanceItems.set(item, item.price);
658
+ }
617
659
  }
618
660
 
619
661
  for (const item of checkout.cart.balanceItems) {
@@ -652,6 +694,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
652
694
  await BalanceItem.updateOutstanding([...createdBalanceItems, ...unrelatedCreatedBalanceItems]);
653
695
  }
654
696
 
697
+ // Reallocate
698
+ await BalanceItemService.reallocate([...createdBalanceItems, ...unrelatedCreatedBalanceItems], organization.id);
699
+
655
700
  // Update occupancy
656
701
  for (const group of groups) {
657
702
  if (registrations.some(r => r.groupId === group.id) || deactivatedRegistrationGroupIds.some(id => id === group.id)) {
@@ -1,6 +1,6 @@
1
1
  import { ConvertArrayToPatchableArray, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder, patchObject } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
- import { AuditLogType, RegistrationPeriod as RegistrationPeriodStruct } from '@stamhoofd/structures';
3
+ import { AuditLogSource, AuditLogType, RegistrationPeriod as RegistrationPeriodStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { Platform, RegistrationPeriod } from '@stamhoofd/models';
@@ -60,6 +60,7 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
60
60
  period.locked = put.locked;
61
61
  period.settings = put.settings;
62
62
  period.organizationId = organization?.id ?? null;
63
+ await period.setPreviousPeriodId();
63
64
 
64
65
  await period.save();
65
66
  periods.push(period);
@@ -91,6 +92,7 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
91
92
  model.settings = patchObject(model.settings, patch.settings);
92
93
  }
93
94
 
95
+ await model.setPreviousPeriodId();
94
96
  await model.save();
95
97
 
96
98
  // Schedule patch of all groups in this period
@@ -108,7 +110,19 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
108
110
  });
109
111
  }
110
112
 
113
+ // Get before deleting the model
114
+ const updateWhere = await RegistrationPeriod.where({ previousPeriodId: model.id });
115
+
116
+ // Now delete the model
111
117
  await model.delete();
118
+
119
+ // Update all previous period ids
120
+ await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
121
+ for (const period of updateWhere) {
122
+ await period.setPreviousPeriodId();
123
+ await period.save();
124
+ }
125
+ });
112
126
  }
113
127
 
114
128
  // Clear platform cache
@@ -95,7 +95,7 @@ export class GetEmailTemplatesEndpoint extends Endpoint<Params, Query, Body, Res
95
95
  : []
96
96
  );
97
97
 
98
- const defaultTemplateTypes = organization ? types.filter(type => EmailTemplateStruct.isSavedEmail(type)) : types;
98
+ const defaultTemplateTypes = organization ? types.filter(type => !EmailTemplateStruct.isSavedEmail(type)) : types;
99
99
  const defaultTemplates = defaultTemplateTypes.length === 0
100
100
  ? []
101
101
  : (await EmailTemplate.where({
@@ -106,6 +106,20 @@ export class GetEmailTemplatesEndpoint extends Endpoint<Params, Query, Body, Res
106
106
  },
107
107
  }));
108
108
 
109
+ if (organization && (request.query.webshopId || request.query.groupIds)) {
110
+ const orgDefaults = (await EmailTemplate.where({
111
+ organizationId: organization.id,
112
+ webshopId: null,
113
+ groupId: null,
114
+ type: {
115
+ sign: 'IN',
116
+ value: defaultTemplateTypes,
117
+ },
118
+ }));
119
+
120
+ defaultTemplates.unshift(...orgDefaults);
121
+ }
122
+
109
123
  return new Response(templates.concat(defaultTemplates).map(template => EmailTemplateStruct.create(template)));
110
124
  }
111
125
  }
@@ -125,6 +125,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
125
125
  organization.privateMeta.inheritedResponsibilityRoles = request.body.privateMeta.inheritedResponsibilityRoles.applyTo(organization.privateMeta.inheritedResponsibilityRoles);
126
126
  organization.privateMeta.privateKey = request.body.privateMeta.privateKey ?? organization.privateMeta.privateKey;
127
127
  organization.privateMeta.featureFlags = patchObject(organization.privateMeta.featureFlags, request.body.privateMeta.featureFlags);
128
+ organization.privateMeta.balanceNotificationSettings = patchObject(organization.privateMeta.balanceNotificationSettings, request.body.privateMeta.balanceNotificationSettings);
128
129
 
129
130
  if (request.body.privateMeta.mollieProfile !== undefined) {
130
131
  organization.privateMeta.mollieProfile = patchObject(organization.privateMeta.mollieProfile, request.body.privateMeta.mollieProfile);
@@ -6,6 +6,7 @@ import { QueueHandler } from '@stamhoofd/queues';
6
6
  import { BalanceItemStatus, BalanceItemType, BalanceItemWithPayments, PermissionLevel } from '@stamhoofd/structures';
7
7
 
8
8
  import { Context } from '../../../../helpers/Context';
9
+ import { BalanceItemService } from '../../../../services/BalanceItemService';
9
10
 
10
11
  type Params = Record<string, never>;
11
12
  type Query = undefined;
@@ -36,7 +37,7 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
36
37
  throw Context.auth.error();
37
38
  }
38
39
 
39
- if (request.body.changes.length == 0) {
40
+ if (request.body.changes.length === 0) {
40
41
  return new Response([]);
41
42
  }
42
43
 
@@ -208,8 +209,14 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
208
209
 
209
210
  await BalanceItem.updateOutstanding(updateOutstandingBalance);
210
211
 
212
+ // Reallocate
213
+ await BalanceItemService.reallocate(updateOutstandingBalance, organization.id);
214
+
215
+ // Reload returnedModels
216
+ const returnedModelsReloaded = await BalanceItem.getByIDs(...returnedModels.map(m => m.id));
217
+
211
218
  return new Response(
212
- await BalanceItem.getStructureWithPayments(returnedModels),
219
+ await BalanceItem.getStructureWithPayments(returnedModelsReloaded),
213
220
  );
214
221
  }
215
222
 
@@ -200,6 +200,9 @@ export class PatchPaymentsEndpoint extends Endpoint<Params, Query, Body, Respons
200
200
  }
201
201
 
202
202
  await BalanceItem.updateOutstanding(balanceItems);
203
+
204
+ // Reallocate
205
+ await BalanceItemService.reallocate(balanceItems, organization.id);
203
206
  }
204
207
 
205
208
  changedPayments.push(payment);
@@ -37,7 +37,7 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
37
37
  throw Context.auth.error();
38
38
  }
39
39
 
40
- const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type);
40
+ const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type, true);
41
41
  let paymentModels: Payment[] = [];
42
42
 
43
43
  switch (request.params.type) {
@@ -100,6 +100,26 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
100
100
  .fetch();
101
101
  break;
102
102
  }
103
+
104
+ case ReceivableBalanceType.registration: {
105
+ paymentModels = await Payment.select()
106
+ .where('organizationId', organization.id)
107
+ .join(
108
+ SQL.join(BalanceItemPayment.table)
109
+ .where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
110
+ )
111
+ .join(
112
+ SQL.join(BalanceItem.table)
113
+ .where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
114
+ )
115
+ .where(SQL.column(BalanceItem.table, 'registrationId'), request.params.id)
116
+ .andWhere(
117
+ SQL.whereNot('status', PaymentStatus.Failed),
118
+ )
119
+ .groupBy(SQL.column(Payment.table, 'id'))
120
+ .fetch();
121
+ break;
122
+ }
103
123
  }
104
124
 
105
125
  const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels);
@@ -108,8 +128,9 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
108
128
  const balances = await CachedBalance.getForObjects([request.params.id], organization.id);
109
129
 
110
130
  const created = new CachedBalance();
111
- created.amount = 0;
131
+ created.amountOpen = 0;
112
132
  created.amountPending = 0;
133
+ created.amountPaid = 0;
113
134
  created.organizationId = organization.id;
114
135
  created.objectId = request.params.id;
115
136
  created.objectType = request.params.type;
@@ -48,20 +48,8 @@ export class GetReceivableBalancesEndpoint extends Endpoint<Params, Query, Body,
48
48
 
49
49
  scopeFilter = {
50
50
  organizationId: organization.id,
51
- $or: {
52
- amount: { $neq: 0 },
53
- amountPending: { $neq: 0 },
54
- nextDueAt: { $neq: null },
55
- },
56
51
  };
57
52
 
58
- if (!Context.auth.hasSomePlatformAccess()) {
59
- // Cannot see debt between organizations
60
- scopeFilter.objectType = {
61
- $neq: ReceivableBalanceType.organization,
62
- };
63
- }
64
-
65
53
  const query = CachedBalance
66
54
  .select();
67
55
 
@@ -346,7 +346,11 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
346
346
  model.settings.period = period.getBaseStructure();
347
347
 
348
348
  if (model.type !== GroupType.EventRegistration) {
349
- model.settings.startDate = period.startDate;
349
+ // Note: start date is curomizable, as long as it stays between period start and end
350
+ if (model.settings.startDate < period.startDate || model.settings.startDate > period.endDate) {
351
+ model.settings.startDate = period.startDate;
352
+ }
353
+
350
354
  model.settings.endDate = period.endDate;
351
355
  }
352
356
  }
@@ -446,9 +450,13 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
446
450
  model.status = struct.status;
447
451
  model.type = struct.type;
448
452
  model.settings.period = period.getBaseStructure();
449
- model.settings.startDate = period.startDate;
450
453
  model.settings.endDate = period.endDate;
451
454
 
455
+ // Note: start date is curomizable, as long as it stays between period start and end
456
+ if (model.settings.startDate < period.startDate || model.settings.startDate > period.endDate) {
457
+ model.settings.startDate = period.startDate;
458
+ }
459
+
452
460
  if (!await Context.auth.canAccessGroup(model, PermissionLevel.Full)) {
453
461
  // Create a temporary permission role for this user
454
462
  const organizationPermissions = user.permissions?.organizationPermissions?.get(organizationId);
@@ -263,7 +263,7 @@ export class AdminPermissionChecker {
263
263
  }
264
264
 
265
265
  const cachedBalance = await CachedBalance.getForObjects([member.id]);
266
- if (cachedBalance.length === 0 || (cachedBalance[0].amount === 0 && cachedBalance[0].amountPending === 0)) {
266
+ if (cachedBalance.length === 0 || (cachedBalance[0].amountOpen === 0 && cachedBalance[0].amountPending === 0)) {
267
267
  return true;
268
268
  }
269
269
  }
@@ -1063,6 +1063,7 @@ export class AdminPermissionChecker {
1063
1063
  for (const registration of cloned.registrations) {
1064
1064
  registration.price = 0;
1065
1065
  registration.pricePaid = 0;
1066
+ registration.balances = [];
1066
1067
  }
1067
1068
  }
1068
1069
 
@@ -1,6 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { AuditLog, BalanceItem, CachedBalance, Document, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
- import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, Document as DocumentStruct, Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
2
+ import { AuditLog, BalanceItem, CachedBalance, Document, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
+ import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, Document as DocumentStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { Context } from './Context';
@@ -323,6 +323,9 @@ export class AuthenticatedStructures {
323
323
  }
324
324
  const organizations = new Map<string, Organization>();
325
325
 
326
+ const registrationIds = Formatter.uniqueArray(members.flatMap(m => m.registrations.map(r => r.id)));
327
+ const balances = await CachedBalance.getForObjects(registrationIds, Context.organization?.id ?? null);
328
+
326
329
  if (includeUser) {
327
330
  for (const organizationId of includeUser.permissions?.organizationPermissions.keys() ?? []) {
328
331
  if (includeContextOrganization || organizationId !== Context.auth.organization?.id) {
@@ -365,7 +368,25 @@ export class AuthenticatedStructures {
365
368
  }
366
369
  }
367
370
  member.registrations = member.registrations.filter(r => (Context.auth.organization && Context.auth.organization.active && r.organizationId === Context.auth.organization.id) || (organizations.get(r.organizationId)?.active ?? false));
368
- const blob = member.getStructureWithRegistrations();
371
+ const balancesPermission = await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read);
372
+
373
+ const blob = MemberWithRegistrationsBlob.create({
374
+ ...member,
375
+ registrations: member.registrations.map((r) => {
376
+ const base = r.getStructure();
377
+
378
+ base.balances = balancesPermission
379
+ ? (balances.filter(b => r.id === b.objectId).map((b) => {
380
+ return GenericBalance.create(b);
381
+ }))
382
+ : [];
383
+
384
+ return base;
385
+ }),
386
+ details: member.details,
387
+ users: member.users.map(u => u.getStructure()),
388
+ });
389
+
369
390
  memberBlobs.push(
370
391
  await Context.auth.filterMemberData(member, blob),
371
392
  );
@@ -568,9 +589,15 @@ export class AuthenticatedStructures {
568
589
 
569
590
  const organizationStructs = await this.organizations(organizations);
570
591
 
592
+ const registrationIds = Formatter.uniqueArray([
593
+ ...balances.filter(b => b.objectType === ReceivableBalanceType.registration).map(b => b.objectId),
594
+ ]);
595
+ const registrations = await Registration.getByIDs(...registrationIds);
596
+
571
597
  const memberIds = Formatter.uniqueArray([
572
598
  ...balances.filter(b => b.objectType === ReceivableBalanceType.member).map(b => b.objectId),
573
599
  ...responsibilities.map(r => r.memberId),
600
+ ...registrations.map(r => r.memberId),
574
601
  ]);
575
602
  const members = memberIds.length > 0 ? await Member.getByIDs(...memberIds) : [];
576
603
 
@@ -655,6 +682,49 @@ export class AuthenticatedStructures {
655
682
  });
656
683
  }
657
684
  }
685
+ else if (balance.objectType === ReceivableBalanceType.registration) {
686
+ const registration = registrations.find(r => r.id === balance.objectId) ?? null;
687
+ if (!registration) {
688
+ continue;
689
+ }
690
+ const member = members.find(m => m.id === registration.memberId) ?? null;
691
+ if (member) {
692
+ const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + Context.organization.getHost() : '';
693
+ object = ReceivableBalanceObject.create({
694
+ id: balance.objectId,
695
+ name: member.details.name,
696
+ contacts: [
697
+ ...(member.details.getMemberEmails().length
698
+ ? [
699
+ ReceivableBalanceObjectContact.create({
700
+ firstName: member.details.firstName ?? '',
701
+ lastName: member.details.lastName ?? '',
702
+ emails: member.details.getMemberEmails(),
703
+ meta: {
704
+ type: 'member',
705
+ responsibilityIds: [],
706
+ url,
707
+ },
708
+ }),
709
+ ]
710
+ : []),
711
+
712
+ ...(member.details.parentsHaveAccess
713
+ ? member.details.parents.filter(p => !!p.email).map(p => ReceivableBalanceObjectContact.create({
714
+ firstName: p.firstName ?? '',
715
+ lastName: p.lastName ?? '',
716
+ emails: [p.email!],
717
+ meta: {
718
+ type: 'parent',
719
+ responsibilityIds: [],
720
+ url,
721
+ },
722
+ }))
723
+ : []),
724
+ ],
725
+ });
726
+ }
727
+ }
658
728
  else if (balance.objectType === ReceivableBalanceType.user) {
659
729
  const user = users.find(m => m.id === balance.objectId) ?? null;
660
730
  if (user) {
@@ -12,13 +12,9 @@ export async function resumeEmails() {
12
12
  const emails = Email.fromRows(result, Email.table);
13
13
 
14
14
  for (const email of emails) {
15
- if (!email.userId) {
16
- console.warn('Cannot retry sending email because userId is not set - which is required for setting the scope', email.id);
17
- continue;
18
- }
19
15
  console.log('Resuming email that has sending status on boot', email.id);
20
16
 
21
- const user = await User.getByID(email.userId);
17
+ const user = email.userId ? (await User.getByID(email.userId)) : await User.getSystem();
22
18
  if (!user) {
23
19
  console.warn('Cannot retry sending email because user not found', email.id);
24
20
  continue;