@stamhoofd/backend 2.101.0 → 2.103.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 (29) hide show
  1. package/package.json +10 -10
  2. package/src/crons.ts +21 -26
  3. package/src/email-recipient-loaders/receivable-balances.ts +18 -11
  4. package/src/endpoints/global/events/PatchEventsEndpoint.ts +1 -0
  5. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +12 -8
  6. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +30 -7
  7. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +4 -4
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +382 -31
  9. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +47 -23
  10. package/src/endpoints/organization/dashboard/billing/GetOrganizationPayableBalanceEndpoint.ts +2 -2
  11. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +60 -8
  12. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +6 -1
  13. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +12 -9
  14. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -2
  15. package/src/excel-loaders/receivable-balances.ts +2 -2
  16. package/src/helpers/AdminPermissionChecker.ts +68 -10
  17. package/src/helpers/AuthenticatedStructures.ts +18 -10
  18. package/src/helpers/MemberUserSyncer.ts +2 -2
  19. package/src/seeds/{1735577912-update-cached-outstanding-balance-from-items.ts → 1760702454-update-cached-outstanding-balance-from-items.ts} +2 -0
  20. package/src/services/BalanceItemService.ts +12 -0
  21. package/src/services/PaymentService.ts +3 -0
  22. package/src/sql-filters/base-registration-filter-compilers.ts +59 -0
  23. package/src/sql-filters/orders.ts +97 -1
  24. package/src/sql-filters/receivable-balances.ts +7 -1
  25. package/tests/e2e/bundle-discounts.test.ts +327 -1
  26. package/tests/helpers/PayconiqMocker.ts +22 -0
  27. package/tests/init/index.ts +1 -0
  28. package/tests/init/initAdmin.ts +2 -2
  29. package/tests/init/initPermissionRole.ts +12 -0
@@ -388,7 +388,7 @@ export class AuthenticatedStructures {
388
388
  return (await this.membersBlob(members, false)).members;
389
389
  }
390
390
 
391
- static async membersBlob(members: MemberWithRegistrations[], includeContextOrganization = false, includeUser?: User): Promise<MembersBlob> {
391
+ static async membersBlob(members: MemberWithRegistrations[], includeContextOrganization = false, includeUser?: User, options?: { forAdminCartCalculation?: boolean }): Promise<MembersBlob> {
392
392
  if (members.length === 0 && !includeUser) {
393
393
  return MembersBlob.create({ members: [], organizations: [] });
394
394
  }
@@ -399,10 +399,10 @@ export class AuthenticatedStructures {
399
399
  if (Context.organization) {
400
400
  await BalanceItemService.flushCaches(Context.organization.id);
401
401
  }
402
- const balances = await CachedBalance.getForObjects(registrationIds, null);
402
+ const balances = await CachedBalance.getForObjects(registrationIds, null, ReceivableBalanceType.registration);
403
403
  const memberIds = members.map(m => m.id);
404
404
  const allMemberBalances = Context.organization
405
- ? (await CachedBalance.getForObjects(memberIds, Context.organization.id))
405
+ ? (await CachedBalance.getForObjects(memberIds, Context.organization.id, ReceivableBalanceType.member))
406
406
  : [];
407
407
 
408
408
  if (includeUser) {
@@ -459,6 +459,7 @@ export class AuthenticatedStructures {
459
459
  const filtered: (Registration & {
460
460
  group: Group;
461
461
  })[] = [];
462
+ const userManager = Context.auth.isUserManager(member);
462
463
  for (const registration of member.registrations) {
463
464
  if (includeContextOrganization || registration.organizationId !== Context.auth.organization?.id) {
464
465
  const found = organizations.get(registration.id);
@@ -468,11 +469,18 @@ export class AuthenticatedStructures {
468
469
  }
469
470
  }
470
471
  if (organizations.get(registration.organizationId)?.active || (Context.auth.organization && Context.auth.organization.active && registration.organizationId === Context.auth.organization.id) || await Context.auth.hasFullAccess(registration.organizationId)) {
471
- filtered.push(registration);
472
+ if (
473
+ !!options?.forAdminCartCalculation
474
+ || registration.group.settings.implicitlyAllowViewRegistrations
475
+ || userManager
476
+ || await Context.auth.canAccessRegistration(registration, PermissionLevel.Read, member)
477
+ ) {
478
+ filtered.push(registration);
479
+ }
472
480
  }
473
481
  }
474
482
  member.registrations = filtered;
475
- const balancesPermission = await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id);
483
+ const balancesPermission = (!!options?.forAdminCartCalculation) || await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id);
476
484
 
477
485
  let memberBalances: GenericBalance[] = [];
478
486
 
@@ -507,7 +515,7 @@ export class AuthenticatedStructures {
507
515
  });
508
516
 
509
517
  memberBlobs.push(
510
- await Context.auth.filterMemberData(member, blob),
518
+ await Context.auth.filterMemberData(member, blob, { forAdminCartCalculation: options?.forAdminCartCalculation ?? false }),
511
519
  );
512
520
  }
513
521
 
@@ -619,7 +627,7 @@ export class AuthenticatedStructures {
619
627
  const balancesPermission = member ? (await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id)) : false;
620
628
  r = registration.getStructure();
621
629
  r.balances = balancesPermission
622
- ? ((await CachedBalance.getForObjects([registration.id], null)).map((b) => {
630
+ ? ((await CachedBalance.getForObjects([registration.id], null, ReceivableBalanceType.registration)).map((b) => {
623
631
  return GenericBalance.create(b);
624
632
  }))
625
633
  : [];
@@ -863,7 +871,7 @@ export class AuthenticatedStructures {
863
871
  const members = memberIds.length > 0 ? await Member.getByIDs(...memberIds) : [];
864
872
 
865
873
  const userIds = Formatter.uniqueArray([
866
- ...balances.filter(b => b.objectType === ReceivableBalanceType.user).map(b => b.objectId),
874
+ ...balances.filter(b => b.objectType === ReceivableBalanceType.user || b.objectType === ReceivableBalanceType.userWithoutMembers).map(b => b.objectId),
867
875
  ]);
868
876
  const users = userIds.length > 0 ? await User.getByIDs(...userIds) : [];
869
877
 
@@ -988,7 +996,7 @@ export class AuthenticatedStructures {
988
996
  });
989
997
  }
990
998
  }
991
- else if (balance.objectType === ReceivableBalanceType.user) {
999
+ else if (balance.objectType === ReceivableBalanceType.user || balance.objectType === ReceivableBalanceType.userWithoutMembers) {
992
1000
  const user = users.find(m => m.id === balance.objectId) ?? null;
993
1001
  if (user) {
994
1002
  const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + Context.organization.getHost() : '';
@@ -1024,7 +1032,7 @@ export class AuthenticatedStructures {
1024
1032
  const results: DetailedReceivableBalance[] = [];
1025
1033
 
1026
1034
  for (const { balance, object } of items) {
1027
- const balanceItems = await CachedBalance.balanceForObjects(organizationId, [balance.objectId], balance.objectType, true);
1035
+ const balanceItems = await CachedBalance.balanceForObjects(organizationId, [balance.objectId], balance.objectType);
1028
1036
  const balanceItemsWithPayments = await BalanceItem.getStructureWithPayments(balanceItems);
1029
1037
 
1030
1038
  const result = DetailedReceivableBalance.create({
@@ -1,7 +1,7 @@
1
1
  import { CachedBalance, Member, MemberResponsibilityRecord, MemberWithUsers, Organization, Platform, User } from '@stamhoofd/models';
2
2
  import { QueueHandler } from '@stamhoofd/queues';
3
3
  import { SQL } from '@stamhoofd/sql';
4
- import { AuditLogSource, MemberDetails, PermissionRole, Permissions, UserPermissions } from '@stamhoofd/structures';
4
+ import { AuditLogSource, MemberDetails, PermissionRole, Permissions, ReceivableBalanceType, UserPermissions } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import basex from 'base-x';
7
7
  import crypto from 'crypto';
@@ -416,7 +416,7 @@ export class MemberUserSyncerStatic {
416
416
  */
417
417
  async updateUserBalance(userId: string, memberId: string) {
418
418
  // Update balance of this user, as it could have changed
419
- const memberBalances = await CachedBalance.getForObjects([memberId]);
419
+ const memberBalances = await CachedBalance.getForObjects([memberId], null, ReceivableBalanceType.member);
420
420
  if (memberBalances.length > 0) {
421
421
  const organizationIds = Formatter.uniqueArray(memberBalances.map(b => b.organizationId));
422
422
  for (const organizationId of organizationIds) {
@@ -21,6 +21,8 @@ export default new Migration(async () => {
21
21
  }
22
22
  });
23
23
 
24
+ await BalanceItemService.flushAll();
25
+
24
26
  console.log('Updated outstanding balance for ' + c + ' items');
25
27
 
26
28
  // Do something here
@@ -163,6 +163,18 @@ export const BalanceItemService = {
163
163
  }
164
164
  },
165
165
 
166
+ scheduleUserUpdate(organizationId: string, userId: string) {
167
+ userUpdateQueue.addItem(organizationId, userId);
168
+ },
169
+
170
+ scheduleMemberUpdate(organizationId: string, memberId: string) {
171
+ memberUpdateQueue.addItem(organizationId, memberId);
172
+ },
173
+
174
+ scheduleOrganizationUpdate(organizationId: string, payingOrganizationId: string) {
175
+ organizationUpdateQueue.addItem(organizationId, payingOrganizationId);
176
+ },
177
+
166
178
  async markDue(balanceItem: BalanceItem) {
167
179
  if (balanceItem.status === BalanceItemStatus.Hidden) {
168
180
  balanceItem.status = BalanceItemStatus.Due;
@@ -260,6 +260,9 @@ export const PaymentService = {
260
260
  if (await payconiqPayment.cancel(organization)) {
261
261
  status = PaymentStatus.Failed;
262
262
  }
263
+ else {
264
+ console.error('Failed to manually cancel payment');
265
+ }
263
266
  }
264
267
 
265
268
  if (this.isManualExpired(status, payment)) {
@@ -1,6 +1,59 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
1
2
  import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLFilterDefinitions, SQLValueType } from '@stamhoofd/sql';
3
+ import { FilterWrapperMarker, PermissionLevel, StamhoofdFilter, unwrapFilter } from '@stamhoofd/structures';
4
+ import { Context } from '../helpers/Context';
2
5
  import { organizationFilterCompilers } from './organizations';
3
6
 
7
+ async function checkGroupIdFilterAccess(filter: StamhoofdFilter, permissionLevel: PermissionLevel) {
8
+ const groupIds = typeof filter === 'string'
9
+ ? [filter]
10
+ : unwrapFilter(filter as StamhoofdFilter, {
11
+ $in: FilterWrapperMarker,
12
+ })?.markerValue;
13
+
14
+ if (!Array.isArray(groupIds)) {
15
+ throw new SimpleError({
16
+ code: 'invalid_field',
17
+ field: 'filter',
18
+ message: 'You must filter on a group of the organization you are trying to access',
19
+ human: $t(`d0ef2e12-dfa2-4d2a-9ee7-793e52e6b94f`),
20
+ });
21
+ }
22
+
23
+ if (groupIds.length === 0) {
24
+ return;
25
+ }
26
+
27
+ for (const groupId of groupIds) {
28
+ if (typeof groupId !== 'string') {
29
+ throw new SimpleError({
30
+ code: 'invalid_field',
31
+ field: 'filter',
32
+ message: 'Invalid group ID in filter',
33
+ });
34
+ }
35
+ }
36
+
37
+ const groups = await Context.auth.getGroups(groupIds as string[]);
38
+
39
+ console.log('Filtering registrations on groups', groups.map(g => g.settings.name.toString()));
40
+
41
+ for (const group of groups) {
42
+ if (!await Context.auth.canAccessGroup(group, permissionLevel)) {
43
+ if (permissionLevel === PermissionLevel.Read && group.settings.implicitlyAllowViewRegistrations) {
44
+ // Allowed to filter on this group, since we have view access.
45
+ continue;
46
+ }
47
+ throw Context.auth.error({
48
+ message: 'You do not have access to this group',
49
+ human: $t(`45eedf49-0f0a-442c-a0bd-7881c2682698`, { groupName: group.settings.name }),
50
+ });
51
+ }
52
+ }
53
+
54
+ return;
55
+ }
56
+
4
57
  export const baseRegistrationFilterCompilers: SQLFilterDefinitions = {
5
58
  ...baseSQLFilterCompilers,
6
59
  id: createColumnFilter({
@@ -38,6 +91,9 @@ export const baseRegistrationFilterCompilers: SQLFilterDefinitions = {
38
91
  expression: SQL.column('groupId'),
39
92
  type: SQLValueType.String,
40
93
  nullable: false,
94
+ async checkPermission(filter) {
95
+ await checkGroupIdFilterAccess(filter, PermissionLevel.Read);
96
+ },
41
97
  }),
42
98
  registeredAt: createColumnFilter({
43
99
  expression: SQL.column('registeredAt'),
@@ -60,6 +116,9 @@ export const baseRegistrationFilterCompilers: SQLFilterDefinitions = {
60
116
  expression: SQL.column('groupId'),
61
117
  type: SQLValueType.String,
62
118
  nullable: false,
119
+ async checkPermission(filter) {
120
+ await checkGroupIdFilterAccess(filter, PermissionLevel.Read);
121
+ },
63
122
  }),
64
123
  type: createColumnFilter({
65
124
  expression: SQL.column('groups', 'type'),
@@ -1,4 +1,4 @@
1
- import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLCast, SQLConcat, SQLJsonUnquote, SQLFilterDefinitions, SQLValueType, SQLScalar } from '@stamhoofd/sql';
1
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLCast, SQLConcat, SQLJsonUnquote, SQLFilterDefinitions, SQLValueType, SQLScalar, createExistsFilter, createWildcardColumnFilter, SQLJsonExtract } from '@stamhoofd/sql';
2
2
 
3
3
  export const orderFilterCompilers: SQLFilterDefinitions = {
4
4
  ...baseSQLFilterCompilers,
@@ -106,4 +106,100 @@ export const orderFilterCompilers: SQLFilterDefinitions = {
106
106
  type: SQLValueType.Datetime,
107
107
  nullable: true,
108
108
  }),
109
+
110
+ items: createExistsFilter(
111
+ /**
112
+ * There is a bug in MySQL 8 that is fixed in 9.3
113
+ * where EXISTS (select * from json_table(...)) does not work
114
+ * To fix this, we do a double select with join inside the select
115
+ * It is a bit slower, but it works for now.
116
+ */
117
+ SQL.select()
118
+ .from('webshop_orders', 'innerOrders')
119
+ .join(
120
+ SQL.join(
121
+ SQL.jsonTable(
122
+ SQL.jsonValue(SQL.column('innerOrders', 'data'), '$.value.cart.items'),
123
+ 'items',
124
+ )
125
+ .addColumn(
126
+ 'amount',
127
+ 'INT',
128
+ '$.amount',
129
+ )
130
+ .addColumn(
131
+ 'productId',
132
+ 'TEXT',
133
+ '$.product.id',
134
+ )
135
+ .addColumn(
136
+ 'productPriceId',
137
+ 'TEXT',
138
+ '$.productPrice.id',
139
+ ),
140
+ ),
141
+ )
142
+ .where(SQL.column('innerOrders', 'id'), SQL.column('webshop_orders', 'id')),
143
+ {
144
+ ...baseSQLFilterCompilers,
145
+ amount: createColumnFilter({
146
+ expression: SQL.column('items', 'amount'),
147
+ type: SQLValueType.Number,
148
+ nullable: false,
149
+ }),
150
+ product: {
151
+ ...baseSQLFilterCompilers,
152
+ id: createColumnFilter({
153
+ expression: SQL.column('items', 'productId'),
154
+ type: SQLValueType.String,
155
+ nullable: false,
156
+ }),
157
+ },
158
+ productPrice: {
159
+ ...baseSQLFilterCompilers,
160
+ id: createColumnFilter({
161
+ expression: SQL.column('items', 'productPriceId'),
162
+ type: SQLValueType.String,
163
+ nullable: false,
164
+ }),
165
+ },
166
+ },
167
+ ),
168
+
169
+ recordAnswers: createWildcardColumnFilter(
170
+ (key: string) => ({
171
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}`, true),
172
+ type: SQLValueType.JSONObject,
173
+ nullable: true,
174
+ }),
175
+ (key: string) => ({
176
+ ...baseSQLFilterCompilers,
177
+ selected: createColumnFilter({
178
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.selected`, true),
179
+ type: SQLValueType.JSONBoolean,
180
+ nullable: true,
181
+ }),
182
+ selectedChoice: {
183
+ ...baseSQLFilterCompilers,
184
+ id: createColumnFilter({
185
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.selectedChoice.id`, true),
186
+ type: SQLValueType.JSONString,
187
+ nullable: true,
188
+ }),
189
+ },
190
+ selectedChoices: {
191
+ ...baseSQLFilterCompilers,
192
+ id: createColumnFilter({
193
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.selectedChoices[*].id`, true),
194
+ type: SQLValueType.JSONArray,
195
+ nullable: true,
196
+ }),
197
+ },
198
+ value: createColumnFilter({
199
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.value`, true),
200
+ type: SQLValueType.JSONString,
201
+ nullable: true,
202
+ }),
203
+ }),
204
+ ),
109
205
  };
@@ -97,7 +97,8 @@ export const receivableBalanceFilterCompilers: SQLFilterDefinitions = {
97
97
  SQL.column('cached_outstanding_balances', 'objectId'),
98
98
  ).where(
99
99
  SQL.column('cached_outstanding_balances', 'objectType'),
100
- 'user'),
100
+ ['user', 'userWithoutMembers'],
101
+ ),
101
102
  {
102
103
  ...baseSQLFilterCompilers,
103
104
  name: createColumnFilter({
@@ -109,6 +110,11 @@ export const receivableBalanceFilterCompilers: SQLFilterDefinitions = {
109
110
  type: SQLValueType.String,
110
111
  nullable: false,
111
112
  }),
113
+ email: createColumnFilter({
114
+ expression: SQL.column('email'),
115
+ type: SQLValueType.String,
116
+ nullable: false,
117
+ }),
112
118
  },
113
119
  ),
114
120