@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.
Files changed (55) hide show
  1. package/package.json +18 -17
  2. package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
  3. package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/email-recipient-loaders/orders.ts +7 -9
  6. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
  7. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
  8. package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
  10. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
  11. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
  12. package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
  13. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
  14. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
  15. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
  16. package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
  17. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -2
  18. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
  19. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
  20. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
  21. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
  22. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
  23. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
  24. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
  25. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
  26. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
  27. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  28. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
  29. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +156 -0
  30. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
  31. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
  32. package/src/excel-loaders/members.test.ts +59 -0
  33. package/src/excel-loaders/members.ts +17 -0
  34. package/src/helpers/AuthenticatedStructures.ts +9 -0
  35. package/src/helpers/GlobalHelper.ts +1 -1
  36. package/src/helpers/MemberMerger.test.ts +70 -2
  37. package/src/helpers/MemberMerger.ts +43 -1
  38. package/src/helpers/MemberUserSyncer.ts +5 -0
  39. package/src/helpers/MembershipCharger.test.ts +110 -0
  40. package/src/helpers/MembershipCharger.ts +12 -33
  41. package/src/helpers/UitpasTokenRepository.ts +12 -11
  42. package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
  43. package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
  44. package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
  45. package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
  46. package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
  47. package/src/services/BalanceItemService.ts +1 -1
  48. package/src/services/PaymentService.ts +33 -8
  49. package/src/services/PlatformMembershipService.ts +2 -1
  50. package/src/services/RegistrationService.ts +5 -0
  51. package/src/services/uitpas/UitpasService.ts +3 -4
  52. package/src/sql-filters/members.ts +10 -0
  53. package/src/sql-filters/orders.ts +5 -0
  54. package/src/sql-sorters/members.ts +12 -1
  55. 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 { STExpect } from '@stamhoofd/test-utils';
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 { mergeMemberDetails, selectBaseMember } from './MemberMerger.js';
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
- await mergeModels(base, other, BalanceItem);
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
+ });