@stamhoofd/backend 2.134.0 → 2.136.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 +18 -17
- package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
- package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
- package/src/crons/index.ts +1 -0
- package/src/email-recipient-loaders/orders.ts +7 -9
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -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/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
- 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/GetWebshopOrdersEndpoint.test.ts +112 -0
- 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 +156 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
- package/src/excel-loaders/members.test.ts +59 -0
- package/src/excel-loaders/members.ts +17 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -0
- package/src/helpers/GlobalHelper.ts +1 -1
- package/src/helpers/MemberMerger.test.ts +70 -2
- package/src/helpers/MemberMerger.ts +43 -1
- package/src/helpers/MemberUserSyncer.ts +5 -0
- package/src/helpers/MembershipCharger.test.ts +110 -0
- package/src/helpers/MembershipCharger.ts +12 -33
- package/src/helpers/UitpasTokenRepository.ts +12 -11
- package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
- 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/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
- package/src/services/BalanceItemService.ts +1 -1
- 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 +10 -0
- package/src/sql-filters/orders.ts +5 -0
- package/src/sql-sorters/members.ts +12 -1
- package/vitest.config.js +1 -0
|
@@ -0,0 +1,156 @@
|
|
|
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, EmailContent, 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
|
+
|
|
71
|
+
test('selects the translated template that matches the language the customer ordered in', async () => {
|
|
72
|
+
TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
|
|
73
|
+
|
|
74
|
+
const organization = await new OrganizationFactory({}).create();
|
|
75
|
+
const freeProductPrice = ProductPrice.create({ name: 'Free', price: 0, stock: 100 });
|
|
76
|
+
const product = Product.create({ name: 'Product', stock: 100, prices: [freeProductPrice] });
|
|
77
|
+
const webshop = await new WebshopFactory({
|
|
78
|
+
organizationId: organization.id,
|
|
79
|
+
products: [product],
|
|
80
|
+
}).create();
|
|
81
|
+
|
|
82
|
+
// The template has default (Dutch) content and a French translation. The French translation
|
|
83
|
+
// must be selected for a French order, the default content for a Dutch order.
|
|
84
|
+
// Both order-table replacements are included: their headers/titles are localized per
|
|
85
|
+
// recipient (consumer) language, independently of the selected template translation.
|
|
86
|
+
const tables = '{{orderTable}} {{orderDetailsTable}}';
|
|
87
|
+
await new EmailTemplateFactory({
|
|
88
|
+
organization,
|
|
89
|
+
webshopId: webshop.id,
|
|
90
|
+
type: EmailTemplateType.OrderConfirmationOnline,
|
|
91
|
+
subject: 'Standaard onderwerp',
|
|
92
|
+
html: `<p>Standaard inhoud ${tables}</p>`,
|
|
93
|
+
text: 'Standaard inhoud',
|
|
94
|
+
language: Language.Dutch,
|
|
95
|
+
translations: new Map([
|
|
96
|
+
[Language.French, EmailContent.create({ subject: 'Sujet français', html: `<p>Contenu français ${tables}</p>`, text: 'Contenu français' })],
|
|
97
|
+
]),
|
|
98
|
+
}).create();
|
|
99
|
+
|
|
100
|
+
// Table headers/titles rendered via $t inside the order tables. Hardcoded on purpose so we
|
|
101
|
+
// don't verify $t with the same $t machinery we're testing.
|
|
102
|
+
// orderTable column headers (%Sc / %M4) and an orderDetailsTable row title (%xA).
|
|
103
|
+
const dutch = { orderColumn: 'Artikel', amountColumn: 'Aantal', orderNumberTitle: 'Bestelnummer' };
|
|
104
|
+
const french = { orderColumn: 'Article', amountColumn: 'Nombre', orderNumberTitle: 'Numéro de commande' };
|
|
105
|
+
|
|
106
|
+
const placeFreeOrderInLanguage = async (language: string) => {
|
|
107
|
+
EmailMocker.transactional.reset();
|
|
108
|
+
|
|
109
|
+
const orderData = OrderData.create({
|
|
110
|
+
paymentMethod: PaymentMethod.Unknown,
|
|
111
|
+
cart: Cart.create({
|
|
112
|
+
items: [CartItem.create({ product, productPrice: freeProductPrice, amount: 1 })],
|
|
113
|
+
}),
|
|
114
|
+
customer: Customer.create({ firstName: 'John', lastName: 'Doe', email: 'john@example.com', phone: '+32412345678' }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), orderData);
|
|
118
|
+
r.headers['accept-language'] = language;
|
|
119
|
+
await testServer.test(endpoint, r);
|
|
120
|
+
|
|
121
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
122
|
+
expect(emails).toHaveLength(1);
|
|
123
|
+
return emails[0];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// French order → the French translation of the template is used
|
|
127
|
+
const frenchEmail = await placeFreeOrderInLanguage('fr');
|
|
128
|
+
expect(frenchEmail.subject).toBe('Sujet français');
|
|
129
|
+
expect(frenchEmail.html).toContain('Contenu français');
|
|
130
|
+
expect(frenchEmail.html).not.toContain('Standaard inhoud');
|
|
131
|
+
|
|
132
|
+
// Both order tables are rendered in the recipient's (consumer) language, French
|
|
133
|
+
expect(frenchEmail.html).toContain(french.orderColumn);
|
|
134
|
+
expect(frenchEmail.html).toContain(french.amountColumn);
|
|
135
|
+
expect(frenchEmail.html).toContain(french.orderNumberTitle);
|
|
136
|
+
// No Dutch table headers/titles leak into the French email
|
|
137
|
+
expect(frenchEmail.html).not.toContain(dutch.orderColumn);
|
|
138
|
+
expect(frenchEmail.html).not.toContain(dutch.amountColumn);
|
|
139
|
+
expect(frenchEmail.html).not.toContain(dutch.orderNumberTitle);
|
|
140
|
+
|
|
141
|
+
// Dutch order → Dutch is the default language, so the default content is used
|
|
142
|
+
const dutchEmail = await placeFreeOrderInLanguage('nl');
|
|
143
|
+
expect(dutchEmail.subject).toBe('Standaard onderwerp');
|
|
144
|
+
expect(dutchEmail.html).toContain('Standaard inhoud');
|
|
145
|
+
expect(dutchEmail.html).not.toContain('Contenu français');
|
|
146
|
+
|
|
147
|
+
// Both order tables are rendered in Dutch
|
|
148
|
+
expect(dutchEmail.html).toContain(dutch.orderColumn);
|
|
149
|
+
expect(dutchEmail.html).toContain(dutch.amountColumn);
|
|
150
|
+
expect(dutchEmail.html).toContain(dutch.orderNumberTitle);
|
|
151
|
+
// No French table headers/titles leak into the Dutch email
|
|
152
|
+
expect(dutchEmail.html).not.toContain(french.orderColumn);
|
|
153
|
+
expect(dutchEmail.html).not.toContain(french.amountColumn);
|
|
154
|
+
expect(dutchEmail.html).not.toContain(french.orderNumberTitle);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -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);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
2
|
+
import type { PlatformMember } from '@stamhoofd/structures';
|
|
3
|
+
import { EmergencyContact, MemberDetails, MembersBlob, MemberWithRegistrationsBlob, Organization, Platform, PlatformFamily } from '@stamhoofd/structures';
|
|
4
|
+
import { baseMemberColumns } from './members.js';
|
|
5
|
+
|
|
6
|
+
describe('Member excel export', () => {
|
|
7
|
+
describe('emergencyContacts column', () => {
|
|
8
|
+
function createMember(emergencyContacts: EmergencyContact[]) {
|
|
9
|
+
const organization = Organization.create({});
|
|
10
|
+
const blob = MembersBlob.create({
|
|
11
|
+
organizations: [organization],
|
|
12
|
+
members: [
|
|
13
|
+
MemberWithRegistrationsBlob.create({
|
|
14
|
+
details: MemberDetails.create({ firstName: 'John', lastName: 'Doe', emergencyContacts }),
|
|
15
|
+
}),
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const family = PlatformFamily.create(blob, { platform: Platform.create({}), contextOrganization: organization });
|
|
20
|
+
return family.members[0];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getColumn() {
|
|
24
|
+
const column = baseMemberColumns.find(c => 'id' in c && c.id === 'emergencyContacts') as XlsxTransformerConcreteColumn<PlatformMember> | undefined;
|
|
25
|
+
|
|
26
|
+
if (!column) {
|
|
27
|
+
throw new Error('Column emergencyContacts not found');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return column;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getValue(member: PlatformMember) {
|
|
34
|
+
return getColumn().getValue(member).value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('lists every emergency contact on its own line, also the ones without a numbered column', () => {
|
|
38
|
+
const member = createMember([
|
|
39
|
+
EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
|
|
40
|
+
EmergencyContact.create({ name: 'Jan Janssens', title: 'Buur', phone: '0470 65 43 21' }),
|
|
41
|
+
EmergencyContact.create({ name: 'Rita Maes', title: 'Tante', phone: '0470 11 22 33' }),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(getValue(member)).toBe('Oma: An Peeters (0470 12 34 56)\nBuur: Jan Janssens (0470 65 43 21)\nTante: Rita Maes (0470 11 22 33)');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('wraps the text so every line stays visible', () => {
|
|
48
|
+
const member = createMember([
|
|
49
|
+
EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
expect(getColumn().getValue(member).style?.alignment?.wrapText).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns an empty value when the member has no emergency contacts', () => {
|
|
56
|
+
expect(getValue(createMember([]))).toBe('');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -191,6 +191,23 @@ export const baseMemberColumns: XlsxTransformerColumn<PlatformMember>[] = [
|
|
|
191
191
|
|
|
192
192
|
...XlsxTransformerColumnHelper.creatColumnsForParents(),
|
|
193
193
|
|
|
194
|
+
// emergency contacts
|
|
195
|
+
{
|
|
196
|
+
id: 'emergencyContacts',
|
|
197
|
+
name: $t(`%ZeJ`),
|
|
198
|
+
width: 40,
|
|
199
|
+
getValue: ({ patchedMember: object }: PlatformMember) => ({
|
|
200
|
+
// One contact per line so the cell stays readable, and wrap the text so all lines are visible
|
|
201
|
+
value: object.details.emergencyContacts.map(c => c.toString()).join('\n'),
|
|
202
|
+
style: {
|
|
203
|
+
alignment: {
|
|
204
|
+
wrapText: true,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
...XlsxTransformerColumnHelper.createColumnsForEmergencyContacts(),
|
|
210
|
+
|
|
194
211
|
// unverified data
|
|
195
212
|
{
|
|
196
213
|
id: 'unverifiedPhones',
|
|
@@ -406,6 +406,15 @@ export class AuthenticatedStructures {
|
|
|
406
406
|
const members = await Member.getMembersWithRegistrationForUser(user);
|
|
407
407
|
const filtered: MemberWithUsersRegistrationsAndGroups[] = [];
|
|
408
408
|
for (const member of members) {
|
|
409
|
+
if (STAMHOOFD.userMode === 'organization') {
|
|
410
|
+
if (!Context.organization) {
|
|
411
|
+
// Don't list any members
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (member.organizationId !== Context.organization.id) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
409
418
|
if (await Context.auth.canAccessMember(member, PermissionLevel.Read)) {
|
|
410
419
|
filtered.push(member);
|
|
411
420
|
}
|
|
@@ -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;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Address,
|
|
3
|
+
BalanceItemRelation,
|
|
4
|
+
BalanceItemRelationType,
|
|
3
5
|
BooleanStatus,
|
|
4
6
|
EmergencyContact,
|
|
5
7
|
Gender,
|
|
6
8
|
MemberDetails,
|
|
7
9
|
Parent,
|
|
10
|
+
ReceivableBalanceType,
|
|
8
11
|
RecordAnswer,
|
|
9
12
|
RecordSettings,
|
|
10
13
|
ReviewTime,
|
|
@@ -16,8 +19,10 @@ import {
|
|
|
16
19
|
} from '@stamhoofd/structures';
|
|
17
20
|
import { Country } from '@stamhoofd/types/Country';
|
|
18
21
|
import { v4 as uuidv4 } from 'uuid';
|
|
19
|
-
import { Member } from '@stamhoofd/models';
|
|
20
|
-
import {
|
|
22
|
+
import { BalanceItem, BalanceItemFactory, CachedBalance, Member, MemberFactory, OrganizationFactory } from '@stamhoofd/models';
|
|
23
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
24
|
+
import { BalanceItemService } from '../services/BalanceItemService.js';
|
|
25
|
+
import { mergeMemberDetails, mergeTwoMembers, selectBaseMember } from './MemberMerger.js';
|
|
21
26
|
|
|
22
27
|
describe('member merge', () => {
|
|
23
28
|
describe('mergeMemberDetails', () => {
|
|
@@ -914,3 +919,66 @@ describe('member merge', () => {
|
|
|
914
919
|
});
|
|
915
920
|
});
|
|
916
921
|
});
|
|
922
|
+
|
|
923
|
+
describe('mergeTwoMembers balance items', () => {
|
|
924
|
+
beforeEach(() => {
|
|
925
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
const createBalanceItem = async (organizationId: string, member: Member, unitPrice: number) => {
|
|
929
|
+
return await new BalanceItemFactory({
|
|
930
|
+
organizationId,
|
|
931
|
+
memberId: member.id,
|
|
932
|
+
amount: 1,
|
|
933
|
+
unitPrice,
|
|
934
|
+
relations: new Map([
|
|
935
|
+
[
|
|
936
|
+
BalanceItemRelationType.Member,
|
|
937
|
+
BalanceItemRelation.create({
|
|
938
|
+
id: member.id,
|
|
939
|
+
name: new TranslatedString(member.details.name),
|
|
940
|
+
}),
|
|
941
|
+
],
|
|
942
|
+
]),
|
|
943
|
+
}).create();
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
test('moves the balance items to the base member, rewrites the Member relation and recalculates cached balances', async () => {
|
|
947
|
+
const organization = await new OrganizationFactory({}).create();
|
|
948
|
+
const base = await new MemberFactory({ organization, firstName: 'Base', lastName: 'Member' }).create();
|
|
949
|
+
const other = await new MemberFactory({ organization, firstName: 'Other', lastName: 'Member' }).create();
|
|
950
|
+
|
|
951
|
+
const baseItem = await createBalanceItem(organization.id, base, 30_00);
|
|
952
|
+
const otherItem = await createBalanceItem(organization.id, other, 50_00);
|
|
953
|
+
|
|
954
|
+
// Warm up the cached balances so both members have a stale value before merging
|
|
955
|
+
await CachedBalance.updateForMembers(organization.id, [base.id, other.id]);
|
|
956
|
+
|
|
957
|
+
await mergeTwoMembers(base, other);
|
|
958
|
+
|
|
959
|
+
// The balance item of the removed member now belongs to the base member
|
|
960
|
+
const movedItem = await BalanceItem.getByID(otherItem.id);
|
|
961
|
+
expect(movedItem?.memberId).toBe(base.id);
|
|
962
|
+
|
|
963
|
+
// Its Member relation is rewritten to point to the base member
|
|
964
|
+
const relation = movedItem?.relations.get(BalanceItemRelationType.Member);
|
|
965
|
+
expect(relation?.id).toBe(base.id);
|
|
966
|
+
expect(relation?.name.toString()).toBe(base.details.name);
|
|
967
|
+
|
|
968
|
+
// The balance item that was already on the base member is untouched
|
|
969
|
+
const keptItem = await BalanceItem.getByID(baseItem.id);
|
|
970
|
+
expect(keptItem?.memberId).toBe(base.id);
|
|
971
|
+
expect(keptItem?.relations.get(BalanceItemRelationType.Member)?.id).toBe(base.id);
|
|
972
|
+
|
|
973
|
+
// Make sure all scheduled cached balance updates have run
|
|
974
|
+
await BalanceItemService.flushAll();
|
|
975
|
+
|
|
976
|
+
// The base member now owes both balance items
|
|
977
|
+
const [baseBalance] = await CachedBalance.getForObjects([base.id], organization.id, ReceivableBalanceType.member);
|
|
978
|
+
expect(baseBalance?.amountOpen).toBe(80_00);
|
|
979
|
+
|
|
980
|
+
// The removed member no longer has an outstanding cached balance
|
|
981
|
+
const [otherBalance] = await CachedBalance.getForObjects([other.id], organization.id, ReceivableBalanceType.member);
|
|
982
|
+
expect(otherBalance?.amountOpen ?? 0).toBe(0);
|
|
983
|
+
});
|
|
984
|
+
});
|
|
@@ -19,10 +19,14 @@ import type {
|
|
|
19
19
|
UitpasNumberDetails,
|
|
20
20
|
} from '@stamhoofd/structures';
|
|
21
21
|
import {
|
|
22
|
+
BalanceItemRelation,
|
|
23
|
+
BalanceItemRelationType,
|
|
22
24
|
Gender,
|
|
23
25
|
ParentType,
|
|
26
|
+
TranslatedString,
|
|
24
27
|
} from '@stamhoofd/structures';
|
|
25
28
|
import { Formatter } from '@stamhoofd/utility';
|
|
29
|
+
import { BalanceItemService } from '../services/BalanceItemService.js';
|
|
26
30
|
import { PlatformMembershipService } from '../services/PlatformMembershipService.js';
|
|
27
31
|
import { RegistrationService } from '../services/RegistrationService.js';
|
|
28
32
|
import { MemberUserSyncer } from './MemberUserSyncer.js';
|
|
@@ -214,7 +218,45 @@ async function mergeResponsibilities(base: Member, other: Member) {
|
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
async function mergeBalanceItems(base: Member, other: Member) {
|
|
217
|
-
|
|
221
|
+
const otherModels = await BalanceItem.select()
|
|
222
|
+
.where('memberId', other.id)
|
|
223
|
+
.fetch();
|
|
224
|
+
|
|
225
|
+
// Keep track of every organization that had balance items for the removed member,
|
|
226
|
+
// so we can reset its cached balance afterwards.
|
|
227
|
+
const organizationIds = new Set<string>();
|
|
228
|
+
|
|
229
|
+
for (const otherModel of otherModels) {
|
|
230
|
+
organizationIds.add(otherModel.organizationId);
|
|
231
|
+
|
|
232
|
+
otherModel.memberId = base.id;
|
|
233
|
+
|
|
234
|
+
// The Member relation stores the member id + name. Update it so it points to the base member.
|
|
235
|
+
if (otherModel.relations.has(BalanceItemRelationType.Member) && otherModel.relations.get(BalanceItemRelationType.Member)?.id === other.id) {
|
|
236
|
+
otherModel.relations.set(
|
|
237
|
+
BalanceItemRelationType.Member,
|
|
238
|
+
BalanceItemRelation.create({
|
|
239
|
+
id: base.id,
|
|
240
|
+
name: new TranslatedString(base.details.name),
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await otherModel.save({
|
|
246
|
+
skipMarkSaved: true,
|
|
247
|
+
skipSendEvents: true,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Recalculate the cached balance of the base member (+ related users/organizations/registrations).
|
|
251
|
+
// We skip the model events above, so we have to schedule the update manually.
|
|
252
|
+
BalanceItemService.scheduleUpdate(otherModel);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Recalculate the cached balance of the removed member, otherwise stale amounts remain
|
|
256
|
+
// in the cached balances for a member that no longer exists.
|
|
257
|
+
for (const organizationId of organizationIds) {
|
|
258
|
+
BalanceItemService.scheduleMemberUpdate(organizationId, other.id);
|
|
259
|
+
}
|
|
218
260
|
}
|
|
219
261
|
|
|
220
262
|
async function mergeDocuments(base: Member, other: Member) {
|
|
@@ -380,6 +380,11 @@ export class MemberUserSyncerStatic {
|
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
if (setMemberId && member.organizationId !== user.organizationId) {
|
|
384
|
+
// Don't allow linking across scopes
|
|
385
|
+
setMemberId = false;
|
|
386
|
+
}
|
|
387
|
+
|
|
383
388
|
if (setMemberId) {
|
|
384
389
|
if (name) {
|
|
385
390
|
user.firstName = name.firstName;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Member, Organization, RegistrationPeriod } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItem, MemberFactory, MemberPlatformMembership, OrganizationFactory, Platform, RegistrationPeriodFactory } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItemType, PlatformMembershipType, PlatformMembershipTypeBehaviour, PlatformMembershipTypeConfig, PlatformMembershipTypeConfigPrice, ReduceablePrice } from '@stamhoofd/structures';
|
|
4
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
|
+
import { MembershipCharger } from './MembershipCharger.js';
|
|
6
|
+
|
|
7
|
+
describe('MembershipCharger', () => {
|
|
8
|
+
const membershipPrice = 25_00;
|
|
9
|
+
|
|
10
|
+
let currentPeriod: RegistrationPeriod;
|
|
11
|
+
let nextPeriod: RegistrationPeriod;
|
|
12
|
+
let membershipOrganization: Organization;
|
|
13
|
+
let payingOrganization: Organization;
|
|
14
|
+
let membershipType: PlatformMembershipType;
|
|
15
|
+
|
|
16
|
+
const buildConfig = (period: RegistrationPeriod) => {
|
|
17
|
+
return PlatformMembershipTypeConfig.create({
|
|
18
|
+
startDate: period.startDate,
|
|
19
|
+
endDate: period.endDate,
|
|
20
|
+
prices: [
|
|
21
|
+
PlatformMembershipTypeConfigPrice.create({
|
|
22
|
+
prices: new Map([['', ReduceablePrice.create({ price: membershipPrice })]]),
|
|
23
|
+
}),
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Set the period that should not be charged yet (or null to charge every period)
|
|
29
|
+
const setNextPeriod = async (nextPeriodId: string | null) => {
|
|
30
|
+
const platform = await Platform.getForEditing();
|
|
31
|
+
platform.periodId = currentPeriod.id;
|
|
32
|
+
platform.nextPeriodId = nextPeriodId;
|
|
33
|
+
platform.membershipOrganizationId = membershipOrganization.id;
|
|
34
|
+
platform.config.membershipTypes = [membershipType];
|
|
35
|
+
await platform.save();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const createMembership = async (period: RegistrationPeriod, organization: Organization, member: Member) => {
|
|
39
|
+
const membership = new MemberPlatformMembership();
|
|
40
|
+
membership.memberId = member.id;
|
|
41
|
+
membership.membershipTypeId = membershipType.id;
|
|
42
|
+
membership.organizationId = organization.id;
|
|
43
|
+
membership.periodId = period.id;
|
|
44
|
+
membership.startDate = period.startDate;
|
|
45
|
+
membership.endDate = period.endDate;
|
|
46
|
+
await membership.save();
|
|
47
|
+
return membership;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
56
|
+
|
|
57
|
+
currentPeriod = await new RegistrationPeriodFactory({
|
|
58
|
+
startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
|
|
59
|
+
endDate: new Date(2024, 11, 31, 23, 59, 59, 0),
|
|
60
|
+
}).create();
|
|
61
|
+
|
|
62
|
+
nextPeriod = await new RegistrationPeriodFactory({
|
|
63
|
+
startDate: new Date(2025, 0, 1, 0, 0, 0, 0),
|
|
64
|
+
endDate: new Date(2025, 11, 31, 23, 59, 59, 0),
|
|
65
|
+
}).create();
|
|
66
|
+
|
|
67
|
+
membershipOrganization = await new OrganizationFactory({}).create();
|
|
68
|
+
payingOrganization = await new OrganizationFactory({}).create();
|
|
69
|
+
|
|
70
|
+
membershipType = PlatformMembershipType.create({
|
|
71
|
+
name: 'Test membership',
|
|
72
|
+
behaviour: PlatformMembershipTypeBehaviour.Period,
|
|
73
|
+
periods: new Map([
|
|
74
|
+
[currentPeriod.id, buildConfig(currentPeriod)],
|
|
75
|
+
[nextPeriod.id, buildConfig(nextPeriod)],
|
|
76
|
+
]),
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('Charges current period memberships but skips memberships of the next period', async () => {
|
|
81
|
+
await setNextPeriod(nextPeriod.id);
|
|
82
|
+
|
|
83
|
+
const currentMember = await new MemberFactory({}).create();
|
|
84
|
+
const nextMember = await new MemberFactory({}).create();
|
|
85
|
+
|
|
86
|
+
const currentMembership = await createMembership(currentPeriod, payingOrganization, currentMember);
|
|
87
|
+
const nextMembership = await createMembership(nextPeriod, payingOrganization, nextMember);
|
|
88
|
+
|
|
89
|
+
await MembershipCharger.charge();
|
|
90
|
+
|
|
91
|
+
const chargedCurrent = await MemberPlatformMembership.getByID(currentMembership.id);
|
|
92
|
+
expect(chargedCurrent).toBeDefined();
|
|
93
|
+
expect(chargedCurrent!.balanceItemId).not.toBeNull();
|
|
94
|
+
expect(chargedCurrent!.locked).toBe(true);
|
|
95
|
+
|
|
96
|
+
// The next period membership should be left untouched
|
|
97
|
+
const chargedNext = await MemberPlatformMembership.getByID(nextMembership.id);
|
|
98
|
+
expect(chargedNext).toBeDefined();
|
|
99
|
+
expect(chargedNext!.balanceItemId).toBeNull();
|
|
100
|
+
expect(chargedNext!.locked).toBe(false);
|
|
101
|
+
|
|
102
|
+
// The created balance item should be charged via the membership organization
|
|
103
|
+
const balanceItem = await BalanceItem.getByID(chargedCurrent!.balanceItemId!);
|
|
104
|
+
expect(balanceItem).toBeDefined();
|
|
105
|
+
expect(balanceItem!.type).toBe(BalanceItemType.PlatformMembership);
|
|
106
|
+
expect(balanceItem!.unitPrice).toBe(membershipPrice);
|
|
107
|
+
expect(balanceItem!.organizationId).toBe(membershipOrganization.id);
|
|
108
|
+
expect(balanceItem!.payingOrganizationId).toBe(payingOrganization.id);
|
|
109
|
+
});
|
|
110
|
+
});
|