@stamhoofd/backend 2.135.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 (34) 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/global/email/CreateEmailEndpoint.ts +5 -2
  9. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
  10. package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
  11. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
  12. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
  13. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
  14. package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
  15. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +119 -1
  16. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
  17. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
  18. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
  19. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +87 -1
  20. package/src/excel-loaders/members.test.ts +59 -0
  21. package/src/excel-loaders/members.ts +17 -0
  22. package/src/helpers/AuthenticatedStructures.ts +9 -0
  23. package/src/helpers/MemberMerger.test.ts +70 -2
  24. package/src/helpers/MemberMerger.ts +43 -1
  25. package/src/helpers/MemberUserSyncer.ts +5 -0
  26. package/src/helpers/MembershipCharger.test.ts +110 -0
  27. package/src/helpers/MembershipCharger.ts +10 -32
  28. package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
  29. package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
  30. package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
  31. package/src/services/BalanceItemService.ts +1 -1
  32. package/src/sql-filters/members.ts +5 -0
  33. package/src/sql-filters/orders.ts +5 -0
  34. package/vitest.config.js +1 -0
@@ -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
+ });
@@ -8,10 +8,9 @@ export const MembershipCharger = {
8
8
  async charge() {
9
9
  console.log('Charging memberships...');
10
10
 
11
- // Loop all
12
- let lastId = '';
13
11
  const platform = await Platform.getShared();
14
12
  const chargeVia = platform.membershipOrganizationId;
13
+ const nextPeriodId = platform.nextPeriodId;
15
14
 
16
15
  if (!chargeVia) {
17
16
  throw new SimpleError({
@@ -46,27 +45,17 @@ export const MembershipCharger = {
46
45
 
47
46
  let createdCount = 0;
48
47
  let createdPrice = 0;
49
- const chunkSize = 100;
50
-
51
- while (true) {
52
- const memberships = await MemberPlatformMembership.select()
53
- .where('id', SQLWhereSign.Greater, lastId)
54
- .where('deletedAt', null)
55
- .where('locked', false)
56
- .where(SQL.where('trialUntil', null).or('trialUntil', SQLWhereSign.LessEqual, new Date()))
57
- .limit(chunkSize)
58
- .orderBy(
59
- new SQLOrderBy({
60
- column: SQL.column('id'),
61
- direction: 'ASC',
62
- }),
63
- )
64
- .fetch();
48
+ let q = MemberPlatformMembership.select()
49
+ .where('deletedAt', null)
50
+ .where('locked', false)
51
+ .where(SQL.where('trialUntil', null).or('trialUntil', SQLWhereSign.LessEqual, new Date()));
65
52
 
66
- if (memberships.length === 0) {
67
- break;
68
- }
53
+ if (nextPeriodId) {
54
+ q = q.where('periodId', '!=', nextPeriodId);
55
+ }
69
56
 
57
+ // allBatched() paginates by id ASC internally, so we must not set a custom order by here.
58
+ for await (const memberships of q.limit(100).allBatched()) {
70
59
  const memberIds = Formatter.uniqueArray(memberships.map(m => m.memberId));
71
60
  const members = await Member.getByIDs(...memberIds);
72
61
  const createdBalanceItems: BalanceItem[] = [];
@@ -153,17 +142,6 @@ export const MembershipCharger = {
153
142
  createdCount += 1;
154
143
  createdPrice += membership.price;
155
144
  }
156
-
157
- if (memberships.length < chunkSize) {
158
- break;
159
- }
160
-
161
- const z = lastId;
162
- lastId = memberships[memberships.length - 1].id;
163
-
164
- if (lastId === z) {
165
- throw new Error('Unexpected infinite loop found in MembershipCharger');
166
- }
167
145
  }
168
146
 
169
147
  console.log('Charged ' + Formatter.integer(createdCount) + ' memberships, for a total value of ' + Formatter.price(createdPrice));
@@ -1,6 +1,7 @@
1
1
  import type { XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
2
2
  import type { PlatformMember } from '@stamhoofd/structures';
3
3
  import {
4
+ EmergencyContact,
4
5
  Group,
5
6
  GroupCategory,
6
7
  GroupCategorySettings,
@@ -166,4 +167,66 @@ describe('XlsxTransformerColumnHelper', () => {
166
167
  expect(getColumn(ageGroupsCategory.id).getValue(member).value).toBe('Kapoenen, Welpen');
167
168
  });
168
169
  });
170
+
171
+ describe('createColumnsForEmergencyContacts', () => {
172
+ function createMember(emergencyContacts: EmergencyContact[]) {
173
+ const organization = Organization.create({});
174
+ const blob = MembersBlob.create({
175
+ organizations: [organization],
176
+ members: [
177
+ MemberWithRegistrationsBlob.create({
178
+ details: MemberDetails.create({ firstName: 'John', lastName: 'Doe', emergencyContacts }),
179
+ }),
180
+ ],
181
+ });
182
+
183
+ const family = PlatformFamily.create(blob, { platform: Platform.create({}), contextOrganization: organization });
184
+ return family.members[0];
185
+ }
186
+
187
+ function getValue(member: PlatformMember, id: string) {
188
+ const columns = XlsxTransformerColumnHelper.createColumnsForEmergencyContacts();
189
+ const column = columns.find(c => 'id' in c && c.id === id) as XlsxTransformerConcreteColumn<PlatformMember> | undefined;
190
+
191
+ if (!column) {
192
+ throw new Error(`Column ${id} not found`);
193
+ }
194
+
195
+ return column.getValue(member).value;
196
+ }
197
+
198
+ it('returns the values of the first two emergency contacts', () => {
199
+ const member = createMember([
200
+ EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
201
+ EmergencyContact.create({ name: 'Jan Janssens', title: 'Buur', phone: '0470 65 43 21' }),
202
+ ]);
203
+
204
+ expect(getValue(member, 'emergencyContact.0.name')).toBe('An Peeters');
205
+ expect(getValue(member, 'emergencyContact.0.title')).toBe('Oma');
206
+ expect(getValue(member, 'emergencyContact.0.phone')).toBe('0470 12 34 56');
207
+
208
+ expect(getValue(member, 'emergencyContact.1.name')).toBe('Jan Janssens');
209
+ expect(getValue(member, 'emergencyContact.1.title')).toBe('Buur');
210
+ expect(getValue(member, 'emergencyContact.1.phone')).toBe('0470 65 43 21');
211
+ });
212
+
213
+ it('returns empty values when the member has fewer emergency contacts', () => {
214
+ const member = createMember([
215
+ EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
216
+ ]);
217
+
218
+ expect(getValue(member, 'emergencyContact.1.name')).toBe('');
219
+ expect(getValue(member, 'emergencyContact.1.title')).toBe('');
220
+ expect(getValue(member, 'emergencyContact.1.phone')).toBe('');
221
+ });
222
+
223
+ it('returns an empty value for a contact without a phone number', () => {
224
+ const member = createMember([
225
+ EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: null }),
226
+ ]);
227
+
228
+ expect(getValue(member, 'emergencyContact.0.name')).toBe('An Peeters');
229
+ expect(getValue(member, 'emergencyContact.0.phone')).toBe('');
230
+ });
231
+ });
169
232
  });
@@ -1,6 +1,6 @@
1
1
  import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
2
2
  import { isXlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
3
- import type { Address, GroupCategory, Parent, PlatformMember, RecordAnswer, RecordSettings } from '@stamhoofd/structures';
3
+ import type { Address, EmergencyContact, GroupCategory, Parent, PlatformMember, RecordAnswer, RecordSettings } from '@stamhoofd/structures';
4
4
  import { CountryHelper, ParentTypeHelper, RecordCategory, RecordType } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
 
@@ -24,6 +24,52 @@ export class XlsxTransformerColumnHelper {
24
24
  ];
25
25
  }
26
26
 
27
+ /**
28
+ * Emergency contacts are an unbounded array, so only the first two get their own columns. The combined
29
+ * 'emergencyContacts' column in the members loader lists all of them.
30
+ */
31
+ static createColumnsForEmergencyContacts(): XlsxTransformerColumn<PlatformMember>[] {
32
+ return [
33
+ ...this.createColumnsForEmergencyContact(0),
34
+ ...this.createColumnsForEmergencyContact(1),
35
+ ];
36
+ }
37
+
38
+ static createColumnsForEmergencyContact(contactIndex: number): XlsxTransformerColumn<PlatformMember>[] {
39
+ const getContact = (member: PlatformMember): EmergencyContact | null | undefined => member.patchedMember.details.emergencyContacts[contactIndex];
40
+
41
+ const identifier = `Noodcontact ${contactIndex + 1}`;
42
+ const getId = (value: string) => `emergencyContact.${contactIndex}.${value}`;
43
+ const getName = (value: string) => `${identifier} - ${value}`;
44
+
45
+ return [
46
+ {
47
+ id: getId('name'),
48
+ name: getName($t(`%1Os`)),
49
+ width: 20,
50
+ getValue: (member: PlatformMember) => ({
51
+ value: getContact(member)?.name ?? '',
52
+ }),
53
+ },
54
+ {
55
+ id: getId('title'),
56
+ name: getName($t(`%f5`)),
57
+ width: 20,
58
+ getValue: (member: PlatformMember) => ({
59
+ value: getContact(member)?.title ?? '',
60
+ }),
61
+ },
62
+ {
63
+ id: getId('phone'),
64
+ name: getName($t(`%wD`)),
65
+ width: 20,
66
+ getValue: (member: PlatformMember) => ({
67
+ value: getContact(member)?.phone ?? '',
68
+ }),
69
+ },
70
+ ];
71
+ }
72
+
27
73
  static createColumnsForAddresses<T>({ limit, getAddresses, matchIdStart }: { limit: number; getAddresses: (object: T) => Address[]; matchIdStart: string }): XlsxTransformerColumn<T>[] {
28
74
  const result: XlsxTransformerColumn<unknown>[] = [];
29
75
 
@@ -0,0 +1,143 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { BalanceItem, Organization, Registration } from '@stamhoofd/models';
3
+ import { QueryableModel } from '@stamhoofd/sql';
4
+ import { BalanceItemStatus } from '@stamhoofd/structures';
5
+ import { Formatter } from '@stamhoofd/utility';
6
+ import { SeedTools } from '../helpers/SeedTools.js';
7
+ import { BalanceItemService } from '../services/BalanceItemService.js';
8
+
9
+ /**
10
+ * Registrations with a trial period that were made via the member portal were never activated: their
11
+ * balance items are only due after the trial, so they were not part of the payment, and nothing ever
12
+ * marked them valid. The result is a registration that exists in the database, but is invisible in the
13
+ * registration lists and the member portal, and a balance item that stays Hidden and is never billed.
14
+ *
15
+ * markPaid (without a payment) puts them in the exact same state as a registration made after the fix:
16
+ * it marks the balance item due (Hidden -> Due) and marks the registration valid without shortening the
17
+ * trial period.
18
+ */
19
+ export async function fixInvisibleTrialRegistrations() {
20
+ let fixed = 0;
21
+ let skipped = 0;
22
+ let expired = 0;
23
+
24
+ const now = new Date();
25
+
26
+ const result = await SeedTools.loopBatched({
27
+ // Keyset pagination on id: activating a registration drops it from this filter, but rows are
28
+ // never skipped because each next batch is fetched by id > lastId.
29
+ //
30
+ // For now, only registrations whose trial is still running are activated. Activating one whose
31
+ // trial already ended would mark its balance item due right away (its dueAt is in the past), so
32
+ // the member would immediately owe the full price - as an overdue amount - for a trial they
33
+ // never got, since the registration was invisible to them the whole time. Most of the affected
34
+ // registrations are in that state, so this deliberately does not fix all of them yet: what
35
+ // should happen to them (bill them anyway, give them a new payment deadline, or don't charge
36
+ // them at all) is still to be decided. Leaving them untouched keeps that decision open, no data
37
+ // is lost and a later seed can still pick them up.
38
+ query: Registration.select()
39
+ .whereNot('trialUntil', null)
40
+ .where('registeredAt', null)
41
+ .where('deactivatedAt', null),
42
+ batchSize: 100,
43
+ batchAction: async (registrations: Registration[]) => {
44
+ const organizationIds = Formatter.uniqueArray(registrations.map(r => r.organizationId));
45
+ const organizations = organizationIds.length ? await Organization.getByIDs(...organizationIds) : [];
46
+
47
+ for (const registration of registrations) {
48
+ try {
49
+ const organization = organizations.find(o => o.id === registration.organizationId);
50
+ if (!organization) {
51
+ console.warn('Organization not found for registration, skipping', registration.id, registration.organizationId);
52
+ skipped++;
53
+ continue;
54
+ }
55
+
56
+ // The trial already ended. Activating it now would mark the balance item due right
57
+ // away (its dueAt is in the past), so the member would immediately owe the full
58
+ // price for a trial they never got. Leave it alone.
59
+ if (registration.trialUntil === null || registration.trialUntil <= now) {
60
+ expired++;
61
+ continue;
62
+ }
63
+
64
+ // The registration was invisible, so the member may have been registered for the same
65
+ // group again in the meantime. Activating this one would create a duplicate.
66
+ const activeDuplicates = await Registration.select()
67
+ .where('memberId', registration.memberId)
68
+ .where('groupId', registration.groupId)
69
+ .where('periodId', registration.periodId)
70
+ .whereNot('registeredAt', null)
71
+ .where('deactivatedAt', null)
72
+ .count();
73
+
74
+ if (activeDuplicates > 0) {
75
+ console.log('Member is already registered for this group again, skipping', registration.id);
76
+ skipped++;
77
+ continue;
78
+ }
79
+
80
+ // Do not send a registration confirmation email for a registration that was made a
81
+ // long time ago. The flag is only read when the registration is marked valid.
82
+ if (registration.sendConfirmationEmail) {
83
+ registration.sendConfirmationEmail = false;
84
+ await registration.save();
85
+ }
86
+
87
+ // All the balance items of a trial registration are deferred (Hidden + dueAt), so they
88
+ // all need to be marked due. The base and option items also mark the registration valid.
89
+ const balanceItems = await BalanceItem.select()
90
+ .where('registrationId', registration.id)
91
+ .where('status', BalanceItemStatus.Hidden)
92
+ .fetch();
93
+
94
+ for (const balanceItem of balanceItems) {
95
+ await BalanceItemService.markPaid(balanceItem, null, organization);
96
+ }
97
+
98
+ // A trial registration always has at least one balance item, but don't leave the
99
+ // registration invisible if it somehow doesn't.
100
+ await registration.refresh();
101
+ if (registration.registeredAt === null) {
102
+ console.warn('Registration has no hidden balance items to activate it, skipping', registration.id);
103
+ skipped++;
104
+ continue;
105
+ }
106
+
107
+ // markValid stamps registeredAt with the current date. Attribute the registration to
108
+ // the date it was really made instead of the date this migration runs.
109
+ registration.registeredAt = registration.createdAt;
110
+ await registration.save();
111
+
112
+ fixed++;
113
+ } catch (e) {
114
+ // Isolate failures per registration: one bad row should not abort (and wedge) the whole migration.
115
+ console.error('Failed to fix trial registration, skipping', registration.id, e);
116
+ skipped++;
117
+ }
118
+
119
+ if (QueryableModel.shutdownMigrations) {
120
+ break;
121
+ }
122
+ }
123
+
124
+ if (QueryableModel.shutdownMigrations) {
125
+ throw new Error('Stopping migration gracefully');
126
+ }
127
+ },
128
+ });
129
+
130
+ console.log(`Finished fixing invisible trial registrations: ${fixed} fixed, ${expired} left alone because their trial already ended, ${skipped} skipped of ${result.total} registrations.`);
131
+
132
+ return { fixed, skipped, expired };
133
+ }
134
+
135
+ export default new Migration(async () => {
136
+ if (STAMHOOFD.environment === 'test') {
137
+ console.log('skipped in tests');
138
+ return;
139
+ }
140
+
141
+ console.log('Start fixing invisible trial registrations.');
142
+ await fixInvisibleTrialRegistrations();
143
+ });
@@ -189,7 +189,7 @@ export class BalanceItemService {
189
189
  userUpdateQueue.addItem(organizationId, userId);
190
190
  }
191
191
 
192
- static cheduleMemberUpdate(organizationId: string, memberId: string) {
192
+ static scheduleMemberUpdate(organizationId: string, memberId: string) {
193
193
  memberUpdateQueue.addItem(organizationId, memberId);
194
194
  }
195
195
 
@@ -509,6 +509,11 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
509
509
  type: SQLValueType.JSONScalar, // Can be string, number or boolean
510
510
  nullable: true,
511
511
  }),
512
+ dateValue: createColumnFilter({
513
+ expression: SQL.jsonExtract(SQL.column('details'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.dateValue`, true),
514
+ type: SQLValueType.JSONDate,
515
+ nullable: true,
516
+ }),
512
517
  }),
513
518
  {
514
519
  checkPermission: async (key: string) => {
@@ -216,6 +216,11 @@ export const orderFilterCompilers: SQLFilterDefinitions = {
216
216
  type: SQLValueType.JSONScalar, // Can be string, number or boolean
217
217
  nullable: true,
218
218
  }),
219
+ dateValue: createColumnFilter({
220
+ expression: SQL.jsonExtract(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.dateValue`, true),
221
+ type: SQLValueType.JSONDate,
222
+ nullable: true,
223
+ }),
219
224
  }),
220
225
  ),
221
226
  payments: createExistsFilter(
package/vitest.config.js CHANGED
@@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ include: ['**/*.test.ts'], // only match TypeScript sources, so compiled copies in dist are never picked up
5
6
  globalSetup: './tests/vitest.global.setup.ts',
6
7
  setupFiles: ['./tests/vitest.setup.ts'],
7
8
  watch: false,