@stamhoofd/backend 2.137.3 → 2.137.5

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 (68) hide show
  1. package/package.json +23 -17
  2. package/src/crons/drip-emails.ts +2 -1
  3. package/src/crons.ts +4 -2
  4. package/src/email-recipient-loaders/payments.ts +3 -2
  5. package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
  6. package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
  8. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  9. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  10. package/src/endpoints/auth/SignupEndpoint.ts +4 -2
  11. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
  12. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  13. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  14. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  15. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  16. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  17. package/src/endpoints/global/email/PatchEmailEndpoint.ts +2 -1
  18. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
  19. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  20. package/src/endpoints/global/files/UploadFile.ts +31 -6
  21. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  22. package/src/endpoints/global/files/UploadImage.ts +23 -4
  23. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  24. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  25. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  26. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  27. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  28. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  29. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  30. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  31. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  32. package/src/excel-loaders/balance-items.ts +1 -1
  33. package/src/excel-loaders/event-notifications.ts +6 -7
  34. package/src/excel-loaders/members.test.ts +2 -2
  35. package/src/excel-loaders/members.ts +9 -10
  36. package/src/excel-loaders/organizations.ts +14 -20
  37. package/src/excel-loaders/payments.ts +1 -1
  38. package/src/excel-loaders/platform-memberships.ts +7 -7
  39. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  40. package/src/excel-loaders/receivable-balances.ts +1 -1
  41. package/src/excel-loaders/registrations.ts +9 -9
  42. package/src/helpers/AdminPermissionChecker.ts +1 -1
  43. package/src/helpers/AuthenticatedStructures.ts +12 -9
  44. package/src/helpers/MembershipCharger.ts +3 -2
  45. package/src/services/BalanceItemService.ts +2 -1
  46. package/src/services/DocumentRenderService.test.ts +229 -0
  47. package/src/services/DocumentRenderService.ts +180 -0
  48. package/src/services/EmailPreviewService.test.ts +300 -0
  49. package/src/services/EmailPreviewService.ts +239 -0
  50. package/src/services/FileSignService.test.ts +85 -0
  51. package/src/services/FileSignService.ts +23 -1
  52. package/src/services/OrderService.test.ts +308 -0
  53. package/src/services/OrderService.ts +214 -0
  54. package/src/services/OrganizationDNSService.test.ts +177 -0
  55. package/src/services/OrganizationDNSService.ts +282 -0
  56. package/src/services/OrganizationEmailService.test.ts +57 -0
  57. package/src/services/OrganizationEmailService.ts +211 -0
  58. package/src/services/PasswordForgotService.test.ts +99 -0
  59. package/src/services/PasswordForgotService.ts +38 -0
  60. package/src/services/PlatformMembershipService.test.ts +180 -0
  61. package/src/services/PlatformMembershipService.ts +281 -4
  62. package/src/services/RegistrationService.ts +3 -2
  63. package/src/services/STPackageService.test.ts +191 -0
  64. package/src/services/STPackageService.ts +114 -2
  65. package/src/services/VerificationCodeService.test.ts +71 -0
  66. package/src/services/VerificationCodeService.ts +100 -0
  67. package/tests/e2e/documents.test.ts +2 -1
  68. package/tests/e2e/private-files.test.ts +77 -14
@@ -1,6 +1,7 @@
1
1
  import { Model } from '@simonbackx/simple-database';
2
+ import { SimpleError } from '@simonbackx/simple-errors';
2
3
  import { logger } from '@simonbackx/simple-logging';
3
- import { Group, Member, MemberPlatformMembership, Organization, Platform, Registration, RegistrationPeriod } from '@stamhoofd/models';
4
+ import { BalanceItem, Group, Member, MemberPlatformMembership, Organization, Platform, Registration, RegistrationPeriod } from '@stamhoofd/models';
4
5
  import { QueueHandler } from '@stamhoofd/queues';
5
6
  import { SQL, SQLWhereSign } from '@stamhoofd/sql';
6
7
  import { AuditLogSource, PlatformMembershipTypeBehaviour } from '@stamhoofd/structures';
@@ -331,7 +332,7 @@ export class PlatformMembershipService {
331
332
  membership.endDate = periodConfig.endDate;
332
333
  membership.expireDate = periodConfig.expireDate;
333
334
  membership.generated = true;
334
- await membership.calculatePrice(me, cheapestMembership.registration);
335
+ await PlatformMembershipService.calculatePrice(membership, me, cheapestMembership.registration);
335
336
 
336
337
  // Check if we have a not-locked but non-generated one that is cheaper
337
338
  // if so, we stop and don't create a new one (but still delete others if required)
@@ -353,7 +354,7 @@ export class PlatformMembershipService {
353
354
  if (!m.locked && m.generated) {
354
355
  // Update the price and dates of this active membership (could have changed)
355
356
  try {
356
- await m.calculatePrice(me, cheapestMembership.registration);
357
+ await PlatformMembershipService.calculatePrice(m, me, cheapestMembership.registration);
357
358
  } catch (e) {
358
359
  // Ignore error: membership might not be available anymore
359
360
  if (!silent) {
@@ -381,7 +382,7 @@ export class PlatformMembershipService {
381
382
  // Update price
382
383
  if (!m.locked) {
383
384
  try {
384
- await m.calculatePrice(me);
385
+ await PlatformMembershipService.calculatePrice(m, me);
385
386
  } catch (e) {
386
387
  // Ignore error: membership might not be available anymore
387
388
  if (!silent) {
@@ -419,4 +420,280 @@ export class PlatformMembershipService {
419
420
  });
420
421
  });
421
422
  }
423
+
424
+ /**
425
+ * Returns the last trial until date for this member. Null if this member does not have any trials
426
+ */
427
+ private static async isElegibleForTrial(membership: MemberPlatformMembership, member: Member) {
428
+ const period = await RegistrationPeriod.getByID(membership.periodId);
429
+ if (!period) {
430
+ return null;
431
+ }
432
+
433
+ if (!period.previousPeriodId) {
434
+ // We have no previous period = no data = no trials
435
+ return null;
436
+ }
437
+
438
+ const platform = await Platform.getSharedStruct();
439
+ const typeIds = platform.config.membershipTypes.filter(m => m.behaviour === PlatformMembershipTypeBehaviour.Period).map(m => m.id);
440
+
441
+ const previousMembership = await MemberPlatformMembership.select()
442
+ .where('memberId', member.id)
443
+ .where('deletedAt', null)
444
+ .where('periodId', period.previousPeriodId)
445
+ .where('membershipTypeId', typeIds)
446
+ .first(false);
447
+
448
+ const hasBlockingMemberships = !!previousMembership;
449
+
450
+ if (hasBlockingMemberships) {
451
+ return null;
452
+ }
453
+
454
+ // The member needs to be registered for at least one registration that had a trial
455
+ const registrations = await Registration.select()
456
+ .where('memberId', member.id)
457
+ .where('periodId', period.id)
458
+ .where('organizationId', membership.organizationId)
459
+ .where('deactivatedAt', null)
460
+ .where('registeredAt', '!=', null)
461
+ .where('trialUntil', '!=', null)
462
+ .fetch();
463
+
464
+ if (registrations.length === 0) {
465
+ return null;
466
+ }
467
+
468
+ const latestTrialUntil = new Date(Math.max(...registrations.map(r => r.trialUntil!.getTime())));
469
+ return latestTrialUntil;
470
+ }
471
+
472
+ private static async correctDates(membership: MemberPlatformMembership, member: Member, registration?: Registration) {
473
+ if (membership.locked) {
474
+ // price of locked membership cannot be changed
475
+ return;
476
+ }
477
+
478
+ const platform = await Platform.getSharedPrivateStruct();
479
+ const membershipType = platform.config.membershipTypes.find(m => m.id === membership.membershipTypeId);
480
+
481
+ if (!membershipType) {
482
+ throw new SimpleError({
483
+ code: 'invalid_membership_type',
484
+ message: 'Unknown membership type',
485
+ human: $t(`%GM`),
486
+ });
487
+ }
488
+
489
+ const periodConfig = membershipType.periods.get(membership.periodId);
490
+
491
+ if (!periodConfig) {
492
+ throw new SimpleError({
493
+ code: 'period_unavailable',
494
+ message: 'Membership not available for this period',
495
+ human: $t(`%GN`),
496
+ });
497
+ }
498
+
499
+ if (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days) {
500
+ // Make sure time is equal between start and end date
501
+ let startBrussels = Formatter.luxon(membership.startDate);
502
+ let endBrussels = Formatter.luxon(membership.endDate);
503
+ startBrussels = startBrussels.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
504
+ endBrussels = endBrussels.set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
505
+ membership.startDate = startBrussels.toJSDate();
506
+ membership.endDate = endBrussels.toJSDate();
507
+ membership.expireDate = null;
508
+ } else {
509
+ membership.endDate = Formatter.luxon(periodConfig.endDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 }).toJSDate();
510
+ membership.expireDate = periodConfig.expireDate ? Formatter.luxon(periodConfig.expireDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 }).toJSDate() : null;
511
+
512
+ // Alter start date
513
+ if (registration) {
514
+ const preferredStartDate = registration.startDate ?? registration.registeredAt ?? membership.createdAt;
515
+ membership.startDate = periodConfig.startDate;
516
+
517
+ if (preferredStartDate > periodConfig.startDate && preferredStartDate < periodConfig.endDate) {
518
+ let startBrussels = Formatter.luxon(preferredStartDate);
519
+ startBrussels = startBrussels.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
520
+ membership.startDate = startBrussels.toJSDate();
521
+ }
522
+ } else {
523
+ // Keep the set date, but make sure it is at 0:00 in CET
524
+ let startBrussels = Formatter.luxon(membership.startDate);
525
+ startBrussels = startBrussels.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
526
+ membership.startDate = startBrussels.toJSDate();
527
+ }
528
+ }
529
+
530
+ let minimumStartDate = Formatter.luxon(periodConfig.startDate).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
531
+
532
+ if (!membership.existsInDatabase) {
533
+ if (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days) {
534
+ // Cannot create a membership in the past
535
+ minimumStartDate = Formatter.luxon(new Date()).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
536
+ }
537
+ }
538
+
539
+ if (membership.startDate < minimumStartDate) {
540
+ membership.startDate = minimumStartDate;
541
+ }
542
+
543
+ if (membership.endDate < membership.startDate) {
544
+ membership.endDate = Formatter.luxon(membership.startDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 }).toJSDate();
545
+ }
546
+
547
+ const maximumEndDate = periodConfig.getMaximumEndDate(membership.startDate, membershipType.behaviour);
548
+ if (membership.endDate > maximumEndDate) {
549
+ throw new SimpleError({
550
+ code: 'invalid_field',
551
+ field: 'endDate',
552
+ message: 'End date is after the maximum allowed end date',
553
+ human: $t('%15C', { date: Formatter.date(maximumEndDate) }),
554
+ });
555
+ }
556
+
557
+ if (periodConfig.trialDays) {
558
+ // Check whether you are elegible for a trial
559
+ const latestTrialDate = await PlatformMembershipService.isElegibleForTrial(membership, member);
560
+ if (latestTrialDate !== null) {
561
+ // Allowed to set trial until, maximum periodConfig.trialDays after startDate
562
+ let trialUntil = Formatter.luxon(membership.startDate).plus({ days: periodConfig.trialDays });
563
+ trialUntil = trialUntil.set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
564
+
565
+ if (trialUntil.toJSDate() > latestTrialDate) {
566
+ // Use the latest trial date instead
567
+ trialUntil = Formatter.luxon(latestTrialDate);
568
+ }
569
+
570
+ // Max end date
571
+ if (trialUntil.toJSDate() > membership.endDate) {
572
+ trialUntil = Formatter.luxon(membership.endDate).set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
573
+ }
574
+
575
+ membership.trialUntil = trialUntil.toJSDate();
576
+ } else {
577
+ membership.trialUntil = null;
578
+ }
579
+ } else {
580
+ // No trial
581
+ membership.trialUntil = null;
582
+ }
583
+ }
584
+
585
+ static async calculatePrice(membership: MemberPlatformMembership, member: Member, registration?: Registration) {
586
+ if (membership.locked) {
587
+ // price of locked membership cannot be changed
588
+ return;
589
+ }
590
+
591
+ const platform = await Platform.getSharedPrivateStruct();
592
+ const membershipType = platform.config.membershipTypes.find(m => m.id === membership.membershipTypeId);
593
+
594
+ if (!membershipType) {
595
+ throw new SimpleError({
596
+ code: 'invalid_membership_type',
597
+ message: 'Unknown membership type',
598
+ human: $t(`%GM`),
599
+ });
600
+ }
601
+
602
+ const periodConfig = membershipType.periods.get(membership.periodId);
603
+
604
+ if (!periodConfig) {
605
+ throw new SimpleError({
606
+ code: 'period_unavailable',
607
+ message: 'Membership not available for this period',
608
+ human: $t(`%GN`),
609
+ });
610
+ }
611
+
612
+ const organization = await Organization.getByID(membership.organizationId);
613
+ if (!organization) {
614
+ throw new SimpleError({
615
+ // todo
616
+ code: 'not_found',
617
+ message: 'Organization not found',
618
+ human: $t(`%GO`),
619
+ });
620
+ }
621
+
622
+ const tagIds = organization.meta.tags;
623
+ const shouldApplyReducedPrice = member.details.shouldApplyReducedPrice;
624
+
625
+ // Correct dates so we get the correct price config
626
+ await PlatformMembershipService.correctDates(membership, member, registration);
627
+ const priceConfig = periodConfig.getPriceConfigForDate(membership.trialUntil ?? membership.startDate);
628
+ const earliestPriceConfig = periodConfig.getPriceConfigForDate(new Date(1950, 0, 1));
629
+
630
+ let freeDays = 0;
631
+
632
+ const d = membership.createdAt ?? new Date();
633
+
634
+ if (periodConfig.amountFree) {
635
+ // Check if this organization has rights to free memberships
636
+ const alreadyUsed = await MemberPlatformMembership.select()
637
+ .where('organizationId', membership.organizationId)
638
+ .where('membershipTypeId', membership.membershipTypeId)
639
+ .where('periodId', membership.periodId)
640
+ .where('deletedAt', null)
641
+ .whereNot('id', membership.id)
642
+ .where(
643
+ SQL.where('createdAt', SQLWhereSign.Less, d)
644
+ .or(
645
+ SQL.where('createdAt', SQLWhereSign.Equal, d)
646
+ .and('id', SQLWhereSign.Less, membership.id),
647
+ ),
648
+ )
649
+ .sum(SQL.column('maximumFreeAmount'));
650
+
651
+ if (alreadyUsed < periodConfig.amountFree) {
652
+ freeDays = periodConfig.amountFree - alreadyUsed;
653
+ console.log('Free membership created for ', membership.id, periodConfig.amountFree, alreadyUsed);
654
+ } else {
655
+ console.log('No free membership created for', membership.id, periodConfig.amountFree, alreadyUsed);
656
+ }
657
+ }
658
+
659
+ if (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days) {
660
+ const days = Math.round((membership.endDate.getTime() - membership.startDate.getTime()) / (1000 * 60 * 60 * 24));
661
+ membership.maximumFreeAmount = days;
662
+ membership.priceWithoutDiscount = earliestPriceConfig.calculatePrice(tagIds, false, days);
663
+ membership.price = priceConfig.calculatePrice(tagIds, shouldApplyReducedPrice, Math.max(0, days - freeDays));
664
+ membership.freeAmount = Math.min(days, freeDays);
665
+ } else {
666
+ membership.priceWithoutDiscount = earliestPriceConfig.getBasePrice(tagIds, false);
667
+ membership.price = priceConfig.getBasePrice(tagIds, shouldApplyReducedPrice);
668
+ membership.maximumFreeAmount = membership.price > 0 ? 1 : 0;
669
+ membership.freeAmount = 0;
670
+
671
+ if (freeDays > 0) {
672
+ membership.price = 0;
673
+ membership.freeAmount = 1;
674
+ }
675
+ }
676
+
677
+ // Never charge itself
678
+ const chargeVia = platform.membershipOrganizationId;
679
+ if (membership.organizationId === chargeVia) {
680
+ membership.price = 0;
681
+ membership.priceWithoutDiscount = 0;
682
+ membership.freeAmount = 0;
683
+ membership.maximumFreeAmount = 0;
684
+ }
685
+
686
+ if (membership.balanceItemId) {
687
+ membership.maximumFreeAmount = membership.freeAmount;
688
+
689
+ // Also update the balance item
690
+ const balanceItem = await BalanceItem.getByID(membership.balanceItemId);
691
+ if (balanceItem) {
692
+ balanceItem.unitPrice = membership.price;
693
+ await balanceItem.save();
694
+
695
+ // await BalanceItem.updateOutstanding([balanceItem]);
696
+ }
697
+ }
698
+ }
422
699
  }
@@ -1,6 +1,6 @@
1
1
  import { ManyToOneRelation } from '@simonbackx/simple-database';
2
2
  import { encodeObject } from '@simonbackx/simple-encoding';
3
- import { BalanceItem, Document, Group, Member, Organization, Registration, RegistrationInvitation, sendEmailTemplate } from '@stamhoofd/models';
3
+ import { BalanceItem, Document, Group, Member, Organization, Platform, Registration, RegistrationInvitation, sendEmailTemplate } from '@stamhoofd/models';
4
4
  import { QueueHandler } from '@stamhoofd/queues';
5
5
  import { AppliedRegistrationDiscount, AuditLogSource, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, EmailTemplateType, getAppHost, GroupType, Recipient, Replacement, StockReservation, Version } from '@stamhoofd/structures';
6
6
  import { Formatter } from '@stamhoofd/utility';
@@ -150,6 +150,7 @@ export const RegistrationService = {
150
150
  }
151
151
 
152
152
  const allowedEmails = member.details.getNotificationEmails();
153
+ const platform = await Platform.getSharedStruct();
153
154
 
154
155
  return member.users.map(user => Recipient.create({
155
156
  firstName: user.firstName,
@@ -167,7 +168,7 @@ export const RegistrationService = {
167
168
  }),
168
169
  Replacement.create({
169
170
  token: 'registerUrl',
170
- value: 'https://' + getAppHost('registration', organization, user.permissions?.forOrganization(organization)?.isEmpty === false),
171
+ value: 'https://' + getAppHost('registration', organization, user.permissions?.forOrganization(organization, platform, { inheritFromPlatform: false })?.isEmpty === false),
171
172
  }),
172
173
  Replacement.create({
173
174
  token: 'groupName',
@@ -0,0 +1,191 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import { EmailTemplateFactory, OrganizationFactory, STPackage, UserFactory } from '@stamhoofd/models';
3
+ import { EmailTemplateType, PermissionLevel, Permissions, STPackageMeta, STPackageType } from '@stamhoofd/structures';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import { Formatter } from '@stamhoofd/utility';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { vi } from 'vitest';
8
+ import { STPackageService } from './STPackageService.js';
9
+
10
+ const DAY = 1000 * 60 * 60 * 24;
11
+
12
+ /**
13
+ * A global (not organization scoped) template that only contains the replacements the service fills
14
+ * in, so the rendered email can be asserted without depending on the real template contents.
15
+ */
16
+ async function createExpirationTemplate(type: EmailTemplateType) {
17
+ await new EmailTemplateFactory({
18
+ type,
19
+ html: '<p>organizationName: {{organizationName}}</p>'
20
+ + '<p>packageName: {{packageName}}</p>'
21
+ + '<p>validUntil: {{validUntil}}</p>'
22
+ + '<p>validUntilDate: {{validUntilDate}}</p>'
23
+ + '<p>renewUrl: {{renewUrl}}</p>',
24
+ }).create();
25
+ }
26
+
27
+ async function createPackage(options: {
28
+ organizationId: string;
29
+ type: STPackageType;
30
+ /**
31
+ * Defaults to 10 days from now: inside the reminder window of every package type.
32
+ */
33
+ validUntil?: Date | null;
34
+ removeAt?: Date | null;
35
+ validAt?: Date | null;
36
+ }) {
37
+ const pack = new STPackage();
38
+ pack.organizationId = options.organizationId;
39
+ pack.meta = STPackageMeta.create({
40
+ type: options.type,
41
+ startDate: new Date(Date.now() - 365 * DAY),
42
+ });
43
+ pack.validAt = options.validAt !== undefined ? options.validAt : new Date(Date.now() - 365 * DAY);
44
+ pack.validUntil = options.validUntil !== undefined ? options.validUntil : new Date(Date.now() + 10 * DAY);
45
+ pack.removeAt = options.removeAt ?? null;
46
+ await pack.save();
47
+ return pack;
48
+ }
49
+
50
+ async function createOrganizationWithAdmin() {
51
+ const organization = await new OrganizationFactory({}).create();
52
+ const admin = await new UserFactory({
53
+ organization,
54
+ firstName: 'Full',
55
+ lastName: 'Admin',
56
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
57
+ }).create();
58
+ return { organization, admin };
59
+ }
60
+
61
+ describe('STPackageService', () => {
62
+ afterEach(() => {
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ describe('sendExpiryEmail', () => {
67
+ test('Sends the reminder to the full admins of the organization and records it on the package', async () => {
68
+ TestUtils.setEnvironment('environment', 'production');
69
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
70
+
71
+ const { organization, admin } = await createOrganizationWithAdmin();
72
+
73
+ // A user without permissions should not receive the reminder
74
+ const member = await new UserFactory({ organization }).create();
75
+
76
+ const validUntil = new Date(Date.now() + 10 * DAY);
77
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validUntil });
78
+
79
+ await STPackageService.sendExpiryEmail(pack);
80
+
81
+ const emails = await EmailMocker.getSucceededEmails();
82
+ expect(emails.length).toBe(1);
83
+ expect(emails[0].to).toContain(admin.email);
84
+ expect(emails[0].to).not.toContain(member.email);
85
+
86
+ expect(emails[0].html).toContain('organizationName: ' + organization.name);
87
+ expect(emails[0].html).toContain('packageName: ' + pack.meta.name);
88
+ expect(emails[0].html).toContain('validUntil: ' + Formatter.dateTime(validUntil));
89
+ expect(emails[0].html).toContain('validUntilDate: ' + Formatter.date(validUntil));
90
+ expect(emails[0].html).toContain(`renewUrl: https://${STAMHOOFD.domains.dashboard ?? 'stamhoofd.app'}/${organization.i18n.locale}/beheerders/${organization.uri}/instellingen/functionaliteiten`);
91
+
92
+ expect(pack.lastEmailAt).not.toBeNull();
93
+ expect(pack.emailCount).toBe(1);
94
+
95
+ const fromDatabase = await STPackage.getByID(pack.id);
96
+ expect(fromDatabase!.lastEmailAt).not.toBeNull();
97
+ expect(fromDatabase!.emailCount).toBe(1);
98
+ });
99
+
100
+ test('Counts the email, but does not set lastEmailAt, for a package type without a reminder', async () => {
101
+ TestUtils.setEnvironment('environment', 'production');
102
+ const { organization } = await createOrganizationWithAdmin();
103
+
104
+ // LegacyMembers has no expiration reminder template type
105
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.LegacyMembers });
106
+
107
+ await STPackageService.sendExpiryEmail(pack);
108
+
109
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
110
+ expect(pack.lastEmailAt).toBeNull();
111
+ expect(pack.emailCount).toBe(1);
112
+
113
+ const fromDatabase = await STPackage.getByID(pack.id);
114
+ expect(fromDatabase!.lastEmailAt).toBeNull();
115
+ expect(fromDatabase!.emailCount).toBe(1);
116
+ });
117
+
118
+ test('Does not send outside production, but still records the email on the package', async () => {
119
+ // The production guard only covers the send itself: the bookkeeping runs in every environment
120
+ expect(STAMHOOFD.environment).not.toBe('production');
121
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
122
+
123
+ const { organization } = await createOrganizationWithAdmin();
124
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members });
125
+
126
+ await STPackageService.sendExpiryEmail(pack);
127
+
128
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
129
+ expect(pack.lastEmailAt).not.toBeNull();
130
+ expect(pack.emailCount).toBe(1);
131
+
132
+ const fromDatabase = await STPackage.getByID(pack.id);
133
+ expect(fromDatabase!.lastEmailAt).not.toBeNull();
134
+ expect(fromDatabase!.emailCount).toBe(1);
135
+ });
136
+
137
+ test('Logs and skips the email when the organization of the package no longer exists', async () => {
138
+ TestUtils.setEnvironment('environment', 'production');
139
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
140
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
141
+
142
+ const pack = await createPackage({ organizationId: uuidv4(), type: STPackageType.Members });
143
+
144
+ await STPackageService.sendExpiryEmail(pack);
145
+
146
+ expect(consoleError).toHaveBeenCalledWith('Could not find package organization ' + pack.id);
147
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
148
+
149
+ // A missing organization does not stop the bookkeeping
150
+ expect(pack.emailCount).toBe(1);
151
+ });
152
+
153
+ test('Does not send or count a package that is not due', async () => {
154
+ TestUtils.setEnvironment('environment', 'production');
155
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
156
+
157
+ const { organization } = await createOrganizationWithAdmin();
158
+
159
+ const neverActivated = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validAt: null });
160
+
161
+ // Expires in 100 days, which is outside the 32 day window of a members package
162
+ const notExpiringSoon = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validUntil: new Date(Date.now() + 100 * DAY) });
163
+
164
+ await STPackageService.sendExpiryEmail(neverActivated);
165
+ await STPackageService.sendExpiryEmail(notExpiringSoon);
166
+
167
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
168
+ expect(neverActivated.emailCount).toBe(0);
169
+ expect(neverActivated.lastEmailAt).toBeNull();
170
+ expect(notExpiringSoon.emailCount).toBe(0);
171
+ expect(notExpiringSoon.lastEmailAt).toBeNull();
172
+ });
173
+
174
+ test('Counts, but does not send, a package that is already removed', async () => {
175
+ TestUtils.setEnvironment('environment', 'production');
176
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
177
+
178
+ const { organization } = await createOrganizationWithAdmin();
179
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members, removeAt: new Date(Date.now() - DAY) });
180
+
181
+ await STPackageService.sendExpiryEmail(pack);
182
+
183
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
184
+ expect(pack.lastEmailAt).toBeNull();
185
+ expect(pack.emailCount).toBe(1);
186
+
187
+ const fromDatabase = await STPackage.getByID(pack.id);
188
+ expect(fromDatabase!.emailCount).toBe(1);
189
+ });
190
+ });
191
+ });
@@ -1,7 +1,7 @@
1
- import { BalanceItem, Organization, Platform, STPackage } from '@stamhoofd/models';
1
+ import { BalanceItem, Organization, Platform, sendEmailTemplate, STPackage } from '@stamhoofd/models';
2
2
  import { SQL } from '@stamhoofd/sql';
3
3
  import type { Company } from '@stamhoofd/structures';
4
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, getPricingTypeName, STPackageStatus, STPackageType, STPackageTypeHelper, STPricingType, TranslatedString } from '@stamhoofd/structures';
4
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, EmailTemplateType, getPricingTypeName, Recipient, Replacement, STPackageStatus, STPackageType, STPackageTypeHelper, STPricingType, TranslatedString } from '@stamhoofd/structures';
5
5
  import { Formatter, STMath } from '@stamhoofd/utility';
6
6
  import { VATService } from './VATService.js';
7
7
 
@@ -263,6 +263,118 @@ export class STPackageService {
263
263
  await this.updateOrganizationPackages(organizationId);
264
264
  }
265
265
 
266
+ /**
267
+ * Send the expiration reminder of a package if one is due, and record on the package that it was
268
+ * sent (also when nothing was sent, so a package is only ever considered once).
269
+ */
270
+ static async sendExpiryEmail(pack: STPackage) {
271
+ if (pack.validAt === null) {
272
+ // never activated
273
+ return;
274
+ }
275
+
276
+ if (pack.removeAt && pack.removeAt <= new Date()) {
277
+ pack.emailCount += 1;
278
+ await pack.save();
279
+ return;
280
+ }
281
+
282
+ let allowDays = 0;
283
+ let type: EmailTemplateType | null = null;
284
+
285
+ if (pack.meta.type === STPackageType.Members) {
286
+ type = EmailTemplateType.MembersExpirationReminder;
287
+ allowDays = 32;
288
+ } else if (pack.meta.type === STPackageType.Webshops) {
289
+ type = EmailTemplateType.WebshopsExpirationReminder;
290
+ allowDays = 32;
291
+ } else if (pack.meta.type === STPackageType.SingleWebshop) {
292
+ type = EmailTemplateType.SingleWebshopExpirationReminder;
293
+ allowDays = 7;
294
+ } else if (pack.meta.type === STPackageType.TrialMembers) {
295
+ type = EmailTemplateType.TrialMembersExpirationReminder;
296
+ allowDays = 3;
297
+ } else if (pack.meta.type === STPackageType.TrialWebshops) {
298
+ type = EmailTemplateType.TrialWebshopsExpirationReminder;
299
+ allowDays = 3;
300
+ }
301
+
302
+ const allowFrom = new Date(Date.now() + 1000 * 60 * 60 * 24 * allowDays);
303
+ if (type && (pack.validUntil === null || pack.validUntil < new Date() || pack.validUntil > allowFrom)) {
304
+ console.log('Skip sending expiration email for ' + pack.id);
305
+ return;
306
+ }
307
+
308
+ if (type) {
309
+ console.log('Sending expiration email for ' + pack.id, type);
310
+ if (STAMHOOFD.environment === 'production') {
311
+ await this.sendEmailTemplate(pack, {
312
+ type,
313
+ });
314
+ }
315
+ pack.lastEmailAt = new Date();
316
+ } else {
317
+ console.log('Skip sending expiration email for ' + pack.id + ' (no type)');
318
+ }
319
+
320
+ pack.emailCount += 1;
321
+ await pack.save();
322
+ }
323
+
324
+ /**
325
+ * Send an email template about a package to the full administrators of its organization.
326
+ */
327
+ static async sendEmailTemplate(pack: STPackage, data: {
328
+ type: EmailTemplateType;
329
+ }) {
330
+ const organization = await Organization.getByID(pack.organizationId);
331
+
332
+ if (!organization) {
333
+ console.error('Could not find package organization ' + pack.id);
334
+ return;
335
+ }
336
+
337
+ const admins = await organization.getFullAdmins();
338
+
339
+ const recipients = admins.map(admin =>
340
+ Recipient.create({
341
+ firstName: admin.firstName,
342
+ lastName: admin.lastName,
343
+ email: admin.email,
344
+ replacements: [
345
+ Replacement.create({
346
+ token: 'organizationName',
347
+ value: organization.name,
348
+ }),
349
+ Replacement.create({
350
+ token: 'packageName',
351
+ value: pack.meta.name ?? '',
352
+ }),
353
+ Replacement.create({
354
+ token: 'validUntil',
355
+ value: pack.validUntil ? Formatter.dateTime(pack.validUntil) : 'nooit',
356
+ }),
357
+ Replacement.create({
358
+ token: 'validUntilDate',
359
+ value: pack.validUntil ? Formatter.date(pack.validUntil) : 'nooit',
360
+ }),
361
+ Replacement.create({
362
+ token: 'renewUrl',
363
+ value: `https://${(STAMHOOFD.domains.dashboard ?? 'stamhoofd.app')}/${organization.i18n.locale}/beheerders/${organization.uri}/instellingen/functionaliteiten`,
364
+ }),
365
+ ],
366
+ }),
367
+ );
368
+
369
+ // Create e-mail builder
370
+ await sendEmailTemplate(null, {
371
+ template: {
372
+ type: data.type,
373
+ },
374
+ recipients,
375
+ });
376
+ }
377
+
266
378
  static async markBalanceRestored(organizationId: string) {
267
379
  console.log('Marking packages as restored for ' + organizationId);
268
380