@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
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import backendEnv from '@stamhoofd/backend-env';
2
2
  backendEnv.load();
3
3
 
4
- import { Column, Database, Migration, Model } from '@simonbackx/simple-database';
4
+ import { Column, Database, Migration } from '@simonbackx/simple-database';
5
5
  import { CORSPreflightEndpoint, Router, RouterServer } from '@simonbackx/simple-endpoints';
6
6
  import { I18n } from '@stamhoofd/backend-i18n';
7
7
  import { CORSMiddleware, LogMiddleware, VersionMiddleware } from '@stamhoofd/backend-middleware';
@@ -10,14 +10,14 @@ import { loadLogger } from '@stamhoofd/logging';
10
10
  import { Version } from '@stamhoofd/structures';
11
11
  import { sleep } from '@stamhoofd/utility';
12
12
 
13
- import { stopCrons, startCrons, waitForCrons } from '@stamhoofd/crons';
13
+ import { startCrons, stopCrons, waitForCrons } from '@stamhoofd/crons';
14
+ import { Platform } from '@stamhoofd/models';
14
15
  import { resumeEmails } from './src/helpers/EmailResumer';
16
+ import { SetupStepUpdater } from './src/helpers/SetupStepUpdater';
15
17
  import { ContextMiddleware } from './src/middleware/ContextMiddleware';
16
- import { Platform } from '@stamhoofd/models';
17
18
  import { AuditLogService } from './src/services/AuditLogService';
18
- import { PlatformMembershipService } from './src/services/PlatformMembershipService';
19
19
  import { DocumentService } from './src/services/DocumentService';
20
- import { SetupStepUpdater } from './src/helpers/SetupStepUpdater';
20
+ import { PlatformMembershipService } from './src/services/PlatformMembershipService';
21
21
 
22
22
  process.on('unhandledRejection', (error: Error) => {
23
23
  console.error('unhandledRejection');
@@ -39,7 +39,9 @@ if (new Date().getTimezoneOffset() !== 0) {
39
39
  const seeds = async () => {
40
40
  try {
41
41
  // Internal
42
- await Migration.runAll(__dirname + '/src/seeds');
42
+ await AuditLogService.disable(async () => {
43
+ await Migration.runAll(__dirname + '/src/seeds');
44
+ });
43
45
  }
44
46
  catch (e) {
45
47
  console.error('Failed to run seeds:');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.62.0",
3
+ "version": "2.64.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -34,17 +34,17 @@
34
34
  "@bwip-js/node": "^4.5.1",
35
35
  "@mollie/api-client": "3.7.0",
36
36
  "@simonbackx/simple-database": "1.27.0",
37
- "@simonbackx/simple-encoding": "2.18.0",
37
+ "@simonbackx/simple-encoding": "2.19.0",
38
38
  "@simonbackx/simple-endpoints": "1.15.0",
39
39
  "@simonbackx/simple-logging": "^1.0.1",
40
- "@stamhoofd/backend-i18n": "2.62.0",
41
- "@stamhoofd/backend-middleware": "2.62.0",
42
- "@stamhoofd/email": "2.62.0",
43
- "@stamhoofd/models": "2.62.0",
44
- "@stamhoofd/queues": "2.62.0",
45
- "@stamhoofd/sql": "2.62.0",
46
- "@stamhoofd/structures": "2.62.0",
47
- "@stamhoofd/utility": "2.62.0",
40
+ "@stamhoofd/backend-i18n": "2.64.0",
41
+ "@stamhoofd/backend-middleware": "2.64.0",
42
+ "@stamhoofd/email": "2.64.0",
43
+ "@stamhoofd/models": "2.64.0",
44
+ "@stamhoofd/queues": "2.64.0",
45
+ "@stamhoofd/sql": "2.64.0",
46
+ "@stamhoofd/structures": "2.64.0",
47
+ "@stamhoofd/utility": "2.64.0",
48
48
  "archiver": "^7.0.1",
49
49
  "aws-sdk": "^2.885.0",
50
50
  "axios": "1.6.8",
@@ -64,5 +64,5 @@
64
64
  "publishConfig": {
65
65
  "access": "public"
66
66
  },
67
- "gitHead": "e3d5890ed7cbd9774a2b917b758e2ff1a0613b6a"
67
+ "gitHead": "80eebbb0b4fb84ac979041c3f8c7762863622367"
68
68
  }
@@ -26,7 +26,7 @@ export const PaymentLogger = new ModelLogger(Payment, {
26
26
  },
27
27
 
28
28
  createReplacements(model, options) {
29
- let name = `${PaymentMethodHelper.getPaymentName(model.method)}`;
29
+ let name = `${PaymentMethodHelper.getPaymentName(model.method, model.type)}`;
30
30
 
31
31
  if (model.customer?.dynamicName) {
32
32
  name += ` van ${model.customer.dynamicName}`;
@@ -2,3 +2,4 @@ import './amazon-ses.js';
2
2
  import './clearExcelCache.js';
3
3
  import './endFunctionsOfUsersWithoutRegistration.js';
4
4
  import './postmark.js';
5
+ import './update-cached-balances.js';
@@ -0,0 +1,39 @@
1
+ import { CachedBalance } from '@stamhoofd/models';
2
+ import { registerCron } from '@stamhoofd/crons';
3
+
4
+ registerCron('updateCachedBalances', updateCachedBalances);
5
+
6
+ async function updateCachedBalances() {
7
+ // Check if between 3 - 6 AM
8
+ if ((new Date().getHours() > 6 || new Date().getHours() < 3) && STAMHOOFD.environment !== 'development') {
9
+ console.log('Not between 3 and 6 AM, skipping.');
10
+ return;
11
+ }
12
+
13
+ const balances = await CachedBalance.select().where(
14
+ CachedBalance.whereNeedsUpdate(),
15
+ ).limit(100).fetch();
16
+
17
+ // Group by object type and by organization id
18
+ const grouped = new Map<string, CachedBalance[]>();
19
+
20
+ for (const balance of balances) {
21
+ const key = balance.objectType + '-' + balance.organizationId;
22
+ const arr = grouped.get(key);
23
+
24
+ if (!arr) {
25
+ grouped.set(key, [balance]);
26
+ continue;
27
+ }
28
+
29
+ arr.push(balance);
30
+ }
31
+
32
+ for (const [_, balances] of grouped) {
33
+ const balance = balances[0];
34
+
35
+ const ids = balances.map(b => b.objectId);
36
+ console.log('Updating', ids.length, balance.objectType, 'for', balance.organizationId);
37
+ await CachedBalance.updateForObjects(balance.organizationId, ids, balance.objectType);
38
+ }
39
+ }
@@ -18,6 +18,11 @@ async function fetch(query: LimitedFilteredRequest, subfilter: StamhoofdFilter |
18
18
  email,
19
19
  replacements: [
20
20
  Replacement.create({
21
+ token: 'objectName',
22
+ value: balance.object.name,
23
+ }),
24
+ Replacement.create({
25
+ // Deprecated: for backwards compatibility
21
26
  token: 'organizationName',
22
27
  value: balance.object.name,
23
28
  }),
@@ -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
 
@@ -5,7 +5,7 @@ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { Email } from '@stamhoofd/email';
7
7
  import { BalanceItem, BalanceItemPayment, Group, Member, MemberWithRegistrations, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
8
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BalanceItemWithPayments, IDRegisterCheckout, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PermissionLevel, PlatformFamily, PlatformMember, RegisterItem, RegisterResponse, Version } from '@stamhoofd/structures';
8
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BalanceItem as BalanceItemStruct, IDRegisterCheckout, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PermissionLevel, PlatformFamily, PlatformMember, RegisterItem, RegisterResponse, Version, PaymentType } from '@stamhoofd/structures';
9
9
  import { Formatter } from '@stamhoofd/utility';
10
10
 
11
11
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -104,7 +104,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
104
104
 
105
105
  // Validate balance items (can only happen serverside)
106
106
  const balanceItemIds = request.body.cart.balanceItems.map(i => i.item.id);
107
- let memberBalanceItemsStructs: BalanceItemWithPayments[] = [];
107
+ let memberBalanceItemsStructs: BalanceItemStruct[] = [];
108
108
  let balanceItemsModels: BalanceItem[] = [];
109
109
  if (balanceItemIds.length > 0) {
110
110
  balanceItemsModels = await BalanceItem.where({ id: { sign: 'IN', value: balanceItemIds }, organizationId: organization.id });
@@ -114,7 +114,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
114
114
  message: 'Oeps, één of meerdere openstaande bedragen in jouw winkelmandje zijn aangepast. Herlaad de pagina en probeer opnieuw.',
115
115
  });
116
116
  }
117
- memberBalanceItemsStructs = await BalanceItem.getStructureWithPayments(balanceItemsModels);
117
+ memberBalanceItemsStructs = balanceItemsModels.map(i => i.getStructure());
118
118
  }
119
119
 
120
120
  const memberIds = Formatter.uniqueArray(
@@ -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,8 +460,12 @@ 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
- balanceItem2.status = BalanceItemStatus.Hidden; // shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden;
468
+ balanceItem2.status = BalanceItemStatus.Hidden;
433
469
  await balanceItem2.save();
434
470
 
435
471
  // do not add to createdBalanceItems array because we don't want to add this to the payment if we create a payment
@@ -440,12 +476,16 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
440
476
  balanceItem.userId = user.id;
441
477
  }
442
478
 
443
- balanceItem.status = BalanceItemStatus.Hidden; // shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden;
479
+ balanceItem.status = BalanceItemStatus.Hidden;
444
480
  balanceItem.pricePaid = 0;
445
481
 
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
  }
@@ -554,7 +594,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
554
594
  if (oldestMember) {
555
595
  balanceItem.memberId = oldestMember.id;
556
596
  }
557
- balanceItem.status = BalanceItemStatus.Hidden; // shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden;
597
+ balanceItem.status = BalanceItemStatus.Hidden;
558
598
  await balanceItem.save();
559
599
  createdBalanceItems.push(balanceItem);
560
600
  }
@@ -579,7 +619,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
579
619
  }
580
620
  }
581
621
 
582
- balanceItem.status = BalanceItemStatus.Hidden; // shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden;
622
+ balanceItem.status = BalanceItemStatus.Hidden;
583
623
  await balanceItem.save();
584
624
 
585
625
  createdBalanceItems.push(balanceItem);
@@ -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)) {
@@ -674,6 +719,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
674
719
  // Calculate total price to pay
675
720
  let totalPrice = 0;
676
721
  const payMembers: MemberWithRegistrations[] = [];
722
+ let hasNegative = false;
677
723
 
678
724
  for (const [balanceItem, price] of balanceItems) {
679
725
  if (organization.id !== balanceItem.organizationId) {
@@ -694,6 +740,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
694
740
  });
695
741
  }
696
742
 
743
+ if (price < 0) {
744
+ hasNegative = true;
745
+ }
746
+
697
747
  totalPrice += price;
698
748
 
699
749
  if (price > 0 && balanceItem.memberId) {
@@ -709,27 +759,33 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
709
759
  }
710
760
 
711
761
  if (totalPrice < 0) {
712
- // No payment needed: the outstanding balance will be negative and can be used in the future
713
- return;
714
- // throw new SimpleError({
715
- // code: "empty_data",
716
- // message: "Oeps! De totaalprijs is negatief."
717
- // })
762
+ // todo: try to make it non-negative by reducing some balance items
763
+ throw new SimpleError({
764
+ code: 'empty_data',
765
+ message: 'Oeps! De totaalprijs is negatief.',
766
+ });
718
767
  }
768
+ const payment = new Payment();
769
+ payment.method = checkout.paymentMethod ?? PaymentMethod.Unknown;
719
770
 
720
771
  if (totalPrice === 0) {
721
- return;
722
- }
772
+ if (balanceItems.size === 0) {
773
+ return;
774
+ }
775
+ // Create an egalizing payment
776
+ payment.method = PaymentMethod.Unknown;
723
777
 
724
- if (!checkout.paymentMethod || checkout.paymentMethod === PaymentMethod.Unknown) {
778
+ if (hasNegative) {
779
+ payment.type = PaymentType.Reallocation;
780
+ }
781
+ }
782
+ else if (payment.method === PaymentMethod.Unknown) {
725
783
  throw new SimpleError({
726
784
  code: 'invalid_data',
727
785
  message: 'Oeps, je hebt geen betaalmethode geselecteerd. Selecteer een betaalmethode en probeer opnieuw.',
728
786
  });
729
787
  }
730
788
 
731
- const payment = new Payment();
732
-
733
789
  // Who will receive this money?
734
790
  payment.organizationId = organization.id;
735
791
 
@@ -793,11 +849,16 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
793
849
  }
794
850
  }
795
851
 
796
- payment.method = checkout.paymentMethod;
797
852
  payment.status = PaymentStatus.Created;
853
+ payment.paidAt = null;
798
854
  payment.price = totalPrice;
799
855
 
800
- if (payment.method == PaymentMethod.Transfer) {
856
+ if (totalPrice === 0) {
857
+ payment.status = PaymentStatus.Succeeded;
858
+ payment.paidAt = new Date();
859
+ }
860
+
861
+ if (payment.method === PaymentMethod.Transfer) {
801
862
  // remark: we cannot add the lastnames, these will get added in the frontend when it is decrypted
802
863
  payment.transferSettings = organization.mappedTransferSettings;
803
864
 
@@ -821,7 +882,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
821
882
  },
822
883
  );
823
884
  }
824
- payment.paidAt = null;
825
885
 
826
886
  // Determine the payment provider
827
887
  // Throws if invalid
@@ -861,7 +921,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
861
921
  console.error(e);
862
922
  }
863
923
  }
864
- else if (payment.method !== PaymentMethod.PointOfSale) {
924
+ else if (payment.method !== PaymentMethod.PointOfSale && payment.method !== PaymentMethod.Unknown) {
865
925
  if (!checkout.redirectUrl || !checkout.cancelUrl) {
866
926
  throw new Error('Should have been caught earlier');
867
927
  }
@@ -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