@stamhoofd/backend 2.134.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.
- package/package.json +17 -17
- package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +224 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
- package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
- package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +70 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
- package/src/helpers/GlobalHelper.ts +1 -1
- package/src/helpers/MembershipCharger.ts +2 -1
- package/src/helpers/UitpasTokenRepository.ts +12 -11
- package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
- package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
- package/src/services/PaymentService.ts +33 -8
- package/src/services/PlatformMembershipService.ts +2 -1
- package/src/services/RegistrationService.ts +5 -0
- package/src/services/uitpas/UitpasService.ts +3 -4
- package/src/sql-filters/members.ts +5 -0
- package/src/sql-sorters/members.ts +12 -1
package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts
CHANGED
|
@@ -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
|
-
|
|
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,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
|
+
});
|
|
@@ -6,8 +6,10 @@ import type { Organization, StripeAccount, User } from '@stamhoofd/models';
|
|
|
6
6
|
import { Order, OrganizationFactory, Payment, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
|
|
7
7
|
import type { OrderResponse } from '@stamhoofd/structures';
|
|
8
8
|
import { Address, Cart, CartItem, CartItemOption, Customer, Option, OptionMenu, OrderData, PaymentConfiguration, PaymentMethod, PermissionLevel, Permissions, PrivateOrder, PrivatePaymentConfiguration, Product, ProductPrice, ProductType, SeatingPlan, SeatingPlanRow, SeatingPlanSeat, SeatingPlanSection, TransferSettings, WebshopAuthType, WebshopDeliveryMethod, WebshopMetaData, WebshopOnSiteMethod, WebshopPrivateMetaData, WebshopTakeoutMethod, WebshopTimeSlot } from '@stamhoofd/structures';
|
|
9
|
-
import {
|
|
9
|
+
import { I18n } from '@stamhoofd/backend-i18n';
|
|
10
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
10
11
|
import { Country } from '@stamhoofd/types/Country';
|
|
12
|
+
import { Language } from '@stamhoofd/types/Language';
|
|
11
13
|
import sinon from 'sinon';
|
|
12
14
|
import { v4 as uuidv4 } from 'uuid';
|
|
13
15
|
|
|
@@ -573,4 +575,69 @@ describe('Endpoint.PlaceOrderEndpoint', () => {
|
|
|
573
575
|
expect(payment.customer!.equals(orderModel.data.customer.toPaymentCustomer())).toBe(true);
|
|
574
576
|
});
|
|
575
577
|
});
|
|
578
|
+
|
|
579
|
+
describe('Order language', () => {
|
|
580
|
+
function buildFreeOrderData() {
|
|
581
|
+
return OrderData.create({
|
|
582
|
+
paymentMethod: PaymentMethod.Unknown,
|
|
583
|
+
checkoutMethod: onSiteMethod,
|
|
584
|
+
timeSlot: slot4,
|
|
585
|
+
cart: Cart.create({
|
|
586
|
+
items: [
|
|
587
|
+
CartItem.create({
|
|
588
|
+
product,
|
|
589
|
+
productPrice: freeProductPrice,
|
|
590
|
+
amount: 1,
|
|
591
|
+
}),
|
|
592
|
+
],
|
|
593
|
+
}),
|
|
594
|
+
customer,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
test('Stores the request language on a placed order', async () => {
|
|
599
|
+
const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), buildFreeOrderData());
|
|
600
|
+
r.headers['accept-language'] = 'en';
|
|
601
|
+
|
|
602
|
+
const response = await testServer.test(endpoint, r);
|
|
603
|
+
expect(response.body.order.consumerLanguage).toEqual('en');
|
|
604
|
+
|
|
605
|
+
// Persisted in the dedicated column
|
|
606
|
+
const orderModel = (await Order.getByID(response.body.order.id))!;
|
|
607
|
+
expect(orderModel.consumerLanguage).toEqual('en');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('Falls back to the webshop default language when the request language is unknown', async () => {
|
|
611
|
+
webshop.meta.defaultLanguage = Language.French;
|
|
612
|
+
await webshop.save();
|
|
613
|
+
|
|
614
|
+
// The struct default is Dutch, so the server-side fallback to the webshop default is observable.
|
|
615
|
+
const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), buildFreeOrderData());
|
|
616
|
+
|
|
617
|
+
const response = await testServer.test(endpoint, r);
|
|
618
|
+
expect(response.body.order.consumerLanguage).toEqual(Language.French);
|
|
619
|
+
|
|
620
|
+
const orderModel = (await Order.getByID(response.body.order.id))!;
|
|
621
|
+
expect(orderModel.consumerLanguage).toEqual(Language.French);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('runWithLocale overrides the global language used for $t evaluation', async () => {
|
|
625
|
+
// Allow French to be a valid locale for this test (test env has no locales configured).
|
|
626
|
+
TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
|
|
627
|
+
|
|
628
|
+
const i18n = new I18n(Language.French, Country.Belgium);
|
|
629
|
+
|
|
630
|
+
expect(I18n.override).toBeNull();
|
|
631
|
+
expect((global as any).$getLanguage()).toEqual(Language.Dutch);
|
|
632
|
+
|
|
633
|
+
await I18n.runWithLocale(i18n, async () => {
|
|
634
|
+
expect(I18n.override).toBe(i18n);
|
|
635
|
+
expect((global as any).$getLanguage()).toEqual(Language.French);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Restored afterwards
|
|
639
|
+
expect(I18n.override).toBeNull();
|
|
640
|
+
expect((global as any).$getLanguage()).toEqual(Language.Dutch);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
576
643
|
});
|
|
@@ -3,6 +3,7 @@ import type { Decoder } from '@simonbackx/simple-encoding';
|
|
|
3
3
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { I18n } from '@stamhoofd/backend-i18n';
|
|
6
7
|
import { Email } from '@stamhoofd/email';
|
|
7
8
|
import { BalanceItem, BalanceItemPayment, MolliePayment, Order, PayconiqPayment, Payment, RateLimiter, Webshop, WebshopDiscountCode } from '@stamhoofd/models';
|
|
8
9
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
@@ -132,6 +133,13 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
132
133
|
|
|
133
134
|
const order = new Order().setRelation(Order.webshop, webshop);
|
|
134
135
|
order.data = request.body; // TODO: validate
|
|
136
|
+
|
|
137
|
+
// Store the language the customer used while placing the order, so future emails to
|
|
138
|
+
// this order are rendered in the same language. Fall back to the webshop's default
|
|
139
|
+
// language when it can't be determined from the request.
|
|
140
|
+
const consumerLanguage = I18n.getLanguageFromRequest(request) ?? webshop.meta.defaultLanguage;
|
|
141
|
+
order.consumerLanguage = consumerLanguage;
|
|
142
|
+
|
|
135
143
|
order.organizationId = organization.id;
|
|
136
144
|
order.createdAt = new Date();
|
|
137
145
|
order.createdAt.setMilliseconds(0);
|
|
@@ -17,7 +17,7 @@ export class GlobalHelper {
|
|
|
17
17
|
|
|
18
18
|
static loadGlobalTranslateFunction() {
|
|
19
19
|
function getI18n() {
|
|
20
|
-
return ContextInstance.optional?.i18n ?? new I18n(I18n.defaultLanguage, STAMHOOFD.fixedCountry ?? I18n.defaultCountry);
|
|
20
|
+
return I18n.override ?? ContextInstance.optional?.i18n ?? new I18n(I18n.defaultLanguage, STAMHOOFD.fixedCountry ?? I18n.defaultCountry);
|
|
21
21
|
}
|
|
22
22
|
(global as any).$t = (key: string, replace?: Record<string, string>) => getI18n().$t(key, replace);
|
|
23
23
|
(global as any).$getLanguage = () => getI18n().language;
|
|
@@ -2,7 +2,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
2
2
|
import { BalanceItem, Member, MemberPlatformMembership, Platform, RegistrationPeriod } from '@stamhoofd/models';
|
|
3
3
|
import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
|
|
4
4
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
5
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
import { Formatter, sleep } from '@stamhoofd/utility';
|
|
6
6
|
|
|
7
7
|
export const MembershipCharger = {
|
|
8
8
|
async charge() {
|
|
@@ -178,6 +178,7 @@ export const MembershipCharger = {
|
|
|
178
178
|
const chunkSize = 100;
|
|
179
179
|
|
|
180
180
|
while (true) {
|
|
181
|
+
await sleep(200);
|
|
181
182
|
const q = MemberPlatformMembership.select()
|
|
182
183
|
.where('id', SQLWhereSign.Greater, lastId)
|
|
183
184
|
.where('balanceItemId', null)
|
|
@@ -39,26 +39,27 @@ export class UitpasTokenRepository {
|
|
|
39
39
|
return await QueueHandler.schedule('uitpas/token-' + (organizationId ?? 'platform'), handler);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<
|
|
42
|
+
static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<void> {
|
|
43
43
|
if (!clientId || !clientSecret) { // empty strings
|
|
44
|
-
|
|
44
|
+
throw new SimpleError({
|
|
45
|
+
statusCode: 400,
|
|
46
|
+
code: 'invalid_uitpas_client_credentials',
|
|
47
|
+
message: `Empty UiTPAS client credentials`,
|
|
48
|
+
human: $t(`%ZdU`),
|
|
49
|
+
});
|
|
45
50
|
}
|
|
51
|
+
|
|
46
52
|
let model = new UitpasClientCredential();
|
|
47
53
|
model.organizationId = organizationId;
|
|
48
54
|
model.clientId = clientId;
|
|
49
55
|
model.clientSecret = clientSecret;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
await repo.getNewAccessToken();
|
|
54
|
-
} catch (e) {
|
|
55
|
-
return false; // not valid
|
|
56
|
-
}
|
|
56
|
+
await UitpasTokenRepository.handleInQueue(organizationId, async () => {
|
|
57
|
+
const repo = new UitpasTokenRepository(model);
|
|
58
|
+
await repo.getNewAccessToken();
|
|
57
59
|
// valid -> store
|
|
58
60
|
model = await UitpasTokenRepository.setModelInDb(organizationId, model);
|
|
59
61
|
repo.uitpasClientCredential = model; // update the uitpasClientCredential in the repo
|
|
60
|
-
|
|
61
|
-
return true;
|
|
62
|
+
UitpasTokenRepository.setRepoInMemory(organizationId, repo);
|
|
62
63
|
});
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Organization, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { QueryableModel } from '@stamhoofd/sql';
|
|
4
|
+
import { PaymentStatus } from '@stamhoofd/structures';
|
|
5
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
6
|
+
import { SeedTools } from '../helpers/SeedTools.js';
|
|
7
|
+
import { PaymentService } from '../services/PaymentService.js';
|
|
8
|
+
|
|
9
|
+
export default new Migration(async () => {
|
|
10
|
+
if (STAMHOOFD.environment === 'test') {
|
|
11
|
+
console.log('skipped in tests');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log('Start marking zero-price payments as succeeded.');
|
|
16
|
+
|
|
17
|
+
let succeeded = 0;
|
|
18
|
+
|
|
19
|
+
const result = await SeedTools.loopBatched({
|
|
20
|
+
// Keyset pagination on id: marking a payment as succeeded drops it from this filter,
|
|
21
|
+
// but rows are never skipped because each next batch is fetched by id > lastId.
|
|
22
|
+
query: Payment.select()
|
|
23
|
+
.where('price', 0)
|
|
24
|
+
.where('status', [PaymentStatus.Created, PaymentStatus.Pending]),
|
|
25
|
+
batchSize: 100,
|
|
26
|
+
batchAction: async (payments: Payment[]) => {
|
|
27
|
+
// Load the organizations of this batch in bulk
|
|
28
|
+
const organizationIds = Formatter.uniqueArray(
|
|
29
|
+
payments.map(p => p.organizationId).filter((id): id is string => id !== null),
|
|
30
|
+
);
|
|
31
|
+
const organizations = organizationIds.length ? await Organization.getByIDs(...organizationIds) : [];
|
|
32
|
+
|
|
33
|
+
// Load the attached balance item payments of this batch in bulk, and collect the ids of
|
|
34
|
+
// the payments that move money on at least one balance item. Those need the PaymentService
|
|
35
|
+
// side effects (marking balance items as paid, flushing caches, ...); the rest can be
|
|
36
|
+
// updated directly.
|
|
37
|
+
const { balanceItemPayments } = await Payment.loadBalanceItems(payments);
|
|
38
|
+
const paymentIdsWithNonZeroBalanceItemPayment = new Set(
|
|
39
|
+
balanceItemPayments.filter(bip => bip.price !== 0).map(bip => bip.paymentId),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
for (const payment of payments) {
|
|
43
|
+
// Only route through the PaymentService when the payment moves money on at least one
|
|
44
|
+
// balance item. When all attached balance item payments are zero (or there are none),
|
|
45
|
+
// update the status directly to avoid those side effects.
|
|
46
|
+
const hasNonZeroBalanceItemPayment = paymentIdsWithNonZeroBalanceItemPayment.has(payment.id);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
if (hasNonZeroBalanceItemPayment) {
|
|
50
|
+
if (!payment.organizationId) {
|
|
51
|
+
console.warn('Payment without organizationId, skipping', payment.id);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const organization = organizations.find(o => o.id === payment.organizationId);
|
|
56
|
+
if (!organization) {
|
|
57
|
+
console.warn('Organization not found for payment, skipping', payment.id, payment.organizationId);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Attribute the payment to its original date instead of the migration run date.
|
|
62
|
+
await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded, payment.createdAt);
|
|
63
|
+
} else {
|
|
64
|
+
// No side effects needed: update the status directly.
|
|
65
|
+
payment.status = PaymentStatus.Succeeded;
|
|
66
|
+
payment.paidAt = payment.createdAt;
|
|
67
|
+
await payment.save({
|
|
68
|
+
skipMarkSaved: true,
|
|
69
|
+
skipSendEvents: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
succeeded++;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// Isolate failures per payment: one bad row should not abort (and wedge) the whole migration.
|
|
75
|
+
console.error('Failed to mark payment as succeeded, skipping', payment.id, e);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (QueryableModel.shutdownMigrations) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (QueryableModel.shutdownMigrations) {
|
|
84
|
+
throw new Error('Stopping migration gracefully');
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
console.log(`Finished marking zero-price payments as succeeded: ${succeeded} of ${result.total} payments.`);
|
|
90
|
+
});
|
|
@@ -1208,16 +1208,38 @@ export class PaymentService {
|
|
|
1208
1208
|
privatePaymentConfiguration: PrivatePaymentConfiguration;
|
|
1209
1209
|
adminUserId?: string | null;
|
|
1210
1210
|
}) {
|
|
1211
|
-
|
|
1211
|
+
// Balance items that aren't due yet and that aren't being paid now (amount 0) are not part of
|
|
1212
|
+
// this payment: a registration in its trial period is the main example, nothing is due during
|
|
1213
|
+
// the trial. They still have to be marked due and valid, so the registration is activated and
|
|
1214
|
+
// the amount is billed once the trial ends. Marking them via markPaid means the 'paid' side
|
|
1215
|
+
// effects run exactly once (guarded by the paidAt column), also when they are really paid later.
|
|
1216
|
+
//
|
|
1217
|
+
// Items that the caller does pay (a non-zero amount) are always part of the payment, even when
|
|
1218
|
+
// they have a dueAt: balance items that are due soon are part of the outstanding balance and
|
|
1219
|
+
// can be paid before their due date (and are charged by direct debit).
|
|
1220
|
+
const notDueYetBalanceItems = [...balanceItems.keys()].filter(item => item.dueAt !== null && item.dueAt > new Date() && (balanceItems.get(item) ?? 0) === 0);
|
|
1221
|
+
const payableBalanceItems = new Map([...balanceItems].filter(([item]) => !notDueYetBalanceItems.includes(item)));
|
|
1222
|
+
|
|
1223
|
+
// Passing no payment keeps paid: false, so a trial period isn't shortened.
|
|
1224
|
+
const markNotDueYetValid = async () => {
|
|
1225
|
+
for (const balanceItem of notDueYetBalanceItems) {
|
|
1226
|
+
await BalanceItemService.markPaid(balanceItem, null, organization);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
if (payableBalanceItems.size === 0) {
|
|
1231
|
+
// Nothing to pay now, so no payment is created. The not-due-yet items still need to be
|
|
1232
|
+
// marked valid, otherwise their registration stays invisible.
|
|
1233
|
+
await markNotDueYetValid();
|
|
1212
1234
|
return null;
|
|
1213
1235
|
}
|
|
1214
1236
|
|
|
1215
1237
|
// Calculate total price to pay
|
|
1216
|
-
const { price, roundingAmount, hasNegative, names } = this.calculateTotalPrice({ balanceItems, organization, members });
|
|
1238
|
+
const { price, roundingAmount, hasNegative, names } = this.calculateTotalPrice({ balanceItems: payableBalanceItems, organization, members });
|
|
1217
1239
|
PaymentService.validateTotalPrice({ price, roundingAmount, checkout });
|
|
1218
1240
|
|
|
1219
1241
|
const { customer, prefix } = await this.validateCustomer({ user, checkout, payingOrganization });
|
|
1220
|
-
this.validateVATRates({ customer, sellingOrganization: organization, balanceItems });
|
|
1242
|
+
this.validateVATRates({ customer, sellingOrganization: organization, balanceItems: payableBalanceItems });
|
|
1221
1243
|
|
|
1222
1244
|
const { method, type, mandate } = await this.validatePaymentMethod({
|
|
1223
1245
|
method: checkout.paymentMethod ?? PaymentMethod.Unknown,
|
|
@@ -1226,7 +1248,7 @@ export class PaymentService {
|
|
|
1226
1248
|
customer,
|
|
1227
1249
|
price,
|
|
1228
1250
|
hasNegative,
|
|
1229
|
-
balanceItems,
|
|
1251
|
+
balanceItems: payableBalanceItems,
|
|
1230
1252
|
paymentConfiguration,
|
|
1231
1253
|
user,
|
|
1232
1254
|
payingOrganization: payingOrganization ?? null,
|
|
@@ -1283,7 +1305,7 @@ export class PaymentService {
|
|
|
1283
1305
|
|
|
1284
1306
|
payment.provider = provider;
|
|
1285
1307
|
payment.stripeAccountId = stripeAccount?.id ?? null;
|
|
1286
|
-
await ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...
|
|
1308
|
+
await ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...payableBalanceItems.entries()].map(([_, p]) => p));
|
|
1287
1309
|
await ServiceFeeHelper.setTransferFee({ payment, organization, stripeAccount });
|
|
1288
1310
|
|
|
1289
1311
|
// Add transfer description
|
|
@@ -1320,7 +1342,7 @@ export class PaymentService {
|
|
|
1320
1342
|
const description = organization.name + ' ' + payment.id;
|
|
1321
1343
|
|
|
1322
1344
|
try {
|
|
1323
|
-
for (const [balanceItem, price] of
|
|
1345
|
+
for (const [balanceItem, price] of payableBalanceItems) {
|
|
1324
1346
|
// Create one balance item payment to pay it in one payment
|
|
1325
1347
|
const balanceItemPayment = new BalanceItemPayment();
|
|
1326
1348
|
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
@@ -1332,7 +1354,7 @@ export class PaymentService {
|
|
|
1332
1354
|
}
|
|
1333
1355
|
|
|
1334
1356
|
// Update cached balance items pending amount (only created balance items, because those are involved in the payment)
|
|
1335
|
-
await BalanceItemService.updatePaidAndPending([...
|
|
1357
|
+
await BalanceItemService.updatePaidAndPending([...payableBalanceItems.keys()]);
|
|
1336
1358
|
|
|
1337
1359
|
// Update balance items
|
|
1338
1360
|
if (payment.method === PaymentMethod.Transfer) {
|
|
@@ -1445,6 +1467,9 @@ export class PaymentService {
|
|
|
1445
1467
|
throw e;
|
|
1446
1468
|
}
|
|
1447
1469
|
|
|
1470
|
+
// The items that aren't due yet are not paid by this payment, so they are marked valid separately
|
|
1471
|
+
await markNotDueYetValid();
|
|
1472
|
+
|
|
1448
1473
|
// TypeScript thinks status cannot change to Failed, but it can.
|
|
1449
1474
|
if (payment.status === PaymentStatus.Succeeded || (payment.status as PaymentStatus) === PaymentStatus.Failed) {
|
|
1450
1475
|
// force update
|
|
@@ -1454,7 +1479,7 @@ export class PaymentService {
|
|
|
1454
1479
|
} else if (payment.method === PaymentMethod.Transfer || payment.method === PaymentMethod.PointOfSale || payment.method === PaymentMethod.Unknown || (payment.method === PaymentMethod.DirectDebit && mandate)) {
|
|
1455
1480
|
// Mark valid (not same as paid) if needed
|
|
1456
1481
|
let hasBundleDiscount = false;
|
|
1457
|
-
for (const [balanceItem] of
|
|
1482
|
+
for (const [balanceItem] of payableBalanceItems) {
|
|
1458
1483
|
// Mark valid
|
|
1459
1484
|
await BalanceItemService.markPaid(balanceItem, payment, organization);
|
|
1460
1485
|
|
|
@@ -4,7 +4,7 @@ import { Group, Member, MemberPlatformMembership, Organization, Platform, Regist
|
|
|
4
4
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
5
|
import { SQL, SQLWhereSign } from '@stamhoofd/sql';
|
|
6
6
|
import { AuditLogSource, PlatformMembershipTypeBehaviour } from '@stamhoofd/structures';
|
|
7
|
-
import { Formatter, Sorter } from '@stamhoofd/utility';
|
|
7
|
+
import { Formatter, sleep, Sorter } from '@stamhoofd/utility';
|
|
8
8
|
import { AuditLogService } from './AuditLogService.js';
|
|
9
9
|
import { MemberNumberService } from './MemberNumberService.js';
|
|
10
10
|
|
|
@@ -50,6 +50,7 @@ export class PlatformMembershipService {
|
|
|
50
50
|
console.log('Starting updateAllMemberships');
|
|
51
51
|
await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
|
|
52
52
|
while (true) {
|
|
53
|
+
await sleep(200);
|
|
53
54
|
const rawMembers = await Member.where({
|
|
54
55
|
id: {
|
|
55
56
|
value: id,
|
|
@@ -70,6 +70,11 @@ export const RegistrationService = {
|
|
|
70
70
|
const registrationMemberRelation = new ManyToOneRelation(Member, 'member');
|
|
71
71
|
registrationMemberRelation.foreignKey = Member.registrations.foreignKey;
|
|
72
72
|
await Document.updateForRegistration(registration.setRelation(registrationMemberRelation, member));
|
|
73
|
+
|
|
74
|
+
// update lastRegisteredAt (should not be awaited)
|
|
75
|
+
member.tryUpdateLastRegisteredAt(registration)
|
|
76
|
+
// errors should never stop other logic, lastRegisteredAt is only used for sorting
|
|
77
|
+
.catch(console.error);
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
// Update group occupancy
|