@stamhoofd/backend 2.137.4 → 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.
- package/package.json +23 -17
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
- package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SignupEndpoint.ts +4 -2
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +12 -9
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +214 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +211 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/RegistrationService.ts +3 -2
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +114 -2
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +100 -0
- package/tests/e2e/documents.test.ts +2 -1
- 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
|
|
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
|
|
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
|
|
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
|
|