@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.
- package/package.json +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- 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 +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- 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/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- 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 +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- 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/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -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 +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- 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,
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|