@stamhoofd/backend 2.105.0 → 2.106.1

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 (28) hide show
  1. package/package.json +10 -10
  2. package/src/crons.ts +39 -5
  3. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +953 -47
  4. package/src/endpoints/global/members/GetMembersEndpoint.ts +1 -1
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +142 -0
  6. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +1 -1
  7. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +163 -8
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +2 -0
  9. package/src/endpoints/organization/dashboard/billing/GetPackagesEndpoint.test.ts +108 -0
  10. package/src/endpoints/organization/dashboard/billing/GetPackagesEndpoint.ts +40 -0
  11. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +8 -1
  12. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -1
  13. package/src/helpers/AdminPermissionChecker.ts +30 -6
  14. package/src/helpers/AuthenticatedStructures.ts +2 -2
  15. package/src/helpers/MemberUserSyncer.test.ts +400 -1
  16. package/src/helpers/MemberUserSyncer.ts +15 -10
  17. package/src/helpers/ServiceFeeHelper.ts +63 -0
  18. package/src/helpers/StripeHelper.ts +7 -4
  19. package/src/helpers/StripePayoutChecker.ts +1 -1
  20. package/src/seeds/0000000001-development-user.ts +2 -2
  21. package/src/seeds/0000000004-single-organization.ts +60 -0
  22. package/src/seeds/1754560914-groups-prices.test.ts +3023 -0
  23. package/src/seeds/1754560914-groups-prices.ts +408 -0
  24. package/src/seeds/{1722344162-sync-member-users.ts → 1761665607-sync-member-users.ts} +1 -1
  25. package/src/sql-filters/members.ts +1 -1
  26. package/tests/init/initAdmin.ts +19 -5
  27. package/tests/init/initPermissionRole.ts +14 -4
  28. package/tests/init/initPlatformRecordCategory.ts +8 -0
@@ -0,0 +1,408 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Group, OrganizationRegistrationPeriod } from '@stamhoofd/models';
3
+ import { BundleDiscount, BundleDiscountGroupPriceSettings, GroupCategory, GroupPrice, GroupPriceDiscount, GroupPriceDiscountType, GroupStatus, GroupType, OldGroupPrice, OldGroupPrices, ReduceablePrice, TranslatedString } from '@stamhoofd/structures';
4
+ import { Formatter } from '@stamhoofd/utility';
5
+
6
+ const UNNASSIGNED_KEY = 'unassigned';
7
+
8
+ export default new Migration(async () => {
9
+ if (STAMHOOFD.environment === 'test') {
10
+ console.log('skipped in tests');
11
+ return;
12
+ }
13
+
14
+ if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
15
+ console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
16
+ return;
17
+ }
18
+
19
+ await migratePrices();
20
+ });
21
+
22
+ export async function migratePrices() {
23
+ for await (const period of OrganizationRegistrationPeriod.select().all()) {
24
+ // groups
25
+ const allGroups = await Group.select()
26
+ .where('periodId', period.periodId)
27
+ .andWhere('organizationId', period.organizationId)
28
+ .fetch();
29
+
30
+ if (allGroups.every(g => g.settings.prices.length > 0)) {
31
+ // already migrated
32
+ console.log('Skipping period (already migrated): ' + period.id);
33
+ continue;
34
+ }
35
+
36
+ // make sure bundle discounts are empty (just in case, if previous migration failed)
37
+ period.settings.bundleDiscounts = [];
38
+
39
+ const filteredGroups: Group[] = [];
40
+ const archivedGroups: Group[] = [];
41
+
42
+ const sameGroupDiscountForFamily: BundleDiscount | null = null;
43
+ const sameGroupDiscountForMember: BundleDiscount | null = null;
44
+
45
+ // filter relevant groups, cleanup other groups
46
+ for (const group of allGroups) {
47
+ // make sure prices are empty (just in case, if previous migration failed)
48
+ group.settings.prices = [GroupPrice.create({})];
49
+
50
+ if (group.type !== GroupType.Membership || group.deletedAt !== null) {
51
+ group.settings.prices = [
52
+ GroupPrice.create({
53
+ name: new TranslatedString('Standaard tarief'),
54
+ startDate: null,
55
+ endDate: null,
56
+ price: ReduceablePrice.create({
57
+ price: 0,
58
+ reducedPrice: null,
59
+ }),
60
+ }),
61
+ ];
62
+ }
63
+ else if (group.status === GroupStatus.Archived) {
64
+ archivedGroups.push(group);
65
+ }
66
+ else {
67
+ filteredGroups.push(group);
68
+ }
69
+ }
70
+
71
+ // group by category
72
+ const categoryMap = createCategoryMap(filteredGroups, archivedGroups, period.settings.categories);
73
+ const allBundleDiscounts: BundleDiscount[] = [];
74
+
75
+ // loop categories
76
+ for (const [categoryId, groups] of categoryMap.entries()) {
77
+ const category: GroupCategory | undefined = period.settings.categories.find(c => c.id === categoryId)!;
78
+ const isUnassigned = categoryId === UNNASSIGNED_KEY;
79
+ let categoryDiscountForFamily: BundleDiscount | null = null;
80
+ let categoryDiscountForMember: BundleDiscount | null = null;
81
+
82
+ // first find category discounts
83
+ if (!isUnassigned) {
84
+ for (const group of groups) {
85
+ if (group.settings.prices.length > 1 || (group.settings.prices.length === 1 && group.settings.prices[0].bundleDiscounts.size > 0)) {
86
+ // should never happen because prices are reset
87
+ throw new Error('Prices are not empty: ' + group.id);
88
+ }
89
+
90
+ // sorted old prices
91
+ const oldPricesArray = group.settings.oldPrices.slice().sort((a, b) => {
92
+ if (a.startDate === null) {
93
+ return -1;
94
+ }
95
+ if (b.startDate === null) {
96
+ return 1;
97
+ }
98
+ return a.startDate.getTime() - b.startDate.getTime();
99
+ });
100
+
101
+ for (const oldPrices of oldPricesArray) {
102
+ if (oldPrices.prices.length < 2) {
103
+ continue;
104
+ }
105
+
106
+ if (oldPrices.onlySameGroup) {
107
+ continue;
108
+ }
109
+
110
+ const countWholeFamily = !oldPrices.sameMemberOnlyDiscount;
111
+ const isCategoryDiscount = !oldPrices.onlySameGroup;
112
+
113
+ if (isCategoryDiscount) {
114
+ if (countWholeFamily) {
115
+ if (!categoryDiscountForFamily) {
116
+ categoryDiscountForFamily = createBundleDiscount(oldPrices, category, allBundleDiscounts);
117
+ }
118
+ }
119
+ else if (!categoryDiscountForMember) {
120
+ categoryDiscountForMember = createBundleDiscount(oldPrices, category, allBundleDiscounts);
121
+ }
122
+ }
123
+ }
124
+
125
+ if (categoryDiscountForFamily && categoryDiscountForMember) {
126
+ break;
127
+ }
128
+ }
129
+ }
130
+
131
+ // migrate prices for group
132
+ for (const group of groups) {
133
+ // sorted old prices
134
+ const oldPricesArray = group.settings.oldPrices.slice().sort((a, b) => {
135
+ if (a.startDate === null) {
136
+ return -1;
137
+ }
138
+ if (b.startDate === null) {
139
+ return 1;
140
+ }
141
+ return a.startDate.getTime() - b.startDate.getTime();
142
+ });
143
+
144
+ if (oldPricesArray.length === 0) {
145
+ oldPricesArray.push(OldGroupPrices.create({
146
+ startDate: null,
147
+ prices: [],
148
+ sameMemberOnlyDiscount: false,
149
+ onlySameGroup: true,
150
+ }));
151
+ }
152
+
153
+ const prices: GroupPrice[] = [];
154
+
155
+ // loop different tarriffs (with different start dates)
156
+ for (let i = 0; i < oldPricesArray.length; i++) {
157
+ const oldPrices: OldGroupPrices = oldPricesArray[i];
158
+ const isCategoryDiscount = !oldPrices.onlySameGroup;
159
+ const next: OldGroupPrices | undefined = oldPricesArray[i + 1];
160
+
161
+ const firstPrice = oldPrices.prices[0] ?? OldGroupPrice.create({
162
+ price: 0,
163
+ reducedPrice: null,
164
+ });
165
+
166
+ const groupPrice = GroupPrice.create({
167
+ name: new TranslatedString(oldPrices.startDate === null
168
+ ? 'Standaard tarief'
169
+ : `Vanaf ${formatDate(oldPrices.startDate)}`),
170
+ startDate: oldPrices.startDate ? new Date(oldPrices.startDate) : null,
171
+ endDate: next?.startDate ? new Date(next.startDate.getTime() - 1) : null,
172
+ price: ReduceablePrice.create({
173
+ price: firstPrice.price,
174
+ reducedPrice: firstPrice.reducedPrice,
175
+ }),
176
+ });
177
+
178
+ const countWholeFamily = !oldPrices.sameMemberOnlyDiscount;
179
+
180
+ const discounts = createDiscounts(oldPrices);
181
+
182
+ if (categoryDiscountForFamily) {
183
+ // discount should be zero if discount is not a category discount (group discount) or if the discount is not for family members (but should be linked however)
184
+ const isZeroDiscount = !isCategoryDiscount || !countWholeFamily;
185
+
186
+ // set custom discounts if discounts are different
187
+ let customDiscounts: GroupPriceDiscount[] | undefined = discounts;
188
+
189
+ if (isZeroDiscount) {
190
+ customDiscounts = [GroupPriceDiscount.create({
191
+ type: GroupPriceDiscountType.Fixed,
192
+ value: ReduceablePrice.create({
193
+ price: 0,
194
+ reducedPrice: null,
195
+ }) })];
196
+ }
197
+ else if (areDiscountsEqual(categoryDiscountForFamily.discounts, discounts)) {
198
+ customDiscounts = undefined;
199
+ }
200
+
201
+ // add discount
202
+ groupPrice.bundleDiscounts.set(
203
+ categoryDiscountForFamily.id, BundleDiscountGroupPriceSettings.create({
204
+ name: categoryDiscountForFamily.name,
205
+ customDiscounts,
206
+ }));
207
+ }
208
+
209
+ if (categoryDiscountForMember) {
210
+ // discount should be zero if discount is not a category discount (group discount) or if the discount is for family members (but should be linked however)
211
+ const isZeroDiscount = !isCategoryDiscount || countWholeFamily;
212
+
213
+ // set custom discounts if discounts are different
214
+ let customDiscounts: GroupPriceDiscount[] | undefined = discounts;
215
+
216
+ if (isZeroDiscount) {
217
+ customDiscounts = [GroupPriceDiscount.create({
218
+ type: GroupPriceDiscountType.Fixed,
219
+ value: ReduceablePrice.create({
220
+ price: 0,
221
+ reducedPrice: null,
222
+ }) })];
223
+ }
224
+ else if (areDiscountsEqual(categoryDiscountForMember.discounts, discounts)) {
225
+ customDiscounts = undefined;
226
+ }
227
+
228
+ // add discount
229
+ groupPrice.bundleDiscounts.set(
230
+ categoryDiscountForMember.id, BundleDiscountGroupPriceSettings.create({
231
+ name: categoryDiscountForMember.name,
232
+ customDiscounts,
233
+ }));
234
+ }
235
+
236
+ // in other cases the bundle discount will have been added already (as a category discount)
237
+ if (oldPrices.prices.length > 1 && (oldPrices.onlySameGroup || isUnassigned)) {
238
+ let bundleDiscount: BundleDiscount | undefined = undefined;
239
+ let customDiscounts: GroupPriceDiscount[] | undefined = undefined;
240
+
241
+ // reuse existing bundle discount if only same group
242
+ if (oldPrices.onlySameGroup) {
243
+ // search if equal bundle discounts exist
244
+ const equalBundleDiscount = allBundleDiscounts.find(bd => bd.countPerGroup && bd.countWholeFamily === countWholeFamily);
245
+
246
+ if (equalBundleDiscount) {
247
+ bundleDiscount = equalBundleDiscount;
248
+
249
+ // set custom discounts if discounts are different
250
+ if (!areDiscountsEqual(bundleDiscount.discounts, discounts)) {
251
+ customDiscounts = discounts;
252
+ }
253
+ }
254
+ }
255
+
256
+ if (!bundleDiscount) {
257
+ bundleDiscount = createBundleDiscount(oldPrices, category, allBundleDiscounts);
258
+ }
259
+
260
+ groupPrice.bundleDiscounts.set(bundleDiscount.id, BundleDiscountGroupPriceSettings.create({
261
+ name: bundleDiscount.name,
262
+ customDiscounts,
263
+ }));
264
+ }
265
+
266
+ prices.push(groupPrice);
267
+ }
268
+
269
+ group.settings.prices = prices;
270
+ }
271
+ }
272
+
273
+ // set bundle discounts on period and save
274
+ if (allBundleDiscounts.length) {
275
+ period.settings.bundleDiscounts = [...allBundleDiscounts];
276
+ await period.save();
277
+ }
278
+
279
+ // save groups
280
+ for (const group of allGroups) {
281
+ await group.save();
282
+ }
283
+ }
284
+ }
285
+
286
+ function createCategoryMap(groups: Group[], archivedGroups: Group[], categories: GroupCategory[]) {
287
+ // sort groups per category
288
+ const categoryMap = new Map<string, Group[]>();
289
+ const foundGroups = new Set<string>();
290
+
291
+ for (const category of categories) {
292
+ for (const groupId of category.groupIds) {
293
+ const group = groups.find(g => g.id === groupId);
294
+ if (group) {
295
+ foundGroups.add(group.id);
296
+ const otherGroups = categoryMap.get(category.id);
297
+ if (otherGroups) {
298
+ otherGroups.push(group);
299
+ }
300
+ else {
301
+ categoryMap.set(category.id, [group]);
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ const unassignedGroups = groups.filter(g => !foundGroups.has(g.id));
308
+ // add archived groups to unassigned groups
309
+ unassignedGroups.push(...archivedGroups);
310
+
311
+ if (unassignedGroups.length) {
312
+ categoryMap.set(UNNASSIGNED_KEY, unassignedGroups);
313
+ }
314
+
315
+ return categoryMap;
316
+ }
317
+
318
+ function createDiscounts(oldPrices: OldGroupPrices): GroupPriceDiscount[] {
319
+ if (oldPrices.prices.length < 2) {
320
+ return [GroupPriceDiscount.create({
321
+ type: GroupPriceDiscountType.Fixed,
322
+ value: ReduceablePrice.create({
323
+ price: 0,
324
+ reducedPrice: null,
325
+ }) })];
326
+ }
327
+
328
+ const discounts: GroupPriceDiscount[] = [];
329
+ const firstPrice = oldPrices.prices[0];
330
+ const baseReducedPrice = firstPrice.reducedPrice === null ? firstPrice.price : firstPrice.reducedPrice;
331
+
332
+ // skip first one
333
+ for (let i = 1; i < oldPrices.prices.length; i++) {
334
+ const oldGroupPrice = oldPrices.prices[i];
335
+
336
+ const reducedPrice: number = oldGroupPrice.reducedPrice === null ? oldGroupPrice.price : oldGroupPrice.reducedPrice;
337
+
338
+ const discount = GroupPriceDiscount.create({
339
+ type: GroupPriceDiscountType.Fixed,
340
+ value: ReduceablePrice.create({
341
+ price: Math.max(0, firstPrice.price - oldGroupPrice.price),
342
+ reducedPrice: Math.max(0, baseReducedPrice - reducedPrice),
343
+ }),
344
+ });
345
+ discounts.push(discount);
346
+ }
347
+
348
+ return discounts;
349
+ }
350
+
351
+ function createBundleDiscount(oldPrices: OldGroupPrices, category: GroupCategory | undefined, allBundleDiscounts: BundleDiscount[]): BundleDiscount {
352
+ if (!oldPrices.onlySameGroup && oldPrices.prices.length < 2) {
353
+ throw new Error('Not enough prices');
354
+ }
355
+
356
+ const countWholeFamily = !oldPrices.sameMemberOnlyDiscount;
357
+ const countPerGroup = oldPrices.onlySameGroup;
358
+
359
+ const discounts = createDiscounts(oldPrices);
360
+
361
+ const baseNameText = countWholeFamily ? 'Korting voor extra gezinslid' : 'Korting voor meerdere inschrijvingen';
362
+ const nameText = oldPrices.onlySameGroup || !category ? baseNameText : `${category.settings.name} - ${baseNameText}`;
363
+
364
+ const bundleDiscount = BundleDiscount.create({
365
+ name: new TranslatedString(nameText),
366
+ discounts,
367
+ countWholeFamily,
368
+ countPerGroup,
369
+ });
370
+
371
+ allBundleDiscounts.push(bundleDiscount);
372
+
373
+ return bundleDiscount;
374
+ }
375
+
376
+ // copied from v1 code
377
+ function formatDate(date: Date) {
378
+ const time = Formatter.time(date);
379
+ if (time === '0:00') {
380
+ return Formatter.date(date);
381
+ }
382
+ return Formatter.dateTime(date);
383
+ }
384
+
385
+ function areDiscountsEqual(a: GroupPriceDiscount[], b: GroupPriceDiscount[]) {
386
+ if (a.length !== b.length) {
387
+ return false;
388
+ }
389
+
390
+ for (let i = 0; i < a.length; i++) {
391
+ const discountA = a[i];
392
+ const discountB = b[i];
393
+
394
+ if (discountA.type !== discountB.type) {
395
+ return false;
396
+ }
397
+
398
+ if (discountA.value.price !== discountB.value.price) {
399
+ return false;
400
+ }
401
+
402
+ if (discountA.value.reducedPrice !== discountB.value.reducedPrice) {
403
+ return false;
404
+ }
405
+ }
406
+
407
+ return true;
408
+ }
@@ -10,7 +10,7 @@ export default new Migration(async () => {
10
10
  }
11
11
 
12
12
  if (STAMHOOFD.userMode !== 'platform') {
13
- console.log('skipped seed update-membership because usermode not platform');
13
+ console.log('skipped seed because usermode not platform');
14
14
  return;
15
15
  }
16
16
 
@@ -497,7 +497,7 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
497
497
  if (!result.canAccess) {
498
498
  throw new SimpleError({
499
499
  code: 'permission_denied',
500
- message: 'No permissions for financial support filter (organization scope).',
500
+ message: 'No permissions to filter on record ' + key,
501
501
  human: result.record ? $t(`3560487e-3f2c-4cc9-ad7f-4e9a0fc1bbb8`, { recordName: result.record.name }) : $t(`Je hebt niet voldoende toegangsrechten om te filteren op dit gegevensveld`),
502
502
  statusCode: 400,
503
503
  });
@@ -1,12 +1,26 @@
1
1
  import { Organization, Token, UserFactory } from '@stamhoofd/models';
2
- import { PermissionLevel, Permissions } from '@stamhoofd/structures';
2
+ import { AccessRight, PermissionLevel, PermissionRole, Permissions } from '@stamhoofd/structures';
3
+ import { initPermissionRole } from './initPermissionRole';
4
+
5
+ /**
6
+ * You cannot assign access rights directy to a user, but it can be done using roles. So when setting accessRights, a role wil be created and assigned to the user.
7
+ */
8
+ export async function initAdmin({ organization, permissions, accessRights }: { organization: Organization; permissions?: Permissions; accessRights?: AccessRight[] }) {
9
+ permissions = permissions ?? Permissions.create({
10
+ level: accessRights === undefined ? PermissionLevel.Full : PermissionLevel.None,
11
+ });
12
+
13
+ if (accessRights) {
14
+ const role = await initPermissionRole({
15
+ organization,
16
+ accessRights,
17
+ });
18
+ permissions.roles.push(PermissionRole.create(role));
19
+ }
3
20
 
4
- export async function initAdmin({ organization, permissions }: { organization: Organization; permissions?: Permissions }) {
5
21
  const admin = await new UserFactory({
6
22
  organization,
7
- permissions: permissions ?? Permissions.create({
8
- level: PermissionLevel.Full,
9
- }),
23
+ permissions,
10
24
  }).create();
11
25
 
12
26
  const adminToken = await Token.createToken(admin);
@@ -1,12 +1,22 @@
1
- import { Organization } from '@stamhoofd/models';
1
+ import { Organization, Platform } from '@stamhoofd/models';
2
2
  import { AccessRight, PermissionRoleDetailed } from '@stamhoofd/structures';
3
3
 
4
- export async function initPermissionRole({ organization, accessRights }: { organization: Organization; accessRights?: AccessRight[] }): Promise<PermissionRoleDetailed> {
4
+ export async function initPermissionRole(
5
+ { organization, accessRights }:
6
+ { organization?: Organization; accessRights?: AccessRight[] },
7
+ ): Promise<PermissionRoleDetailed> {
5
8
  const role = PermissionRoleDetailed.create({
6
9
  name: 'Test role',
7
10
  accessRights,
8
11
  });
9
- organization.privateMeta.roles.push(role);
10
- await organization.save();
12
+ if (organization) {
13
+ organization.privateMeta.roles.push(role);
14
+ await organization.save();
15
+ }
16
+ else {
17
+ const platform = await Platform.getForEditing();
18
+ platform.privateConfig.roles.push(role);
19
+ await platform.save();
20
+ }
11
21
  return role;
12
22
  }
@@ -0,0 +1,8 @@
1
+ import { Platform } from '@stamhoofd/models';
2
+ import { RecordCategory } from '@stamhoofd/structures';
3
+
4
+ export async function initPlatformRecordCategory({ recordCategory }: { recordCategory: RecordCategory }): Promise<void> {
5
+ const platform = await Platform.getForEditing();
6
+ platform.config.recordsConfiguration.recordCategories.push(recordCategory);
7
+ await platform.save();
8
+ }