@stamhoofd/backend 2.133.0 → 2.135.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 (35) hide show
  1. package/package.json +18 -18
  2. package/src/crons/invoices.ts +5 -3
  3. package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
  4. package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
  5. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +224 -2
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +190 -2
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +72 -14
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
  9. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
  10. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +183 -0
  11. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
  12. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +158 -0
  14. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
  15. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
  16. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  17. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
  18. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +70 -0
  19. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
  20. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
  21. package/src/helpers/GlobalHelper.ts +1 -1
  22. package/src/helpers/MembershipCharger.ts +2 -1
  23. package/src/helpers/StripeInvoicer.ts +13 -2
  24. package/src/helpers/UitpasTokenRepository.ts +12 -11
  25. package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
  26. package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
  27. package/src/services/PaymentService.ts +33 -8
  28. package/src/services/PlatformMembershipService.ts +2 -1
  29. package/src/services/RegistrationService.ts +5 -0
  30. package/src/services/uitpas/UitpasService.ts +3 -4
  31. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  32. package/src/sql-filters/balance-item-payments.ts +4 -56
  33. package/src/sql-filters/balance-items.ts +69 -10
  34. package/src/sql-filters/members.ts +5 -0
  35. package/src/sql-sorters/members.ts +12 -1
@@ -0,0 +1,183 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { BalanceItemFactory, GroupFactory, MemberFactory, OrderFactory, OrganizationFactory, RegistrationFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
+ import type { BalanceItem as BalanceItemStruct, PaginatedResponse, StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
6
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
7
+ import { GetBalanceItemsEndpoint } from './GetBalanceItemsEndpoint.js';
8
+
9
+ describe('Endpoint.GetBalanceItemsEndpoint', () => {
10
+ const endpoint = new GetBalanceItemsEndpoint();
11
+
12
+ const getBalanceItems = async ({ filter, organization, user }: { filter: StamhoofdFilter | null; organization: Organization; user: User }) => {
13
+ const token = await Token.createToken(user);
14
+
15
+ const request = Request.get({
16
+ path: '/balance-items',
17
+ host: organization.getApiHost(),
18
+ query: new LimitedFilteredRequest({
19
+ filter,
20
+ limit: 100,
21
+ }),
22
+ headers: {
23
+ authorization: 'Bearer ' + token.accessToken,
24
+ },
25
+ });
26
+
27
+ return testServer.test<PaginatedResponse<BalanceItemStruct[], LimitedFilteredRequest>>(endpoint, request);
28
+ };
29
+
30
+ const createFinanceUser = async (organization: Organization) => {
31
+ return await new UserFactory({
32
+ organization,
33
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
34
+ }).create();
35
+ };
36
+
37
+ describe('Filtering on the associated webshop', () => {
38
+ test('only returns balance items linked to an order of the given webshop', async () => {
39
+ const organization = await new OrganizationFactory({}).create();
40
+ const user = await createFinanceUser(organization);
41
+
42
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
43
+ const otherWebshop = await new WebshopFactory({ organizationId: organization.id }).create();
44
+
45
+ const order = await new OrderFactory({ webshop }).create();
46
+ const otherOrder = await new OrderFactory({ webshop: otherWebshop }).create();
47
+
48
+ const matchingItem = await new BalanceItemFactory({
49
+ organizationId: organization.id,
50
+ orderId: order.id,
51
+ type: BalanceItemType.Order,
52
+ amount: 1,
53
+ unitPrice: 10_00,
54
+ }).create();
55
+
56
+ // Negative controls: an order of another webshop and an item without an order
57
+ await new BalanceItemFactory({
58
+ organizationId: organization.id,
59
+ orderId: otherOrder.id,
60
+ type: BalanceItemType.Order,
61
+ amount: 1,
62
+ unitPrice: 10_00,
63
+ }).create();
64
+ await new BalanceItemFactory({
65
+ organizationId: organization.id,
66
+ type: BalanceItemType.Other,
67
+ amount: 1,
68
+ unitPrice: 10_00,
69
+ }).create();
70
+
71
+ const response = await getBalanceItems({
72
+ filter: {
73
+ order: {
74
+ webshopId: {
75
+ $in: [webshop.id],
76
+ },
77
+ },
78
+ },
79
+ organization,
80
+ user,
81
+ });
82
+
83
+ expect(response.status).toBe(200);
84
+ expect(response.body.results.map(r => r.id)).toEqual([matchingItem.id]);
85
+ });
86
+ });
87
+
88
+ describe('Filtering on the associated group', () => {
89
+ test('only returns balance items linked to a registration of the given group', async () => {
90
+ const organization = await new OrganizationFactory({}).create();
91
+ const user = await createFinanceUser(organization);
92
+
93
+ const member = await new MemberFactory({}).create();
94
+ const group = await new GroupFactory({ organization }).create();
95
+ const otherGroup = await new GroupFactory({ organization }).create();
96
+
97
+ const registration = await new RegistrationFactory({ member, group }).create();
98
+ const otherRegistration = await new RegistrationFactory({ member, group: otherGroup }).create();
99
+
100
+ const matchingItem = await new BalanceItemFactory({
101
+ organizationId: organization.id,
102
+ registrationId: registration.id,
103
+ type: BalanceItemType.Registration,
104
+ amount: 1,
105
+ unitPrice: 10_00,
106
+ }).create();
107
+
108
+ // Negative control: a registration for another group
109
+ await new BalanceItemFactory({
110
+ organizationId: organization.id,
111
+ registrationId: otherRegistration.id,
112
+ type: BalanceItemType.Registration,
113
+ amount: 1,
114
+ unitPrice: 10_00,
115
+ }).create();
116
+
117
+ const response = await getBalanceItems({
118
+ filter: {
119
+ registration: {
120
+ groupId: {
121
+ $in: [group.id],
122
+ },
123
+ },
124
+ },
125
+ organization,
126
+ user,
127
+ });
128
+
129
+ expect(response.status).toBe(200);
130
+ expect(response.body.results.map(r => r.id)).toEqual([matchingItem.id]);
131
+ });
132
+ });
133
+
134
+ describe('Filtering on the membership type', () => {
135
+ const createMembershipItem = async ({ organization, membershipTypeId }: { organization: Organization; membershipTypeId: string }) => {
136
+ return await new BalanceItemFactory({
137
+ organizationId: organization.id,
138
+ type: BalanceItemType.PlatformMembership,
139
+ amount: 1,
140
+ unitPrice: 10_00,
141
+ relations: new Map([
142
+ [
143
+ BalanceItemRelationType.MembershipType,
144
+ BalanceItemRelation.create({
145
+ id: membershipTypeId,
146
+ name: new TranslatedString('Membership type'),
147
+ }),
148
+ ],
149
+ ]),
150
+ }).create();
151
+ };
152
+
153
+ test('only returns balance items whose membership type relation matches', async () => {
154
+ const organization = await new OrganizationFactory({}).create();
155
+ const user = await createFinanceUser(organization);
156
+
157
+ const membershipTypeId = 'membership-type-a';
158
+ const matchingItem = await createMembershipItem({ organization, membershipTypeId });
159
+
160
+ // Negative controls: a different membership type and an item without any relation
161
+ await createMembershipItem({ organization, membershipTypeId: 'membership-type-b' });
162
+ await new BalanceItemFactory({
163
+ organizationId: organization.id,
164
+ type: BalanceItemType.Other,
165
+ amount: 1,
166
+ unitPrice: 10_00,
167
+ }).create();
168
+
169
+ const response = await getBalanceItems({
170
+ filter: {
171
+ membershipType: {
172
+ $in: [membershipTypeId],
173
+ },
174
+ },
175
+ organization,
176
+ user,
177
+ });
178
+
179
+ expect(response.status).toBe(200);
180
+ expect(response.body.results.map(r => r.id)).toEqual([matchingItem.id]);
181
+ });
182
+ });
183
+ });
@@ -161,6 +161,8 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
161
161
  }
162
162
  organization.privateMeta.privateKey = request.body.privateMeta.privateKey ?? organization.privateMeta.privateKey;
163
163
  organization.privateMeta.featureFlags = patchObject(organization.privateMeta.featureFlags, request.body.privateMeta.featureFlags);
164
+ organization.privateMeta.externalSyncData = patchObject(organization.privateMeta.externalSyncData, request.body.privateMeta.externalSyncData);
165
+ organization.privateMeta.externalGroupNumber = patchObject(organization.privateMeta.externalGroupNumber, request.body.privateMeta.externalGroupNumber);
164
166
  organization.privateMeta.balanceNotificationSettings = patchObject(organization.privateMeta.balanceNotificationSettings, request.body.privateMeta.balanceNotificationSettings);
165
167
  organization.privateMeta.invoiceSettings = patchObject(organization.privateMeta.invoiceSettings, request.body.privateMeta.invoiceSettings);
166
168
  organization.privateMeta.recordAnswers = request.body.privateMeta.recordAnswers.applyTo(organization.privateMeta.recordAnswers);
@@ -84,14 +84,7 @@ export class SetUitpasClientCredentialsEndpoint extends Endpoint<Params, Query,
84
84
 
85
85
  // store the client credentials and store new status in one operation
86
86
  if (!reEvaluation) {
87
- const valid = await UitpasService.storeIfValid(organization.id, request.body.clientId, request.body.clientSecret);
88
- if (!valid) {
89
- throw new SimpleError({
90
- message: 'The provided client credentials are not valid',
91
- code: 'invalid_client_credentials',
92
- human: $t('%1BG'),
93
- });
94
- }
87
+ await UitpasService.storeIfValid(organization.id, request.body.clientId, request.body.clientSecret);
95
88
  }
96
89
  organization.meta.uitpasClientCredentialsStatus = UitpasClientCredentialsStatus.NotChecked;
97
90
  await organization.save();
@@ -0,0 +1,158 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { BalanceItem, BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
+ import type { PaginatedResponse, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PaymentMethod, PaymentStatus, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
6
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
7
+ import { GetPaymentsEndpoint } from './GetPaymentsEndpoint.js';
8
+
9
+ // These tests exercise the balance-item filters reused inside the payments query (balanceItemPayments ->
10
+ // balanceItem -> ...), which is the path where balance_items is joined into another query.
11
+ describe('Endpoint.GetPaymentsEndpoint', () => {
12
+ const endpoint = new GetPaymentsEndpoint();
13
+
14
+ const getPayments = async ({ filter, organization, user }: { filter: StamhoofdFilter | null; organization: Organization; user: User }) => {
15
+ const token = await Token.createToken(user);
16
+
17
+ const request = Request.get({
18
+ path: '/payments',
19
+ host: organization.getApiHost(),
20
+ query: new LimitedFilteredRequest({
21
+ filter,
22
+ limit: 100,
23
+ }),
24
+ headers: {
25
+ authorization: 'Bearer ' + token.accessToken,
26
+ },
27
+ });
28
+
29
+ return testServer.test<PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>>(endpoint, request);
30
+ };
31
+
32
+ const createFinanceUser = async (organization: Organization) => {
33
+ return await new UserFactory({
34
+ organization,
35
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
36
+ }).create();
37
+ };
38
+
39
+ const createPaymentForBalanceItem = async (organization: Organization, balanceItem: BalanceItem) => {
40
+ const payment = new Payment();
41
+ payment.method = PaymentMethod.Transfer;
42
+ payment.status = PaymentStatus.Succeeded;
43
+ payment.organizationId = organization.id;
44
+ payment.price = 10_00;
45
+ await payment.save();
46
+
47
+ const balanceItemPayment = new BalanceItemPayment();
48
+ balanceItemPayment.balanceItemId = balanceItem.id;
49
+ balanceItemPayment.paymentId = payment.id;
50
+ balanceItemPayment.price = 10_00;
51
+ balanceItemPayment.organizationId = organization.id;
52
+ await balanceItemPayment.save();
53
+
54
+ return payment;
55
+ };
56
+
57
+ describe('Filtering on the balance item of a payment', () => {
58
+ test('only returns payments whose balance item is linked to an order of the given webshop', async () => {
59
+ const organization = await new OrganizationFactory({}).create();
60
+ const user = await createFinanceUser(organization);
61
+
62
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
63
+ const otherWebshop = await new WebshopFactory({ organizationId: organization.id }).create();
64
+
65
+ const order = await new OrderFactory({ webshop }).create();
66
+ const otherOrder = await new OrderFactory({ webshop: otherWebshop }).create();
67
+
68
+ const matchingItem = await new BalanceItemFactory({
69
+ organizationId: organization.id,
70
+ orderId: order.id,
71
+ type: BalanceItemType.Order,
72
+ amount: 1,
73
+ unitPrice: 10_00,
74
+ }).create();
75
+ const matchingPayment = await createPaymentForBalanceItem(organization, matchingItem);
76
+
77
+ // Negative control: a payment for a balance item of another webshop's order
78
+ const otherItem = await new BalanceItemFactory({
79
+ organizationId: organization.id,
80
+ orderId: otherOrder.id,
81
+ type: BalanceItemType.Order,
82
+ amount: 1,
83
+ unitPrice: 10_00,
84
+ }).create();
85
+ await createPaymentForBalanceItem(organization, otherItem);
86
+
87
+ const response = await getPayments({
88
+ filter: {
89
+ balanceItemPayments: {
90
+ $elemMatch: {
91
+ balanceItem: {
92
+ order: {
93
+ webshopId: {
94
+ $in: [webshop.id],
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ },
101
+ organization,
102
+ user,
103
+ });
104
+
105
+ expect(response.status).toBe(200);
106
+ expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
107
+ });
108
+
109
+ test('only returns payments whose balance item matches the given membership type', async () => {
110
+ const organization = await new OrganizationFactory({}).create();
111
+ const user = await createFinanceUser(organization);
112
+
113
+ const createMembershipItem = async (membershipTypeId: string) => {
114
+ return await new BalanceItemFactory({
115
+ organizationId: organization.id,
116
+ type: BalanceItemType.PlatformMembership,
117
+ amount: 1,
118
+ unitPrice: 10_00,
119
+ relations: new Map([
120
+ [
121
+ BalanceItemRelationType.MembershipType,
122
+ BalanceItemRelation.create({
123
+ id: membershipTypeId,
124
+ name: new TranslatedString('Membership type'),
125
+ }),
126
+ ],
127
+ ]),
128
+ }).create();
129
+ };
130
+
131
+ const matchingItem = await createMembershipItem('membership-type-a');
132
+ const matchingPayment = await createPaymentForBalanceItem(organization, matchingItem);
133
+
134
+ // Negative control: a payment for a balance item with a different membership type
135
+ const otherItem = await createMembershipItem('membership-type-b');
136
+ await createPaymentForBalanceItem(organization, otherItem);
137
+
138
+ const response = await getPayments({
139
+ filter: {
140
+ balanceItemPayments: {
141
+ $elemMatch: {
142
+ balanceItem: {
143
+ membershipType: {
144
+ $in: ['membership-type-a'],
145
+ },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ organization,
151
+ user,
152
+ });
153
+
154
+ expect(response.status).toBe(200);
155
+ expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,49 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { OrganizationFactory, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
3
+ import { PermissionLevel, Permissions, PrivateWebshop, WebshopMetaData } from '@stamhoofd/structures';
4
+ import { Language } from '@stamhoofd/types/Language';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+
7
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
8
+ import { PatchWebshopEndpoint } from './PatchWebshopEndpoint.js';
9
+
10
+ describe('Endpoint.PatchWebshop', () => {
11
+ const endpoint = new PatchWebshopEndpoint();
12
+
13
+ beforeEach(async () => {
14
+ TestUtils.setEnvironment('userMode', 'platform');
15
+ });
16
+
17
+ async function createAdminContext() {
18
+ const organization = await new OrganizationFactory({}).create();
19
+ const user = await new UserFactory({
20
+ organization,
21
+ permissions: Permissions.create({
22
+ level: PermissionLevel.Full,
23
+ }),
24
+ }).create();
25
+ const token = await Token.createToken(user);
26
+
27
+ return { organization, token };
28
+ }
29
+
30
+ test('defaultLanguage can be updated', async () => {
31
+ const { organization, token } = await createAdminContext();
32
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
33
+
34
+ const patch = PrivateWebshop.patch({
35
+ meta: WebshopMetaData.patch({
36
+ defaultLanguage: Language.French,
37
+ }),
38
+ });
39
+ const request = Request.buildJson('PATCH', '/webshop/' + webshop.id, organization.getApiHost(), patch);
40
+ request.headers.authorization = 'Bearer ' + token.accessToken;
41
+
42
+ const response = await testServer.test(endpoint, request);
43
+ expect(response.body.meta.defaultLanguage).toBe(Language.French);
44
+
45
+ // Persisted in the database
46
+ const reloaded = await Webshop.getByID(webshop.id);
47
+ expect(reloaded?.meta.defaultLanguage).toBe(Language.French);
48
+ });
49
+ });
@@ -0,0 +1,71 @@
1
+ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
+ import { PatchableArray } from '@simonbackx/simple-encoding';
3
+ import { Request } from '@simonbackx/simple-endpoints';
4
+ import { Order, OrganizationFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
5
+ import { Cart, CartItem, Customer, OrderData, PaymentMethod, PermissionLevel, Permissions, PrivateOrder, Product, ProductPrice, WebshopMetaData } from '@stamhoofd/structures';
6
+ import { TestUtils } from '@stamhoofd/test-utils';
7
+ import { Language } from '@stamhoofd/types/Language';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
11
+ import { PatchWebshopOrdersEndpoint } from './PatchWebshopOrdersEndpoint.js';
12
+
13
+ describe('Endpoint.PatchWebshopOrders', () => {
14
+ const endpoint = new PatchWebshopOrdersEndpoint();
15
+
16
+ beforeEach(async () => {
17
+ TestUtils.setEnvironment('userMode', 'platform');
18
+ });
19
+
20
+ test('Manually created orders use the webshop default language', async () => {
21
+ const organization = await new OrganizationFactory({}).create();
22
+ const user = await new UserFactory({
23
+ organization,
24
+ permissions: Permissions.create({
25
+ level: PermissionLevel.Full,
26
+ }),
27
+ }).create();
28
+ const token = await Token.createToken(user);
29
+
30
+ const freeProductPrice = ProductPrice.create({ name: 'Free', price: 0, stock: 100 });
31
+ const product = Product.create({ name: 'Product', stock: 100, prices: [freeProductPrice] });
32
+
33
+ const webshop = await new WebshopFactory({
34
+ organizationId: organization.id,
35
+ meta: WebshopMetaData.patch({ defaultLanguage: Language.French }),
36
+ products: [product],
37
+ }).create();
38
+
39
+ const orderData = OrderData.create({
40
+ paymentMethod: PaymentMethod.Unknown,
41
+ cart: Cart.create({
42
+ items: [
43
+ CartItem.create({ product, productPrice: freeProductPrice, amount: 1 }),
44
+ ],
45
+ }),
46
+ customer: Customer.create({
47
+ firstName: 'John',
48
+ lastName: 'Doe',
49
+ email: 'john@example.com',
50
+ }),
51
+ });
52
+
53
+ const patchArray: PatchableArrayAutoEncoder<PrivateOrder> = new PatchableArray();
54
+ patchArray.addPut(PrivateOrder.create({
55
+ id: uuidv4(),
56
+ data: orderData,
57
+ webshopId: webshop.id,
58
+ }));
59
+
60
+ const r = Request.buildJson('PATCH', `/webshop/${webshop.id}/orders`, organization.getApiHost(), patchArray);
61
+ r.headers.authorization = 'Bearer ' + token.accessToken;
62
+
63
+ const response = await testServer.test(endpoint, r);
64
+ expect(response.body).toHaveLength(1);
65
+ expect(response.body[0].consumerLanguage).toEqual(Language.French);
66
+
67
+ // Persisted in the dedicated column
68
+ const orderModel = (await Order.getByID(response.body[0].id))!;
69
+ expect(orderModel.consumerLanguage).toEqual(Language.French);
70
+ });
71
+ });
@@ -123,6 +123,10 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
123
123
  model.status = struct.status;
124
124
  model.data = struct.data;
125
125
 
126
+ // Manually created orders have no customer request language, so use the webshop's
127
+ // default language for any future emails to this order.
128
+ model.consumerLanguage = webshop.meta.defaultLanguage;
129
+
126
130
  // For now, we don't invalidate tickets, because they will get invalidated at scan time (the order status is checked)
127
131
  // This allows you to revalidate a ticket without needing to generate a new one (e.g. when accidentally canceling an order)
128
132
  // -> the user doesn't need to download the ticket again
@@ -1,6 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import { OrganizationFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
3
3
  import { PermissionLevel, Permissions, WebshopAuthType, WebshopMetaData } from '@stamhoofd/structures';
4
+ import { Language } from '@stamhoofd/types/Language';
4
5
 
5
6
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
6
7
  import { testServer } from '../../../../tests/helpers/TestServer.js';
@@ -30,6 +31,16 @@ describe('Endpoint.GetWebshop', () => {
30
31
  expect((response.body as any).privateMeta).toBeUndefined();
31
32
  });
32
33
 
34
+ test('Webshop defaults to Dutch as its default language', async () => {
35
+ const organization = await new OrganizationFactory({}).create();
36
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
37
+
38
+ const r = Request.buildJson('GET', '/webshop/' + webshop.id, organization.getApiHost());
39
+
40
+ const response = await testServer.test(endpoint, r);
41
+ expect(response.body.meta.defaultLanguage).toBe(Language.Dutch);
42
+ });
43
+
33
44
  test('Allow access without organization scope', async () => {
34
45
  const organization = await new OrganizationFactory({}).create();
35
46
  const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
@@ -0,0 +1,70 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import { EmailTemplateFactory, OrganizationFactory, WebshopFactory } from '@stamhoofd/models';
4
+ import { Cart, CartItem, Customer, EmailTemplateType, OrderData, PaymentMethod, Product, ProductPrice } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { Country } from '@stamhoofd/types/Country';
7
+ import { Language } from '@stamhoofd/types/Language';
8
+
9
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
10
+ import { PlaceOrderEndpoint } from './PlaceOrderEndpoint.js';
11
+
12
+ describe('Order confirmation email language', () => {
13
+ const endpoint = new PlaceOrderEndpoint();
14
+
15
+ test('renders $t in the language the customer used while ordering', async () => {
16
+ // Make French a valid locale so its real translations (loaded from disk) are actually used.
17
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
18
+
19
+ const organization = await new OrganizationFactory({}).create();
20
+ const freeProductPrice = ProductPrice.create({ name: 'Free', price: 0, stock: 100 });
21
+ const product = Product.create({ name: 'Product', stock: 100, prices: [freeProductPrice] });
22
+ const webshop = await new WebshopFactory({
23
+ organizationId: organization.id,
24
+ products: [product],
25
+ }).create();
26
+
27
+ // Confirmation email template for this webshop (auto-includes {{orderStatus}} etc.)
28
+ await new EmailTemplateFactory({
29
+ organization,
30
+ webshopId: webshop.id,
31
+ type: EmailTemplateType.OrderConfirmationOnline,
32
+ }).create();
33
+
34
+ // The order status label (OrderStatus.Created) is rendered via $t inside the confirmation
35
+ // email. These are the translations looked up in shared/locales/dist/locales/digit/{nl,fr}-BE.json,
36
+ // hardcoded on purpose so we don't verify $t with the same $t machinery we're testing.
37
+ const dutchStatusName = 'Nieuw';
38
+ const frenchStatusName = 'Nouveau';
39
+
40
+ const placeFreeOrderInLanguage = async (language: string) => {
41
+ EmailMocker.transactional.reset();
42
+
43
+ const orderData = OrderData.create({
44
+ paymentMethod: PaymentMethod.Unknown,
45
+ cart: Cart.create({
46
+ items: [CartItem.create({ product, productPrice: freeProductPrice, amount: 1 })],
47
+ }),
48
+ customer: Customer.create({ firstName: 'John', lastName: 'Doe', email: 'john@example.com', phone: '+32412345678' }),
49
+ });
50
+
51
+ const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), orderData);
52
+ r.headers['accept-language'] = language;
53
+ await testServer.test(endpoint, r);
54
+
55
+ const emails = await EmailMocker.transactional.getSucceededEmails();
56
+ expect(emails).toHaveLength(1);
57
+ return emails[0];
58
+ };
59
+
60
+ // French order → the $t status label is rendered in French, without Dutch leaking in
61
+ const frenchEmail = await placeFreeOrderInLanguage('fr');
62
+ expect(frenchEmail.html).toContain(frenchStatusName);
63
+ expect(frenchEmail.html).not.toContain(dutchStatusName);
64
+
65
+ // Dutch order → rendered in Dutch, without French leaking in
66
+ const dutchEmail = await placeFreeOrderInLanguage('nl');
67
+ expect(dutchEmail.html).toContain(dutchStatusName);
68
+ expect(dutchEmail.html).not.toContain(frenchStatusName);
69
+ });
70
+ });