@stamhoofd/backend 2.137.4 → 2.138.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 (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -1,5 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { EventNotification, Member, MemberResponsibilityRecord, Organization, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
2
+ import { EventNotification, Member, MemberResponsibilityRecord, Organization, Platform, User } from '@stamhoofd/models';
3
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
3
4
  import type { EmailTemplateType } from '@stamhoofd/structures';
4
5
  import { PermissionLevel, Recipient, RecordCategory, Replacement } from '@stamhoofd/structures';
5
6
  import { Formatter } from '@stamhoofd/utility';
@@ -0,0 +1,85 @@
1
+ import { S3Client } from '@aws-sdk/client-s3';
2
+ import { File } from '@stamhoofd/structures';
3
+ import { TestUtils } from '@stamhoofd/test-utils';
4
+
5
+ import { FileSignService } from './FileSignService.js';
6
+
7
+ describe('FileSignService', () => {
8
+ let originalClient: S3Client;
9
+
10
+ beforeEach(() => {
11
+ TestUtils.setEnvironment('SPACES_BUCKET', 'test-bucket');
12
+ TestUtils.setEnvironment('SPACES_ENDPOINT', 'test.digitaloceanspaces.com');
13
+
14
+ originalClient = FileSignService.s3;
15
+ FileSignService.s3 = new S3Client({
16
+ forcePathStyle: false,
17
+ endpoint: 'https://test.digitaloceanspaces.com',
18
+ credentials: {
19
+ accessKeyId: 'test-key',
20
+ secretAccessKey: 'test-secret',
21
+ },
22
+ region: 'eu-west-1',
23
+ });
24
+ });
25
+
26
+ afterEach(() => {
27
+ FileSignService.s3 = originalClient;
28
+ });
29
+
30
+ const buildFile = (data: { path: string; contentType?: string | null }) => {
31
+ return new File({
32
+ id: '1c9ab9e6-1234-4c5e-9f1a-000000000000',
33
+ server: 'https://test-bucket.test.digitaloceanspaces.com',
34
+ path: data.path,
35
+ size: 100,
36
+ isPrivate: true,
37
+ contentType: data.contentType ?? null,
38
+ });
39
+ };
40
+
41
+ const getSignedQuery = async (file: File) => {
42
+ const signed = await FileSignService.withSignedUrl(file);
43
+ expect(signed?.signedUrl).toBeDefined();
44
+ return new URL(signed!.signedUrl!).searchParams;
45
+ };
46
+
47
+ test('A signed url never renders a file type a browser could execute', async () => {
48
+ const query = await getSignedQuery(buildFile({ path: 'users/1/abc/report.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }));
49
+
50
+ expect(query.get('response-content-disposition')).toBe('attachment');
51
+
52
+ // The response headers are part of what is signed, so they can't be stripped from the url
53
+ expect(query.get('X-Amz-SignedHeaders')).toBeDefined();
54
+ expect(query.get('X-Amz-Signature')).toBeTruthy();
55
+ });
56
+
57
+ test('A signed url renders file types a browser cannot execute', async () => {
58
+ const pdf = await getSignedQuery(buildFile({ path: 'users/1/abc/invoice.pdf', contentType: 'application/pdf' }));
59
+ expect(pdf.get('response-content-disposition')).toBe('inline');
60
+
61
+ const image = await getSignedQuery(buildFile({ path: 'users/1/abc/photo.jpg', contentType: 'image/jpeg' }));
62
+ expect(image.get('response-content-disposition')).toBe('inline');
63
+ });
64
+
65
+ test('It downloads files that were uploaded before we validated content types', async () => {
66
+ // These were stored with a content type chosen by the uploader, and with an extension derived from it
67
+ const svg = await getSignedQuery(buildFile({ path: 'users/1/abc/evil.svg', contentType: 'image/svg+xml' }));
68
+ expect(svg.get('response-content-disposition')).toBe('attachment');
69
+
70
+ const html = await getSignedQuery(buildFile({ path: 'users/1/abc/evil', contentType: 'text/html' }));
71
+ expect(html.get('response-content-disposition')).toBe('attachment');
72
+
73
+ // Both the extension and the content type have to be safe
74
+ const disguised = await getSignedQuery(buildFile({ path: 'users/1/abc/evil.pdf', contentType: 'text/html' }));
75
+ expect(disguised.get('response-content-disposition')).toBe('attachment');
76
+ });
77
+
78
+ test('It uses the path when a file has no content type, like the images we generate', async () => {
79
+ const image = await getSignedQuery(buildFile({ path: 'users/1/abc/def.png', contentType: null }));
80
+ expect(image.get('response-content-disposition')).toBe('inline');
81
+
82
+ const unknown = await getSignedQuery(buildFile({ path: 'users/1/abc/def', contentType: null }));
83
+ expect(unknown.get('response-content-disposition')).toBe('attachment');
84
+ });
85
+ });
@@ -4,7 +4,7 @@ import {
4
4
  } from '@aws-sdk/s3-request-presigner';
5
5
  import type { DecodedRequest, Request, Response } from '@simonbackx/simple-endpoints';
6
6
  import { SimpleError } from '@simonbackx/simple-errors';
7
- import { File } from '@stamhoofd/structures';
7
+ import { File, supportedFileTypes } from '@stamhoofd/structures';
8
8
  import chalk from 'chalk';
9
9
  import * as jose from 'jose';
10
10
 
@@ -103,6 +103,27 @@ export class FileSignService {
103
103
  };
104
104
  }
105
105
 
106
+ /**
107
+ * Files uploaded before we validated content types can have any content type, so we decide here - and not
108
+ * from the stored object - whether a browser may render a file. Note that this only protects private
109
+ * files: public files are served straight from the file server, without a signed url.
110
+ */
111
+ static getContentDisposition(file: File): 'inline' | 'attachment' {
112
+ // The path is generated by us and always ends with the extension the file was stored with, so it says
113
+ // more about how the file server will serve this file than the content type does. Files we generate
114
+ // ourselves (like the images of the Image model) don't even have a content type.
115
+ if (!supportedFileTypes.resolveUpload({ filename: file.path })?.canRenderInline) {
116
+ return 'attachment';
117
+ }
118
+
119
+ // When a file also carries a content type, both have to be safe
120
+ if (file.contentType && !supportedFileTypes.resolveUpload({ contentType: file.contentType })?.canRenderInline) {
121
+ return 'attachment';
122
+ }
123
+
124
+ return 'inline';
125
+ }
126
+
106
127
  static async withSignedUrl(file: File, duration = 60 * 60) {
107
128
  if (file.signedUrl) {
108
129
  console.error('Warning: file already signed');
@@ -112,6 +133,7 @@ export class FileSignService {
112
133
  const command = new GetObjectCommand({
113
134
  Bucket: STAMHOOFD.SPACES_BUCKET,
114
135
  Key: file.path,
136
+ ResponseContentDisposition: this.getContentDisposition(file),
115
137
  });
116
138
  const url = await getSignedUrl(this.s3, command, { expiresIn: duration });
117
139
 
@@ -295,7 +295,7 @@ export class InvoicePdfService {
295
295
  throw new SimpleError({
296
296
  code: 'pdf_creation_timeout',
297
297
  message: 'Failed to generate invoice PDF',
298
- human: $t('Er ging iets mis bij het aanmaken van de factuur'),
298
+ human: $t('%ZgV'),
299
299
  });
300
300
  }
301
301
  }
@@ -306,13 +306,13 @@ export class InvoicePdfService {
306
306
  throw new SimpleError({
307
307
  code: 'pdf_creation_timeout',
308
308
  message: 'Failed to generate invoice PDF: timeout',
309
- human: $t('Er ging iets mis bij het aanmaken van de factuur'),
309
+ human: $t('%ZgV'),
310
310
  });
311
311
  }
312
312
  throw new SimpleError({
313
313
  code: 'pdf_creation_failed',
314
314
  message: 'Failed to generate invoice PDF',
315
- human: $t('Er ging iets mis bij het aanmaken van de factuur'),
315
+ human: $t('%ZgV'),
316
316
  });
317
317
  } finally {
318
318
  clearTimeout(timeout);
@@ -1,6 +1,7 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { Email } from '@stamhoofd/email';
3
- import { BalanceItem, Invoice, InvoicedBalanceItem, Organization, Payment, sendEmailTemplate } from '@stamhoofd/models';
3
+ import { BalanceItem, Invoice, InvoicedBalanceItem, Organization, Payment } from '@stamhoofd/models';
4
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
4
5
  import { InvoiceCounter } from '@stamhoofd/models/helpers/InvoiceCounter.js';
5
6
  import type { Invoice as InvoiceStruct } from '@stamhoofd/structures';
6
7
  import { EmailTemplateType, PaymentStatus, Recipient, Replacement } from '@stamhoofd/structures';
@@ -9,6 +10,7 @@ import { ViesHelper } from '../helpers/ViesHelper.js';
9
10
  import { BalanceItemService } from './BalanceItemService.js';
10
11
  import { InvoicePdfService } from './InvoicePdfService.js';
11
12
  import { InvoiceXMlService } from './InvoiceXMLService.js';
13
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
12
14
 
13
15
  export class InvoiceService {
14
16
  static async createFrom(organization: Organization, struct: InvoiceStruct, options?: { payments?: Payment[]; balanceItems?: BalanceItem[] }) {
@@ -435,7 +437,7 @@ export class InvoiceService {
435
437
  if (!email && invoice.payingOrganizationId) {
436
438
  const payingOrganization = await Organization.getByID(invoice.payingOrganizationId);
437
439
  if (payingOrganization) {
438
- email = (await payingOrganization.getInvoicingToEmail()) ?? undefined;
440
+ email = (await OrganizationAdminService.getInvoicingToEmail(payingOrganization)) ?? undefined;
439
441
  }
440
442
  }
441
443
 
@@ -7,6 +7,7 @@ import { Country } from '@stamhoofd/types/Country';
7
7
  import { Formatter, STMath } from '@stamhoofd/utility';
8
8
  import { v4 as uuidv4 } from 'uuid';
9
9
  import { InvoicePdfService } from './InvoicePdfService.js';
10
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
10
11
 
11
12
  export class InvoiceXMlService {
12
13
  static async buildXml(invoice: Invoice) {
@@ -68,7 +69,7 @@ export class InvoiceXMlService {
68
69
  if (!customerEmail && invoice.payingOrganizationId) {
69
70
  const payingOrganization = await Organization.getByID(invoice.payingOrganizationId);
70
71
  if (payingOrganization) {
71
- customerEmail = (await payingOrganization.getInvoicingToEmail()) ?? null;
72
+ customerEmail = (await OrganizationAdminService.getInvoicingToEmail(payingOrganization)) ?? null;
72
73
  }
73
74
  }
74
75
 
@@ -0,0 +1,308 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import type { Organization } from '@stamhoofd/models';
4
+ import { EmailTemplateFactory, Order, OrganizationFactory, Payment, Webshop, WebshopCounter, WebshopFactory } from '@stamhoofd/models';
5
+ import { Cart, CartItem, Customer, EmailContent, EmailTemplateType, OrderData, OrderStatus, PaymentMethod, PaymentStatus, Product, ProductPrice, ProductType, WebshopMetaData, WebshopStatus, WebshopTicketType } from '@stamhoofd/structures';
6
+ import { TestUtils } from '@stamhoofd/test-utils';
7
+ import { Country } from '@stamhoofd/types/Country';
8
+ import { Language } from '@stamhoofd/types/Language';
9
+ import { OrderService } from './OrderService.js';
10
+
11
+ /**
12
+ * The order emails are rendered in the language the customer ordered in. The status name below is
13
+ * rendered through $t inside the order recipient replacements, and is hardcoded on purpose so we
14
+ * don't verify $t with the same $t machinery we're testing.
15
+ * (translations of OrderStatus.Created in shared/locales/dist/locales/digit/{nl,fr}-BE.json)
16
+ */
17
+ const orderStatusName = {
18
+ [Language.Dutch]: 'Nieuw',
19
+ [Language.French]: 'Nouveau',
20
+ };
21
+
22
+ function createCustomer() {
23
+ return Customer.create({
24
+ firstName: 'John',
25
+ lastName: 'Doe',
26
+ email: 'john@example.com',
27
+ });
28
+ }
29
+
30
+ async function createOrder(webshop: Webshop, options: { consumerLanguage?: Language; valid?: boolean; cart?: Cart; paymentMethod?: PaymentMethod } = {}) {
31
+ const order = new Order();
32
+ order.organizationId = webshop.organizationId;
33
+ order.webshopId = webshop.id;
34
+ order.status = OrderStatus.Created;
35
+ order.consumerLanguage = options.consumerLanguage ?? Language.Dutch;
36
+ order.data = OrderData.create({
37
+ paymentMethod: options.paymentMethod ?? PaymentMethod.Transfer,
38
+ customer: createCustomer(),
39
+ cart: options.cart ?? Cart.create({}),
40
+ });
41
+
42
+ if (options.valid ?? true) {
43
+ order.validAt = new Date();
44
+ order.number = await WebshopCounter.getNextNumber(webshop);
45
+ }
46
+
47
+ await order.save();
48
+ return order;
49
+ }
50
+
51
+ function loadRelations(order: Order, webshop: Webshop, organization: Organization) {
52
+ return order.setRelation(Order.webshop, webshop.setRelation(Webshop.organization, organization));
53
+ }
54
+
55
+ async function createTransferPayment(organization: Organization) {
56
+ const payment = new Payment();
57
+ payment.organizationId = organization.id;
58
+ payment.method = PaymentMethod.Transfer;
59
+ payment.status = PaymentStatus.Succeeded;
60
+ payment.price = 1000;
61
+ payment.paidAt = new Date();
62
+ await payment.save();
63
+ return payment;
64
+ }
65
+
66
+ describe('OrderService', () => {
67
+ describe('sendEmailTemplate', () => {
68
+ test('renders the email in the language the customer ordered in, not the ambient language', async () => {
69
+ // Make French a valid locale so its real translations (loaded from disk) are actually used.
70
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
71
+
72
+ const organization = await new OrganizationFactory({}).create();
73
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
74
+
75
+ await new EmailTemplateFactory({
76
+ organization,
77
+ webshopId: webshop.id,
78
+ type: EmailTemplateType.OrderConfirmationOnline,
79
+ subject: 'Bevestiging',
80
+ html: '<p>{{orderStatus}}</p>',
81
+ text: '{{orderStatus}}',
82
+ }).create();
83
+
84
+ const order = await createOrder(webshop, { consumerLanguage: Language.French });
85
+ const loadedOrder = loadRelations(order, webshop, organization);
86
+
87
+ // The ambient language is Dutch while the customer ordered in French
88
+ await I18n.runWithLocale(new I18n(Language.Dutch, Country.Belgium), async () => {
89
+ await OrderService.sendEmailTemplate(loadedOrder, {
90
+ type: EmailTemplateType.OrderConfirmationOnline,
91
+ });
92
+ });
93
+
94
+ const emails = await EmailMocker.transactional.getSucceededEmails();
95
+ expect(emails).toHaveLength(1);
96
+ expect(emails[0].html).toContain(orderStatusName[Language.French]);
97
+ expect(emails[0].html).not.toContain(orderStatusName[Language.Dutch]);
98
+ });
99
+
100
+ test('does not send anything for an archived webshop', async () => {
101
+ const organization = await new OrganizationFactory({}).create();
102
+ const webshop = await new WebshopFactory({
103
+ organizationId: organization.id,
104
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Archived }),
105
+ }).create();
106
+
107
+ await new EmailTemplateFactory({
108
+ organization,
109
+ webshopId: webshop.id,
110
+ type: EmailTemplateType.OrderConfirmationOnline,
111
+ subject: 'Bevestiging',
112
+ html: '<p>Bevestiging</p>',
113
+ text: 'Bevestiging',
114
+ }).create();
115
+
116
+ const order = await createOrder(webshop);
117
+ const loadedOrder = loadRelations(order, webshop, organization);
118
+
119
+ await OrderService.sendEmailTemplate(loadedOrder, {
120
+ type: EmailTemplateType.OrderConfirmationOnline,
121
+ });
122
+
123
+ expect(await EmailMocker.transactional.getSucceededEmails()).toHaveLength(0);
124
+ });
125
+ });
126
+
127
+ describe('markValid', () => {
128
+ test('sends the notification email without the customer name and in the webshop language', async () => {
129
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
130
+
131
+ const organization = await new OrganizationFactory({}).create();
132
+ const webshop = await new WebshopFactory({
133
+ organizationId: organization.id,
134
+ meta: WebshopMetaData.patch({ defaultLanguage: Language.French }),
135
+ }).create();
136
+ webshop.privateMeta.notificationEmails = ['admin@example.com'];
137
+ await webshop.save();
138
+
139
+ await new EmailTemplateFactory({
140
+ organization,
141
+ webshopId: webshop.id,
142
+ type: EmailTemplateType.OrderConfirmationOnline,
143
+ subject: 'Bevestiging',
144
+ html: '<p>Bevestiging</p>',
145
+ text: 'Bevestiging',
146
+ }).create();
147
+
148
+ // Default (Dutch) content + a French translation, so we can tell which language was selected
149
+ await new EmailTemplateFactory({
150
+ organization,
151
+ webshopId: webshop.id,
152
+ type: EmailTemplateType.OrderNotification,
153
+ subject: 'Nederlandse melding',
154
+ html: '<p>NL [{{firstName}}][{{lastName}}] {{orderUrl}}</p>',
155
+ text: 'Nederlandse melding',
156
+ language: Language.Dutch,
157
+ translations: new Map([
158
+ [Language.French, EmailContent.create({
159
+ subject: 'Franse melding',
160
+ html: '<p>FR [{{firstName}}][{{lastName}}] {{orderUrl}}</p>',
161
+ text: 'Franse melding',
162
+ })],
163
+ ]),
164
+ }).create();
165
+
166
+ // The customer ordered in Dutch, the webshop default language is French
167
+ const order = await createOrder(webshop, { consumerLanguage: Language.Dutch, valid: false });
168
+ const loadedOrder = loadRelations(order, webshop, organization);
169
+
170
+ await OrderService.markValid(loadedOrder, null, []);
171
+
172
+ const emails = await EmailMocker.transactional.getSucceededEmails();
173
+ expect(emails).toHaveLength(2);
174
+
175
+ const customerEmail = emails.find(e => e.to.includes('john@example.com'));
176
+ const notification = emails.find(e => e.to.includes('admin@example.com'));
177
+ expect(customerEmail).toBeDefined();
178
+ expect(notification).toBeDefined();
179
+
180
+ // The customer name is cleared, so the notification is not addressed to the customer
181
+ expect(notification!.to).toBe('admin@example.com');
182
+
183
+ // The firstName/lastName replacements are dropped, so the customer name doesn't leak
184
+ expect(notification!.html).toContain('[][]');
185
+ expect(notification!.html).not.toContain('John');
186
+ expect(notification!.html).not.toContain('Doe');
187
+
188
+ // The language falls back to the webshop default (French), not the customer's (Dutch)
189
+ expect(notification!.subject).toBe('Franse melding');
190
+ expect(notification!.html).toContain('FR ');
191
+
192
+ // The orderUrl of the notification points at the dashboard, not at the webshop order page
193
+ expect(notification!.html).toContain('/webshops/');
194
+
195
+ // The customer still receives their own confirmation
196
+ expect(customerEmail!.subject).toBe('Bevestiging');
197
+ });
198
+ });
199
+
200
+ describe('markPaid', () => {
201
+ test('sends the tickets email when tickets were created', async () => {
202
+ const organization = await new OrganizationFactory({}).create();
203
+ const productPrice = ProductPrice.create({ name: 'Standaard', price: 0 });
204
+ const ticketProduct = Product.create({
205
+ name: 'Ticket',
206
+ type: ProductType.Ticket,
207
+ prices: [productPrice],
208
+ });
209
+
210
+ const webshop = await new WebshopFactory({
211
+ organizationId: organization.id,
212
+ meta: WebshopMetaData.patch({ ticketType: WebshopTicketType.Tickets }),
213
+ products: [ticketProduct],
214
+ }).create();
215
+
216
+ await new EmailTemplateFactory({
217
+ organization,
218
+ webshopId: webshop.id,
219
+ type: EmailTemplateType.TicketsReceivedTransfer,
220
+ subject: 'Tickets ontvangen',
221
+ html: '<p>Tickets ontvangen</p>',
222
+ text: 'Tickets ontvangen',
223
+ }).create();
224
+
225
+ await new EmailTemplateFactory({
226
+ organization,
227
+ webshopId: webshop.id,
228
+ type: EmailTemplateType.OrderReceivedTransfer,
229
+ subject: 'Betaling ontvangen',
230
+ html: '<p>Betaling ontvangen</p>',
231
+ text: 'Betaling ontvangen',
232
+ }).create();
233
+
234
+ const order = await createOrder(webshop, {
235
+ cart: Cart.create({
236
+ items: [CartItem.create({ product: ticketProduct, productPrice, amount: 1 })],
237
+ }),
238
+ });
239
+
240
+ const payment = await createTransferPayment(organization);
241
+ await OrderService.markPaid(order, payment, organization, webshop);
242
+
243
+ const emails = await EmailMocker.transactional.getSucceededEmails();
244
+ expect(emails).toHaveLength(1);
245
+ expect(emails[0].subject).toBe('Tickets ontvangen');
246
+ });
247
+
248
+ test('sends the paid email for a transfer payment when no tickets were created', async () => {
249
+ const organization = await new OrganizationFactory({}).create();
250
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
251
+
252
+ await new EmailTemplateFactory({
253
+ organization,
254
+ webshopId: webshop.id,
255
+ type: EmailTemplateType.TicketsReceivedTransfer,
256
+ subject: 'Tickets ontvangen',
257
+ html: '<p>Tickets ontvangen</p>',
258
+ text: 'Tickets ontvangen',
259
+ }).create();
260
+
261
+ await new EmailTemplateFactory({
262
+ organization,
263
+ webshopId: webshop.id,
264
+ type: EmailTemplateType.OrderReceivedTransfer,
265
+ subject: 'Betaling ontvangen',
266
+ html: '<p>Betaling ontvangen</p>',
267
+ text: 'Betaling ontvangen',
268
+ }).create();
269
+
270
+ const order = await createOrder(webshop);
271
+ const payment = await createTransferPayment(organization);
272
+
273
+ await OrderService.markPaid(order, payment, organization, webshop);
274
+
275
+ const emails = await EmailMocker.transactional.getSucceededEmails();
276
+ expect(emails).toHaveLength(1);
277
+ expect(emails[0].subject).toBe('Betaling ontvangen');
278
+ });
279
+
280
+ test('does not send the paid email for a non-transfer payment when no tickets were created', async () => {
281
+ const organization = await new OrganizationFactory({}).create();
282
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
283
+
284
+ await new EmailTemplateFactory({
285
+ organization,
286
+ webshopId: webshop.id,
287
+ type: EmailTemplateType.OrderReceivedTransfer,
288
+ subject: 'Betaling ontvangen',
289
+ html: '<p>Betaling ontvangen</p>',
290
+ text: 'Betaling ontvangen',
291
+ }).create();
292
+
293
+ const order = await createOrder(webshop, { paymentMethod: PaymentMethod.PointOfSale });
294
+
295
+ const payment = new Payment();
296
+ payment.organizationId = organization.id;
297
+ payment.method = PaymentMethod.PointOfSale;
298
+ payment.status = PaymentStatus.Succeeded;
299
+ payment.price = 1000;
300
+ payment.paidAt = new Date();
301
+ await payment.save();
302
+
303
+ await OrderService.markPaid(order, payment, organization, webshop);
304
+
305
+ expect(await EmailMocker.transactional.getSucceededEmails()).toHaveLength(0);
306
+ });
307
+ });
308
+ });