@stamhoofd/backend 2.59.0 → 2.61.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 (34) hide show
  1. package/index.ts +4 -0
  2. package/package.json +10 -10
  3. package/src/audit-logs/DocumentTemplateLogger.ts +22 -0
  4. package/src/audit-logs/EmailAddressLogger.ts +45 -0
  5. package/src/audit-logs/EmailLogger.ts +67 -0
  6. package/src/audit-logs/EmailTemplateLogger.ts +33 -0
  7. package/src/audit-logs/MemberPlatformMembershipLogger.ts +6 -3
  8. package/src/audit-logs/ModelLogger.ts +23 -6
  9. package/src/audit-logs/OrderLogger.ts +2 -2
  10. package/src/audit-logs/OrganizationLogger.ts +1 -11
  11. package/src/audit-logs/UserLogger.ts +45 -0
  12. package/src/crons/amazon-ses.ts +324 -0
  13. package/src/crons/clearExcelCache.ts +3 -0
  14. package/src/crons/endFunctionsOfUsersWithoutRegistration.ts +3 -0
  15. package/src/crons/index.ts +4 -0
  16. package/src/crons/postmark.ts +223 -0
  17. package/src/crons.ts +3 -315
  18. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +3 -3
  19. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +2 -1
  20. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +38 -25
  21. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -1
  22. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +3 -11
  23. package/src/helpers/MemberUserSyncer.ts +11 -7
  24. package/src/helpers/PeriodHelper.ts +2 -1
  25. package/src/helpers/SetupStepUpdater.ts +503 -0
  26. package/src/seeds/1726847064-setup-steps.ts +1 -1
  27. package/src/seeds/1733319079-fill-paying-organization-ids.ts +68 -0
  28. package/src/services/AuditLogService.ts +19 -14
  29. package/src/services/DocumentService.ts +43 -0
  30. package/src/services/RegistrationService.ts +2 -0
  31. package/src/services/diff.ts +514 -0
  32. package/src/sql-filters/events.ts +13 -1
  33. package/src/crons/updateSetupSteps.ts +0 -9
  34. package/src/services/explainPatch.ts +0 -851
package/src/crons.ts CHANGED
@@ -1,25 +1,13 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
1
  import { Database } from '@simonbackx/simple-database';
3
- import { Email, EmailAddress } from '@stamhoofd/email';
4
2
  import { Group, Organization, Payment, Registration, STPackage, Webshop } from '@stamhoofd/models';
5
3
  import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
6
4
  import { Formatter } from '@stamhoofd/utility';
7
- import AWS from 'aws-sdk';
8
- import { DateTime } from 'luxon';
9
5
 
10
6
  import { registerCron } from '@stamhoofd/crons';
11
- import { clearExcelCache } from './crons/clearExcelCache';
12
- import { endFunctionsOfUsersWithoutRegistration } from './crons/endFunctionsOfUsersWithoutRegistration';
13
- import { ExchangePaymentEndpoint } from './endpoints/organization/shared/ExchangePaymentEndpoint';
14
7
  import { checkSettlements } from './helpers/CheckSettlements';
15
- import { ForwardHandler } from './helpers/ForwardHandler';
16
8
  import { PaymentService } from './services/PaymentService';
17
9
  import { RegistrationService } from './services/RegistrationService';
18
10
 
19
- // Importing postmark returns undefined (this is a bug, so we need to use require)
20
- // eslint-disable-next-line @typescript-eslint/no-require-imports
21
- const postmark = require('postmark');
22
-
23
11
  let lastDNSCheck: Date | null = null;
24
12
  let lastDNSId = '';
25
13
  async function checkDNS() {
@@ -133,303 +121,6 @@ async function checkWebshopDNS() {
133
121
  lastWebshopDNSId = webshops[webshops.length - 1].id;
134
122
  }
135
123
 
136
- async function checkReplies() {
137
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
138
- return;
139
- }
140
-
141
- console.log('Checking replies from AWS SQS');
142
- const sqs = new AWS.SQS();
143
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-email-forwarding', MaxNumberOfMessages: 10 }).promise();
144
- if (messages.Messages) {
145
- for (const message of messages.Messages) {
146
- console.log('Received message from forwarding queue');
147
-
148
- if (message.ReceiptHandle) {
149
- if (STAMHOOFD.environment === 'production') {
150
- await sqs.deleteMessage({
151
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-email-forwarding',
152
- ReceiptHandle: message.ReceiptHandle,
153
- }).promise();
154
- console.log('Deleted from queue');
155
- }
156
- }
157
-
158
- try {
159
- if (message.Body) {
160
- // decode the JSON value
161
- const bounce = JSON.parse(message.Body);
162
-
163
- if (bounce.Message) {
164
- const message = JSON.parse(bounce.Message);
165
-
166
- // Read message content
167
- if (message.mail && message.content && message.receipt) {
168
- const content = message.content;
169
- const receipt = message.receipt as {
170
- recipients: string[];
171
- spamVerdict: { status: 'PASS' | string };
172
- virusVerdict: { status: 'PASS' | string };
173
- spfVerdict: { status: 'PASS' | string };
174
- dkimVerdict: { status: 'PASS' | string };
175
- dmarcVerdict: { status: 'PASS' | string };
176
- };
177
-
178
- const options = await ForwardHandler.handle(content, receipt);
179
- if (options) {
180
- if (STAMHOOFD.environment === 'production') {
181
- Email.send(options);
182
- }
183
- }
184
- }
185
- }
186
- }
187
- }
188
- catch (e) {
189
- console.error(e);
190
- }
191
- }
192
- }
193
- }
194
-
195
- let lastPostmarkCheck: Date | null = null;
196
- let lastPostmarkId: string | null = null;
197
- async function checkPostmarkBounces() {
198
- if (STAMHOOFD.environment !== 'production') {
199
- return;
200
- }
201
-
202
- const token = STAMHOOFD.POSTMARK_SERVER_TOKEN;
203
- if (!token) {
204
- console.log('[POSTMARK BOUNCES] No postmark token, skipping postmark bounces');
205
- return;
206
- }
207
- const fromDate = (lastPostmarkCheck ?? new Date(new Date().getTime() - 24 * 60 * 60 * 1000 * 2));
208
- const ET = DateTime.fromJSDate(fromDate).setZone('EST').toISO({ includeOffset: false });
209
- console.log('[POSTMARK BOUNCES] Checking bounces from Postmark since', fromDate, ET);
210
- const client = new postmark.ServerClient(token);
211
-
212
- const bounces = await client.getBounces({
213
- fromdate: ET,
214
- todate: DateTime.now().setZone('EST').toISO({ includeOffset: false }),
215
- count: 500,
216
- offset: 0,
217
- });
218
-
219
- if (bounces.TotalCount == 0) {
220
- console.log('[POSTMARK BOUNCES] No Postmark bounces at this time');
221
- return;
222
- }
223
-
224
- let lastId: string | null = null;
225
-
226
- for (const bounce of bounces.Bounces) {
227
- // Try to get the organization, if possible, else default to global blocking: "null", which is not visible for an organization, but it is applied
228
- const source = bounce.From;
229
- const organization = source ? await Organization.getByEmail(source) : undefined;
230
-
231
- if (bounce.Type === 'HardBounce' || bounce.Type === 'BadEmailAddress' || bounce.Type === 'Blocked') {
232
- // Block for everyone, but not visible
233
- console.log('[POSTMARK BOUNCES] Postmark ' + bounce.Type + ' for: ', bounce.Email, 'from', source, 'organization', organization?.name);
234
- const emailAddress = await EmailAddress.getOrCreate(bounce.Email, organization?.id ?? null);
235
- emailAddress.hardBounce = true;
236
- await emailAddress.save();
237
- }
238
- else if (bounce.Type === 'SpamComplaint' || bounce.Type === 'SpamNotification' || bounce.Type === 'VirusNotification') {
239
- console.log('[POSTMARK BOUNCES] Postmark ' + bounce.Type + ' for: ', bounce.Email, 'from', source, 'organization', organization?.name);
240
- const emailAddress = await EmailAddress.getOrCreate(bounce.Email, organization?.id ?? null);
241
- emailAddress.markedAsSpam = true;
242
- await emailAddress.save();
243
- }
244
- else {
245
- console.log('[POSTMARK BOUNCES] Unhandled Postmark ' + bounce.Type + ': ', bounce.Email, 'from', source, 'organization', organization?.name);
246
- console.error('[POSTMARK BOUNCES] Unhandled Postmark ' + bounce.Type + ': ', bounce.Email, 'from', source, 'organization', organization?.name);
247
- }
248
-
249
- const bouncedAt = new Date(bounce.BouncedAt);
250
- lastPostmarkCheck = lastPostmarkCheck ? new Date(Math.max(bouncedAt.getTime(), lastPostmarkCheck.getTime())) : bouncedAt;
251
-
252
- lastId = bounce.ID;
253
- }
254
-
255
- if (lastId && lastPostmarkId) {
256
- if (lastId === lastPostmarkId) {
257
- console.log('[POSTMARK BOUNCES] Postmark has no new bounces');
258
- // Increase timestamp by one second to avoid refetching it every time
259
- if (lastPostmarkCheck) {
260
- lastPostmarkCheck = new Date(lastPostmarkCheck.getTime() + 1000);
261
- }
262
- }
263
- }
264
- lastPostmarkId = lastId;
265
- }
266
-
267
- async function checkBounces() {
268
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
269
- return;
270
- }
271
-
272
- console.log('[AWS BOUNCES] Checking bounces from AWS SQS');
273
- const sqs = new AWS.SQS();
274
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-bounces-queue', MaxNumberOfMessages: 10 }).promise();
275
- if (messages.Messages) {
276
- for (const message of messages.Messages) {
277
- console.log('[AWS BOUNCES] Received bounce message');
278
- console.log('[AWS BOUNCES]', message);
279
-
280
- if (message.ReceiptHandle) {
281
- if (STAMHOOFD.environment === 'production') {
282
- await sqs.deleteMessage({
283
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-bounces-queue',
284
- ReceiptHandle: message.ReceiptHandle,
285
- }).promise();
286
- console.log('[AWS BOUNCES] Deleted from queue');
287
- }
288
- }
289
-
290
- try {
291
- if (message.Body) {
292
- // decode the JSON value
293
- const bounce = JSON.parse(message.Body);
294
-
295
- if (bounce.Message) {
296
- const message = JSON.parse(bounce.Message);
297
-
298
- if (message.bounce) {
299
- const b = message.bounce;
300
- // Block all receivers that generate a permanent bounce
301
- const type = b.bounceType;
302
-
303
- const source = message.mail.source;
304
-
305
- // try to find organization that is responsible for this e-mail address
306
-
307
- for (const recipient of b.bouncedRecipients) {
308
- const email = recipient.emailAddress;
309
-
310
- if (
311
- type === 'Permanent'
312
- || (
313
- recipient.diagnosticCode && (
314
- (recipient.diagnosticCode as string).toLowerCase().includes('invalid domain')
315
- || (recipient.diagnosticCode as string).toLowerCase().includes('unable to lookup dns')
316
- )
317
- )
318
- ) {
319
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
320
- if (organization) {
321
- const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
322
- emailAddress.hardBounce = true;
323
- await emailAddress.save();
324
- }
325
- else {
326
- console.error('[AWS BOUNCES] Unknown organization for email address ' + source);
327
- }
328
- }
329
- }
330
- console.log('[AWS BOUNCES] For domain ' + source);
331
- }
332
- else {
333
- console.log("[AWS BOUNCES] 'bounce' field missing in bounce message");
334
- }
335
- }
336
- else {
337
- console.log("[AWS BOUNCES] 'Message' field missing in bounce message");
338
- }
339
- }
340
- else {
341
- console.log('[AWS BOUNCES] Message Body missing in bounce');
342
- }
343
- }
344
- catch (e) {
345
- console.log('[AWS BOUNCES] Bounce message processing failed:');
346
- console.error('[AWS BOUNCES] Bounce message processing failed:');
347
- console.error('[AWS BOUNCES]', e);
348
- }
349
- }
350
- }
351
- }
352
-
353
- async function checkComplaints() {
354
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
355
- return;
356
- }
357
-
358
- console.log('[AWS COMPLAINTS] Checking complaints from AWS SQS');
359
- const sqs = new AWS.SQS();
360
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-complaints-queue', MaxNumberOfMessages: 10 }).promise();
361
- if (messages.Messages) {
362
- for (const message of messages.Messages) {
363
- console.log('[AWS COMPLAINTS] Received complaint message');
364
- console.log('[AWS COMPLAINTS]', message);
365
-
366
- if (message.ReceiptHandle) {
367
- if (STAMHOOFD.environment === 'production') {
368
- await sqs.deleteMessage({
369
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-complaints-queue',
370
- ReceiptHandle: message.ReceiptHandle,
371
- }).promise();
372
- console.log('[AWS COMPLAINTS] Deleted from queue');
373
- }
374
- }
375
-
376
- try {
377
- if (message.Body) {
378
- // decode the JSON value
379
- const complaint = JSON.parse(message.Body);
380
- console.log('[AWS COMPLAINTS]', complaint);
381
-
382
- if (complaint.Message) {
383
- const message = JSON.parse(complaint.Message);
384
-
385
- if (message.complaint) {
386
- const b = message.complaint;
387
- const source = message.mail.source;
388
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
389
-
390
- const type: 'abuse' | 'auth-failure' | 'fraud' | 'not-spam' | 'other' | 'virus' = b.complaintFeedbackType;
391
-
392
- if (organization) {
393
- for (const recipient of b.complainedRecipients) {
394
- const email = recipient.emailAddress;
395
- const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
396
- emailAddress.markedAsSpam = type !== 'not-spam';
397
- await emailAddress.save();
398
- }
399
- }
400
- else {
401
- console.error('[AWS COMPLAINTS] Unknown organization for email address ' + source);
402
- }
403
-
404
- if (type == 'virus' || type == 'fraud') {
405
- console.error('[AWS COMPLAINTS] Received virus / fraud complaint!');
406
- console.error('[AWS COMPLAINTS]', complaint);
407
- if (STAMHOOFD.environment === 'production') {
408
- Email.sendWebmaster({
409
- subject: 'Received a ' + type + ' email notification',
410
- text: 'We received a ' + type + ' notification for an e-mail from the organization: ' + organization?.name + '. Please check and adjust if needed.\n',
411
- });
412
- }
413
- }
414
- }
415
- else {
416
- console.log('[AWS COMPLAINTS] Missing complaint field');
417
- }
418
- }
419
- else {
420
- console.log('[AWS COMPLAINTS] Missing message field in complaint');
421
- }
422
- }
423
- }
424
- catch (e) {
425
- console.log('[AWS COMPLAINTS] Complain message processing failed:');
426
- console.error('[AWS COMPLAINTS] Complain message processing failed:');
427
- console.error('[AWS COMPLAINTS]', e);
428
- }
429
- }
430
- }
431
- }
432
-
433
124
  // Keep checking pending paymetns for 3 days
434
125
  async function checkPayments() {
435
126
  if (STAMHOOFD.environment === 'development') {
@@ -658,14 +349,11 @@ async function checkDrips() {
658
349
 
659
350
  registerCron('checkSettlements', checkSettlements);
660
351
  registerCron('checkExpirationEmails', checkExpirationEmails);
661
- registerCron('checkPostmarkBounces', checkPostmarkBounces);
662
352
  registerCron('checkReservedUntil', checkReservedUntil);
663
- registerCron('checkComplaints', checkComplaints);
664
- registerCron('checkReplies', checkReplies);
665
- registerCron('checkBounces', checkBounces);
666
353
  registerCron('checkDNS', checkDNS);
667
354
  registerCron('checkWebshopDNS', checkWebshopDNS);
668
355
  registerCron('checkPayments', checkPayments);
669
356
  registerCron('checkDrips', checkDrips);
670
- registerCron('clearExcelCache', clearExcelCache);
671
- registerCron('endFunctionsOfUsersWithoutRegistration', endFunctionsOfUsersWithoutRegistration);
357
+
358
+ // Register other crons
359
+ import './crons/index.js';
@@ -2,8 +2,8 @@ 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, SetupStepUpdater, User } from '@stamhoofd/models';
6
- import { AuditLogType, GroupType, MembersBlob, MemberWithRegistrationsBlob, PermissionLevel } from '@stamhoofd/structures';
5
+ import { BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, mergeTwoMembers, Organization, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
6
+ import { GroupType, MembersBlob, MemberWithRegistrationsBlob, PermissionLevel } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
 
9
9
  import { Email } from '@stamhoofd/email';
@@ -13,9 +13,9 @@ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructure
13
13
  import { Context } from '../../../helpers/Context';
14
14
  import { MembershipCharger } from '../../../helpers/MembershipCharger';
15
15
  import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer';
16
- import { AuditLogService } from '../../../services/AuditLogService';
17
16
  import { PlatformMembershipService } from '../../../services/PlatformMembershipService';
18
17
  import { RegistrationService } from '../../../services/RegistrationService';
18
+ import { SetupStepUpdater } from '../../../helpers/SetupStepUpdater';
19
19
 
20
20
  type Params = Record<string, never>;
21
21
  type Query = undefined;
@@ -1,6 +1,6 @@
1
1
  import { AutoEncoderPatchType, Decoder, isPatchableArray, patchObject } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
- import { Organization, Platform, RegistrationPeriod, SetupStepUpdater } from '@stamhoofd/models';
3
+ import { Organization, Platform, RegistrationPeriod } from '@stamhoofd/models';
4
4
  import { MemberResponsibility, PlatformConfig, PlatformPremiseType, Platform as PlatformStruct } from '@stamhoofd/structures';
5
5
 
6
6
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -10,6 +10,7 @@ import { MembershipCharger } from '../../../helpers/MembershipCharger';
10
10
  import { PeriodHelper } from '../../../helpers/PeriodHelper';
11
11
  import { TagHelper } from '../../../helpers/TagHelper';
12
12
  import { PlatformMembershipService } from '../../../services/PlatformMembershipService';
13
+ import { SetupStepUpdater } from '../../../helpers/SetupStepUpdater';
13
14
 
14
15
  type Params = Record<string, never>;
15
16
  type Query = undefined;
@@ -209,6 +209,18 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
209
209
  });
210
210
  }
211
211
 
212
+ // Who is going to pay?
213
+ let whoWillPayNow: 'member' | 'organization' | 'nobody' = 'member'; // if this is set to 'organization', there will also be created separate balance items so the member can pay back the paying organization
214
+
215
+ if (request.body.asOrganizationId && request.body.asOrganizationId === organization.id) {
216
+ // Will get added to the outstanding amount of the member / already paying organization
217
+ whoWillPayNow = 'nobody';
218
+ }
219
+ else if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
220
+ // The organization will pay to the organizing organization, and it will get added to the outstanding amount of the member towards the paying organization
221
+ whoWillPayNow = 'organization';
222
+ }
223
+
212
224
  const registrationMemberRelation = new ManyToOneRelation(Member, 'member');
213
225
  registrationMemberRelation.foreignKey = 'memberId';
214
226
 
@@ -264,6 +276,19 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
264
276
  registration.options = item.options;
265
277
  registration.recordAnswers = item.recordAnswers;
266
278
 
279
+ if (whoWillPayNow === 'organization' && request.body.asOrganizationId) {
280
+ registration.payingOrganizationId = request.body.asOrganizationId;
281
+ }
282
+
283
+ if (whoWillPayNow === 'nobody' && item.replaceRegistrations.length > 0) {
284
+ // If the replace registration was paid by an organization
285
+ // Make sure this updated registration will also be paid by the organization, not the member
286
+ const paidAsOrganization = item.replaceRegistrations[0].payingOrganizationId;
287
+ if (paidAsOrganization) {
288
+ registration.payingOrganizationId = paidAsOrganization;
289
+ }
290
+ }
291
+
267
292
  payRegistrations.push({
268
293
  registration,
269
294
  item,
@@ -272,18 +297,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
272
297
  registrations.push(registration);
273
298
  }
274
299
 
275
- // Who is going to pay?
276
- let whoWillPayNow: 'member' | 'organization' | 'nobody' = 'member'; // if this is set to 'organization', there will also be created separate balance items so the member can pay back the paying organization
277
-
278
- if (request.body.asOrganizationId && request.body.asOrganizationId === organization.id) {
279
- // Will get added to the outstanding amount of the member
280
- whoWillPayNow = 'nobody';
281
- }
282
- else if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
283
- // The organization will pay to the organizing organization, and it will get added to the outstanding amount of the member towards the paying organization
284
- whoWillPayNow = 'organization';
285
- }
286
-
287
300
  // Validate payment method
288
301
  if (totalPrice > 0 && whoWillPayNow !== 'nobody') {
289
302
  const allowedPaymentMethods = organization.meta.registrationPaymentConfiguration.getAvailablePaymentMethods({
@@ -369,8 +382,13 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
369
382
  deactivatedRegistrationGroupIds.push(existingRegistration.groupId);
370
383
  }
371
384
 
372
- async function createBalanceItem({ registration, amount, unitPrice, description, type, relations }: { amount?: number; registration: RegistrationWithMemberAndGroup; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
385
+ async function createBalanceItem({ registration, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: RegistrationWithMemberAndGroup; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
373
386
  // NOTE: We also need to save zero-price balance items because for online payments, we need to know which registrations to activate after payment
387
+ if (skipZero === true) {
388
+ if (unitPrice === 0 || amount === 0) {
389
+ return;
390
+ }
391
+ }
374
392
 
375
393
  // Create balance item
376
394
  const balanceItem = new BalanceItem();
@@ -386,11 +404,11 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
386
404
 
387
405
  // Who is responsible for payment?
388
406
  let balanceItem2: BalanceItem | null = null;
389
- if (whoWillPayNow === 'organization' && request.body.asOrganizationId) {
407
+ if (registration.payingOrganizationId) {
390
408
  // Create a separate balance item for this meber to pay back the paying organization
391
409
  // this is not yet associated with a payment but will be added to the outstanding balance of the member
392
410
 
393
- balanceItem.payingOrganizationId = request.body.asOrganizationId;
411
+ balanceItem.payingOrganizationId = registration.payingOrganizationId;
394
412
 
395
413
  balanceItem2 = new BalanceItem();
396
414
 
@@ -405,7 +423,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
405
423
  balanceItem2.type = type;
406
424
 
407
425
  // Who needs to receive this money?
408
- balanceItem2.organizationId = request.body.asOrganizationId;
426
+ balanceItem2.organizationId = registration.payingOrganizationId;
409
427
 
410
428
  // Who is responsible for payment?
411
429
  balanceItem2.memberId = registration.memberId;
@@ -437,26 +455,19 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
437
455
  const { item, registration } = bundle;
438
456
  registration.reservedUntil = null;
439
457
 
440
- /* if (shouldMarkValid) {
441
- await registration.markValid({ skipEmail: bundle.item.replaceRegistrations.length > 0 });
442
- }
443
- else { */
444
458
  // Reserve registration for 30 minutes (if needed)
445
459
  const group = groups.find(g => g.id === registration.groupId);
446
460
 
447
461
  if (group && group.settings.maxMembers !== null) {
448
462
  registration.reservedUntil = new Date(new Date().getTime() + 1000 * 60 * 30);
449
463
  }
464
+
465
+ // Only now save the registration
450
466
  await registration.save();
451
- // }
452
467
 
453
468
  // Note: we should always create the balance items: even when the price is zero
454
469
  // Otherwise we don't know which registrations to activate after payment
455
470
 
456
- if (shouldMarkValid && item.calculatedPrice === 0) {
457
- // continue;
458
- }
459
-
460
471
  // Create balance items
461
472
  const sharedRelations: [BalanceItemRelationType, BalanceItemRelation][] = [
462
473
  [
@@ -490,6 +501,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
490
501
  registration,
491
502
  unitPrice: item.groupPrice.price.forMember(item.member),
492
503
  type: BalanceItemType.Registration,
504
+ skipZero: false, // Always create at least one balance item for each registration - even when the price is zero
493
505
  description: `${item.member.patchedMember.name} bij ${item.group.settings.name}`,
494
506
  relations: new Map([
495
507
  ...sharedRelations,
@@ -502,6 +514,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
502
514
  registration,
503
515
  amount: option.amount,
504
516
  unitPrice: option.option.price.forMember(item.member),
517
+ skipZero: true, // Do not create for zero option prices
505
518
  type: BalanceItemType.Registration,
506
519
  description: `${option.optionMenu.name}: ${option.option.name}`,
507
520
  relations: new Map([
@@ -1,7 +1,7 @@
1
1
  import { AutoEncoderPatchType, cloneObject, Decoder, isPatchableArray, ObjectData, PatchableArrayAutoEncoder, patchObject } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
4
- import { Organization, OrganizationRegistrationPeriod, PayconiqPayment, Platform, RegistrationPeriod, SetupStepUpdater, StripeAccount, Webshop } from '@stamhoofd/models';
4
+ import { Organization, OrganizationRegistrationPeriod, PayconiqPayment, Platform, RegistrationPeriod, StripeAccount, Webshop } from '@stamhoofd/models';
5
5
  import { BuckarooSettings, Company, OrganizationMetaData, OrganizationPatch, Organization as OrganizationStruct, PayconiqAccount, PaymentMethod, PaymentMethodHelper, PermissionLevel, PlatformConfig } from '@stamhoofd/structures';
6
6
  import { Formatter } from '@stamhoofd/utility';
7
7
 
@@ -10,6 +10,7 @@ import { BuckarooHelper } from '../../../../helpers/BuckarooHelper';
10
10
  import { Context } from '../../../../helpers/Context';
11
11
  import { TagHelper } from '../../../../helpers/TagHelper';
12
12
  import { ViesHelper } from '../../../../helpers/ViesHelper';
13
+ import { SetupStepUpdater } from '../../../../helpers/SetupStepUpdater';
13
14
 
14
15
  type Params = Record<string, never>;
15
16
  type Query = undefined;
@@ -3,9 +3,10 @@ import { GroupPrivateSettings, Group as GroupStruct, GroupType, OrganizationRegi
3
3
 
4
4
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
- import { Group, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod, SetupStepUpdater } from '@stamhoofd/models';
6
+ import { Group, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from '@stamhoofd/models';
7
7
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
8
8
  import { Context } from '../../../../helpers/Context';
9
+ import { SetupStepUpdater } from '../../../../helpers/SetupStepUpdater';
9
10
 
10
11
  type Params = Record<string, never>;
11
12
  type Query = undefined;
@@ -303,11 +304,6 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
303
304
  throw Context.auth.error('Je hebt geen toegangsrechten om deze groep te wijzigen');
304
305
  }
305
306
 
306
- const previousProperties = {
307
- deletedAt: model.deletedAt,
308
- defaultAgeGroupId: model.defaultAgeGroupId,
309
- };
310
-
311
307
  if (struct.settings) {
312
308
  struct.settings.period = undefined; // Not allowed to patch manually
313
309
  model.settings.patchOrPut(struct.settings);
@@ -422,9 +418,7 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
422
418
  }
423
419
  }
424
420
 
425
- await model.updateOccupancy({
426
- previousProperties,
427
- });
421
+ await model.updateOccupancy();
428
422
  await model.save();
429
423
  }
430
424
 
@@ -505,8 +499,6 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
505
499
  }
506
500
 
507
501
  await model.save();
508
- await model.updateOccupancy({ isNew: true }); // Force update steps
509
-
510
502
  return model;
511
503
  }
512
504
  }
@@ -187,7 +187,6 @@ export class MemberUserSyncerStatic {
187
187
 
188
188
  if (user.memberId === member.id) {
189
189
  user.memberId = null;
190
- await user.save();
191
190
  }
192
191
 
193
192
  // Update model relation to correct response
@@ -239,11 +238,7 @@ export class MemberUserSyncerStatic {
239
238
  await this.updateInheritedPermissions(user);
240
239
  }
241
240
  else {
242
- if (user.memberId === member.id) {
243
- // Unlink: parents are never 'equal' to the member
244
- user.memberId = null;
245
- await this.updateInheritedPermissions(user);
246
- }
241
+ let shouldSave = false;
247
242
 
248
243
  if (!user.firstName && !user.lastName) {
249
244
  const parents = member.details.parents.filter(p => p.email === email);
@@ -252,13 +247,22 @@ export class MemberUserSyncerStatic {
252
247
  user.firstName = parents[0].firstName;
253
248
  user.lastName = parents[0].lastName;
254
249
  }
255
- await user.save();
250
+ shouldSave = true;
256
251
  }
257
252
  }
258
253
 
259
254
  if (user.firstName === member.details.firstName && user.lastName === member.details.lastName) {
260
255
  user.firstName = null;
261
256
  user.lastName = null;
257
+ shouldSave = true;
258
+ }
259
+
260
+ if (user.memberId === member.id) {
261
+ // Unlink: parents are never 'equal' to the member
262
+ user.memberId = null;
263
+ await this.updateInheritedPermissions(user);
264
+ }
265
+ if (shouldSave) {
262
266
  await user.save();
263
267
  }
264
268
  }
@@ -1,11 +1,12 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { Group, Member, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod, SetupStepUpdater } from '@stamhoofd/models';
2
+ import { Group, Member, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from '@stamhoofd/models';
3
3
  import { QueueHandler } from '@stamhoofd/queues';
4
4
  import { AuditLogSource, Group as GroupStruct, PermissionLevel } from '@stamhoofd/structures';
5
5
  import { PatchOrganizationRegistrationPeriodsEndpoint } from '../endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint';
6
6
  import { AuthenticatedStructures } from './AuthenticatedStructures';
7
7
  import { MemberUserSyncer } from './MemberUserSyncer';
8
8
  import { AuditLogService } from '../services/AuditLogService';
9
+ import { SetupStepUpdater } from './SetupStepUpdater';
9
10
 
10
11
  export class PeriodHelper {
11
12
  static async moveOrganizationToPeriod(organization: Organization, period: RegistrationPeriod) {