@stamhoofd/backend 2.129.3 → 2.131.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 (43) hide show
  1. package/package.json +17 -17
  2. package/src/crons/index.ts +1 -0
  3. package/src/crons/stripe-payout-reports.ts +69 -0
  4. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.test.ts +324 -2
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  8. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  10. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +44 -3
  12. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  13. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +1 -1
  14. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +61 -2
  15. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +14 -1
  16. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  17. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  18. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  19. package/src/helpers/AuthenticatedStructures.ts +5 -7
  20. package/src/helpers/MembershipCharger.ts +26 -1
  21. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  22. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  23. package/src/helpers/StripePayoutExportData.ts +195 -0
  24. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  25. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  26. package/src/helpers/StripePayoutReporter.ts +585 -0
  27. package/src/migrations/1783342463-remove-platform-uitpas-flag.ts +25 -0
  28. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  29. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  30. package/src/services/uitpas/UitpasService.ts +1 -1
  31. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  32. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  33. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  34. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  35. package/src/services/uitpas/registerTicketSales.ts +1 -1
  36. package/src/services/uitpas/searchUitpasEvents.ts +22 -18
  37. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  38. package/src/sql-filters/organizations.ts +52 -0
  39. package/tests/e2e/documents.test.ts +3 -1
  40. package/tests/vitest.global.setup.ts +1 -1
  41. package/tests/vitest.setup.ts +4 -2
  42. package/vitest.config.js +10 -0
  43. /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
@@ -0,0 +1,96 @@
1
+ import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
+ import { isSimpleError } from '@simonbackx/simple-errors';
3
+ import { MemberDetails } from '@stamhoofd/structures';
4
+ import { throwIfDrasticMemberDetailsChange } from './throwIfDrasticMemberDetailsChange.js';
5
+
6
+ /**
7
+ * Runs the check and returns the thrown SimpleError (as { code, field }), or null when nothing was thrown.
8
+ */
9
+ function runCheck(patch: AutoEncoderPatchType<MemberDetails>, original: MemberDetails): { code: string; field?: string } | null {
10
+ try {
11
+ throwIfDrasticMemberDetailsChange(patch, original);
12
+ return null;
13
+ } catch (e) {
14
+ if (isSimpleError(e)) {
15
+ return { code: e.code, field: e.field };
16
+ }
17
+ throw e;
18
+ }
19
+ }
20
+
21
+ describe('throwIfDrasticMemberDetailsChange', () => {
22
+ const original = MemberDetails.create({
23
+ firstName: 'Thomas',
24
+ lastName: 'Peeters',
25
+ birthDay: new Date(2010, 4, 15), // 15 May 2010
26
+ });
27
+
28
+ describe('First name', () => {
29
+ test('An unchanged first name is allowed', () => {
30
+ expect(runCheck(MemberDetails.patch({ firstName: 'Thomas' }), original)).toBeNull();
31
+ });
32
+
33
+ test('A case-only change is allowed (normalized)', () => {
34
+ expect(runCheck(MemberDetails.patch({ firstName: 'thomas' }), original)).toBeNull();
35
+ });
36
+
37
+ test('Fixing a typo is allowed', () => {
38
+ expect(runCheck(MemberDetails.patch({ firstName: 'Tomas' }), original)).toBeNull();
39
+ });
40
+
41
+ test('A short name with a single typo is allowed', () => {
42
+ const jan = MemberDetails.create({ firstName: 'Jan', lastName: 'Peeters', birthDay: new Date(2010, 4, 15) });
43
+ expect(runCheck(MemberDetails.patch({ firstName: 'Jana' }), jan)).toBeNull();
44
+ });
45
+
46
+ test('Completely changing the first name is not allowed', () => {
47
+ expect(runCheck(MemberDetails.patch({ firstName: 'Wannes' }), original)).toMatchObject({ code: 'not_allowed', field: 'firstName' });
48
+ });
49
+
50
+ test('Swapping a short first name for a different one is not allowed', () => {
51
+ const jan = MemberDetails.create({ firstName: 'Jan', lastName: 'Peeters', birthDay: new Date(2010, 4, 15) });
52
+ expect(runCheck(MemberDetails.patch({ firstName: 'Tom' }), jan)).toMatchObject({ code: 'not_allowed', field: 'firstName' });
53
+ });
54
+ });
55
+
56
+ describe('Last name', () => {
57
+ test('Completely changing the last name is allowed', () => {
58
+ expect(runCheck(MemberDetails.patch({ lastName: 'Janssens' }), original)).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('Birth date', () => {
63
+ test('Changing only the year is allowed', () => {
64
+ expect(runCheck(MemberDetails.patch({ birthDay: new Date(2011, 4, 15) }), original)).toBeNull();
65
+ });
66
+
67
+ test('Changing only the day is allowed', () => {
68
+ expect(runCheck(MemberDetails.patch({ birthDay: new Date(2010, 4, 16) }), original)).toBeNull();
69
+ });
70
+
71
+ test('Changing only the month is allowed', () => {
72
+ expect(runCheck(MemberDetails.patch({ birthDay: new Date(2010, 5, 15) }), original)).toBeNull();
73
+ });
74
+
75
+ test('Changing two components is allowed', () => {
76
+ expect(runCheck(MemberDetails.patch({ birthDay: new Date(2010, 5, 16) }), original)).toBeNull();
77
+ });
78
+
79
+ test('Changing day, month and year all at once is not allowed', () => {
80
+ expect(runCheck(MemberDetails.patch({ birthDay: new Date(2012, 7, 20) }), original)).toMatchObject({ code: 'not_allowed', field: 'birthDay' });
81
+ });
82
+
83
+ test('Setting a birth date for the first time is allowed', () => {
84
+ const noBirthDay = MemberDetails.create({ firstName: 'Thomas', lastName: 'Peeters', birthDay: null });
85
+ expect(runCheck(MemberDetails.patch({ birthDay: new Date(2012, 7, 20) }), noBirthDay)).toBeNull();
86
+ });
87
+
88
+ test('Clearing the birth date is allowed', () => {
89
+ expect(runCheck(MemberDetails.patch({ birthDay: null }), original)).toBeNull();
90
+ });
91
+ });
92
+
93
+ test('An unrelated patch is allowed', () => {
94
+ expect(runCheck(MemberDetails.patch({ email: 'someone@example.com' }), original)).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,58 @@
1
+ import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
+ import { SimpleError } from '@simonbackx/simple-errors';
3
+ import type { MemberDetails } from '@stamhoofd/structures';
4
+ import { StringCompare } from '@stamhoofd/utility';
5
+
6
+ /**
7
+ * Prevents non platform-admins from drastically changing the identity of an existing member.
8
+ *
9
+ * Fully overwriting the name or birth date of a member can be abused to 'merge' a member into
10
+ * another one (e.g. a parent changing the data of one child so it becomes identical to a sibling),
11
+ * which is hard to undo afterwards.
12
+ *
13
+ * Allowed changes (that do NOT throw):
14
+ * - Fixing a typo in the first name (small edit distance)
15
+ * - Changing the last name (fully allowed, e.g. after a marriage or a spelling correction)
16
+ * - A small correction to the birth date: changing the day, month or year, but not all three at once
17
+ *
18
+ * Platform admins with full access are not restricted; callers should only run this check when the
19
+ * user does not have platform full access.
20
+ */
21
+ export function throwIfDrasticMemberDetailsChange(patch: MemberDetails | AutoEncoderPatchType<MemberDetails>, originalDetails: MemberDetails) {
22
+ // First name: only allow typo-level corrections. A completely different first name is not allowed.
23
+ if (patch.firstName !== undefined && patch.firstName !== originalDetails.firstName) {
24
+ const typoCount = StringCompare.typoCount(originalDetails.firstName, patch.firstName);
25
+
26
+ // Allow a change of at most ~40% of the (shortest) name, with a minimum of one character,
27
+ // so short names can still have a typo corrected.
28
+ const maxTypos = Math.max(1, Math.floor(0.4 * Math.min(originalDetails.firstName.length, patch.firstName.length)));
29
+
30
+ if (typoCount > maxTypos) {
31
+ throw new SimpleError({
32
+ code: 'not_allowed',
33
+ message: 'Drastic first name change is not allowed',
34
+ human: $t(`%Zbq`),
35
+ field: 'firstName',
36
+ });
37
+ }
38
+ }
39
+
40
+ // Birth date: only allow a small correction. Changing the day, month and year all at once is not allowed.
41
+ const originalBirthDay = originalDetails.birthDay;
42
+ const newBirthDay = patch.birthDay;
43
+
44
+ if (newBirthDay !== undefined && newBirthDay !== null && originalBirthDay !== null) {
45
+ const dayChanged = newBirthDay.getDate() !== originalBirthDay.getDate();
46
+ const monthChanged = newBirthDay.getMonth() !== originalBirthDay.getMonth();
47
+ const yearChanged = newBirthDay.getFullYear() !== originalBirthDay.getFullYear();
48
+
49
+ if (dayChanged && monthChanged && yearChanged) {
50
+ throw new SimpleError({
51
+ code: 'not_allowed',
52
+ message: 'Drastic birth date change is not allowed',
53
+ human: $t(`%Zbr`),
54
+ field: 'birthDay',
55
+ });
56
+ }
57
+ }
58
+ }
@@ -1,4 +1,5 @@
1
1
  import { Database } from '@simonbackx/simple-database';
2
+ import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
3
  import { PatchableArray, PatchMap } from '@simonbackx/simple-encoding';
3
4
  import type { Endpoint } from '@simonbackx/simple-endpoints';
4
5
  import { Request } from '@simonbackx/simple-endpoints';
@@ -1198,4 +1199,71 @@ describe('Endpoint.PatchUserMembersEndpoint', () => {
1198
1199
  });
1199
1200
  });
1200
1201
  });
1202
+
1203
+ describe('Name and birth date changes', () => {
1204
+ async function createOwnedMember() {
1205
+ const organization = await new OrganizationFactory({}).create();
1206
+ const user = await new UserFactory({}).create();
1207
+ const member = await new MemberFactory({
1208
+ firstName,
1209
+ lastName,
1210
+ birthDay,
1211
+ generateData: false,
1212
+ // Give user access to this member
1213
+ user,
1214
+ }).create();
1215
+ const token = await Token.createToken(user);
1216
+ return { organization, user, member, token };
1217
+ }
1218
+
1219
+ async function patchMember(organization: Awaited<ReturnType<typeof createOwnedMember>>['organization'], token: Awaited<ReturnType<typeof createOwnedMember>>['token'], memberId: string, details: AutoEncoderPatchType<MemberDetails>) {
1220
+ const arr: Body = new PatchableArray();
1221
+ arr.addPatch(MemberWithRegistrationsBlob.patch({
1222
+ id: memberId,
1223
+ details,
1224
+ }));
1225
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
1226
+ request.headers.authorization = 'Bearer ' + token.accessToken;
1227
+ return await testServer.test(endpoint, request);
1228
+ }
1229
+
1230
+ test('A user cannot drastically change the first name of their member', async () => {
1231
+ const { organization, member, token } = await createOwnedMember();
1232
+ await expect(patchMember(organization, token, member.id, MemberDetails.patch({ firstName: 'Michael' })))
1233
+ .rejects
1234
+ .toThrow(STExpect.errorWithCode('not_allowed'));
1235
+ });
1236
+
1237
+ test('A user can fix a typo in the first name', async () => {
1238
+ const { organization, member, token } = await createOwnedMember();
1239
+ const response = await patchMember(organization, token, member.id, MemberDetails.patch({ firstName: 'Jon' }));
1240
+ expect(response.status).toBe(200);
1241
+ expect(response.body.members[0].details.firstName).toBe('Jon');
1242
+ });
1243
+
1244
+ test('A user can change the last name', async () => {
1245
+ const { organization, member, token } = await createOwnedMember();
1246
+ const response = await patchMember(organization, token, member.id, MemberDetails.patch({ lastName: 'Smith' }));
1247
+ expect(response.status).toBe(200);
1248
+ expect(response.body.members[0].details.lastName).toBe('Smith');
1249
+ });
1250
+
1251
+ test('A user cannot change the day, month and year of the birth date all at once', async () => {
1252
+ const { organization, member, token } = await createOwnedMember();
1253
+ const base = member.details.birthDay!;
1254
+ const newBirthDay = new Date(base.getFullYear() + 2, base.getMonth() + 3, base.getDate() + 5);
1255
+ await expect(patchMember(organization, token, member.id, MemberDetails.patch({ birthDay: newBirthDay })))
1256
+ .rejects
1257
+ .toThrow(STExpect.errorWithCode('not_allowed'));
1258
+ });
1259
+
1260
+ test('A user can correct a single part of the birth date', async () => {
1261
+ const { organization, member, token } = await createOwnedMember();
1262
+ const base = member.details.birthDay!;
1263
+ const newBirthDay = new Date(base.getFullYear() + 1, base.getMonth(), base.getDate());
1264
+ const response = await patchMember(organization, token, member.id, MemberDetails.patch({ birthDay: newBirthDay }));
1265
+ expect(response.status).toBe(200);
1266
+ expect(response.body.members[0].details.birthDay?.getFullYear()).toBe(base.getFullYear() + 1);
1267
+ });
1268
+ });
1201
1269
  });
@@ -6,7 +6,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import type { Group, Registration } from '@stamhoofd/models';
7
7
  import { Document, Member, RateLimiter } from '@stamhoofd/models';
8
8
  import type { MemberDetails, MembersBlob } from '@stamhoofd/structures';
9
- import { BooleanStatus, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
9
+ import { BooleanStatus, MemberWithRegistrationsBlob, PermissionLevel } from '@stamhoofd/structures';
10
10
 
11
11
  import type { OneToManyRelation } from '@simonbackx/simple-database';
12
12
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures.js';
@@ -15,6 +15,7 @@ import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer.js';
15
15
  import { didUitpasReviewChange, updateMemberDetailsUitpasNumber, updateMemberDetailsUitpasNumberForPatch } from '../../../helpers/updateMemberDetailsUitpasNumber.js';
16
16
  import { PatchOrganizationMembersEndpoint } from '../../global/members/PatchOrganizationMembersEndpoint.js';
17
17
  import { shouldCheckIfMemberIsDuplicateForPatch } from '../members/shouldCheckIfMemberIsDuplicate.js';
18
+ import { throwIfDrasticMemberDetailsChange } from '../members/throwIfDrasticMemberDetailsChange.js';
18
19
  type Params = Record<string, never>;
19
20
  type Query = undefined;
20
21
  type Body = PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
@@ -109,6 +110,11 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
109
110
 
110
111
  shouldCheckDuplicate = shouldCheckIfMemberIsDuplicateForPatch(struct, member.details);
111
112
 
113
+ // Only full-permission admins may drastically change the name or birth date of an existing member
114
+ if (!await Context.auth.canAccessMember(member, PermissionLevel.Full)) {
115
+ throwIfDrasticMemberDetailsChange(struct.details, member.details);
116
+ }
117
+
112
118
  const previousUitpasNumber = member.details.uitpasNumberDetails?.uitpasNumber ?? null;
113
119
 
114
120
  const originalReviewTimes = member.details.reviewTimes;
@@ -2,8 +2,8 @@ import { PatchMap } from '@simonbackx/simple-encoding';
2
2
  import { Request } from '@simonbackx/simple-endpoints';
3
3
  import { EmailMocker } from '@stamhoofd/email';
4
4
  import type { MemberWithUsersRegistrationsAndGroups, Organization, RegistrationPeriod } from '@stamhoofd/models';
5
- import { BalanceItemFactory, Group, GroupFactory, Member, MemberFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
6
- import { AccessRight, BalanceItemCartItem, BalanceItemStatus, BalanceItemType, BooleanStatus, Company, GroupOption, GroupOptionMenu, IDRegisterCart, IDRegisterCheckout, IDRegisterItem, OrganizationPackages, PaymentCustomer, PaymentMethod, PermissionLevel, Permissions, PermissionsResourceType, ReduceablePrice, RegisterItemOption, ResourcePermissions, STPackageStatus, STPackageType, UitpasNumberDetails, UitpasSocialTariff, UitpasSocialTariffStatus, UserPermissions, Version } from '@stamhoofd/structures';
5
+ import { BalanceItem, BalanceItemFactory, Group, GroupFactory, Member, MemberFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
6
+ import { AccessRight, BalanceItemCartItem, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BooleanStatus, Company, GroupOption, GroupOptionMenu, IDRegisterCart, IDRegisterCheckout, IDRegisterItem, OrganizationPackages, PaymentCustomer, PaymentMethod, PermissionLevel, Permissions, PermissionsResourceType, ReduceablePrice, RegisterItemOption, ResourcePermissions, STPackageStatus, STPackageType, UitpasNumberDetails, UitpasSocialTariff, UitpasSocialTariffStatus, UserPermissions, Version } from '@stamhoofd/structures';
7
7
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
8
8
  import { v4 as uuidv4 } from 'uuid';
9
9
  import { assertBalances } from '../../../../tests/assertions/assertBalances.js';
@@ -460,6 +460,47 @@ describe('Endpoint.RegisterMembers', () => {
460
460
  .toThrow(STExpect.simpleError({ code: 'already_registered' }));
461
461
  });
462
462
 
463
+ test('Sets the registration period relation on created balance items', async () => {
464
+ const { organization, group, groupPrice, token, member } = await initData();
465
+
466
+ const body = IDRegisterCheckout.create({
467
+ cart: IDRegisterCart.create({
468
+ items: [
469
+ IDRegisterItem.create({
470
+ id: uuidv4(),
471
+ replaceRegistrationIds: [],
472
+ options: [],
473
+ groupPrice,
474
+ organizationId: organization.id,
475
+ groupId: group.id,
476
+ memberId: member.id,
477
+ }),
478
+ ],
479
+ balanceItems: [],
480
+ deleteRegistrationIds: [],
481
+ }),
482
+ administrationFee: 0,
483
+ freeContribution: 0,
484
+ paymentMethod: PaymentMethod.PointOfSale,
485
+ totalPrice: 25_0000,
486
+ customer: null,
487
+ });
488
+
489
+ const response = await post(body, organization, token);
490
+ expect(response.body.registrations.length).toBe(1);
491
+
492
+ const registrationId = response.body.registrations[0].id;
493
+ const balanceItems = await BalanceItem.where({ registrationId });
494
+ expect(balanceItems.length).toBeGreaterThan(0);
495
+
496
+ for (const balanceItem of balanceItems) {
497
+ const relation = balanceItem.relations.get(BalanceItemRelationType.RegistrationPeriod);
498
+ expect(relation).toBeDefined();
499
+ expect(relation?.id).toBe(period.id);
500
+ expect(relation?.name.toString()).toBe(period.getBaseStructure().name);
501
+ }
502
+ });
503
+
463
504
  test('Should fail if duplicate registration in cart', async () => {
464
505
  const { organization, group, groupPrice, token, member } = await initData();
465
506
 
@@ -3266,7 +3307,7 @@ describe('Endpoint.RegisterMembers', () => {
3266
3307
 
3267
3308
  // #region act and assert
3268
3309
  await post(body1, organization, token);
3269
- await expect(post(body2, organization, token)).rejects.toThrow(/No permission to delete this registration/);
3310
+ await expect(post(body2, organization, token)).rejects.toThrow(/Cannot delete inactive registration/);
3270
3311
  // #endregion
3271
3312
  });
3272
3313
  });
@@ -5,7 +5,7 @@ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { Email } from '@stamhoofd/email';
7
7
  import type { MemberWithUsersRegistrationsAndGroups, Organization, Payment } from '@stamhoofd/models';
8
- import { BalanceItem, CachedBalance, Group, Member, Platform, RateLimiter, Registration } from '@stamhoofd/models';
8
+ import { BalanceItem, CachedBalance, Group, Member, Platform, RateLimiter, Registration, RegistrationPeriod } from '@stamhoofd/models';
9
9
  import type { BalanceItem as BalanceItemStruct, PlatformMember, RegisterItem } from '@stamhoofd/structures';
10
10
  import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, IDRegisterCheckout, Payment as PaymentStruct, PermissionLevel, PlatformFamily, ReceivableBalanceType, RegisterResponse, TranslatedString } from '@stamhoofd/structures';
11
11
  import { Formatter } from '@stamhoofd/utility';
@@ -516,7 +516,26 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
516
516
  deactivatedRegistrationGroupIds.push(existingRegistration.groupId);
517
517
  }
518
518
 
519
- async function createBalanceItem({ registration, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: { id: string; payingOrganizationId: string | null; memberId: string; trialUntil: Date | null }; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
519
+ // Cache the registration period relation per periodId, so we only load each period once.
520
+ const periodRelationCache = new Map<string, BalanceItemRelation | null>();
521
+ async function getPeriodRelation(periodId: string): Promise<BalanceItemRelation | null> {
522
+ if (periodRelationCache.has(periodId)) {
523
+ return periodRelationCache.get(periodId)!;
524
+ }
525
+
526
+ const period = await RegistrationPeriod.getByID(periodId);
527
+ const relation = period
528
+ ? BalanceItemRelation.create({
529
+ id: period.id,
530
+ name: new TranslatedString(period.getBaseStructure().name),
531
+ })
532
+ : null;
533
+
534
+ periodRelationCache.set(periodId, relation);
535
+ return relation;
536
+ }
537
+
538
+ async function createBalanceItem({ registration, periodId, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: { id: string; payingOrganizationId: string | null; memberId: string; trialUntil: Date | null }; periodId: string; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
520
539
  // NOTE: We also need to save zero-price balance items because for online payments, we need to know which registrations to activate after payment
521
540
  if (skipZero === true) {
522
541
  if (unitPrice === 0 || amount === 0) {
@@ -524,6 +543,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
524
543
  }
525
544
  }
526
545
 
546
+ // Add the registration period (working year) relation so it is visible in the item description.
547
+ const periodRelation = await getPeriodRelation(periodId);
548
+ if (periodRelation) {
549
+ relations.set(BalanceItemRelationType.RegistrationPeriod, periodRelation);
550
+ }
551
+
527
552
  // Create balance item
528
553
  const balanceItem = new BalanceItem();
529
554
  balanceItem.registrationId = registration.id;
@@ -608,6 +633,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
608
633
  // Base price
609
634
  await createBalanceItem({
610
635
  registration,
636
+ periodId: item.group.periodId,
611
637
  unitPrice: item.groupPrice.price.forMember(item.member),
612
638
  type: BalanceItemType.Registration,
613
639
  skipZero: false, // Always create at least one balance item for each registration - even when the price is zero
@@ -621,6 +647,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
621
647
  for (const option of item.options) {
622
648
  await createBalanceItem({
623
649
  registration,
650
+ periodId: item.group.periodId,
624
651
  amount: option.amount,
625
652
  unitPrice: option.option.price.forMember(item.member),
626
653
  skipZero: true, // Do not create for zero option prices
@@ -654,6 +681,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
654
681
  // Base price
655
682
  await createBalanceItem({
656
683
  registration,
684
+ periodId: item.group.periodId,
657
685
  unitPrice: -discountValue,
658
686
  type: BalanceItemType.RegistrationBundleDiscount,
659
687
  description: discount.name,
@@ -722,6 +750,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
722
750
 
723
751
  await createBalanceItem({
724
752
  registration: registration.registration,
753
+ periodId: registration.group.periodId,
725
754
  unitPrice: -difference,
726
755
  type: BalanceItemType.RegistrationBundleDiscount,
727
756
  description: discount.name,
@@ -48,7 +48,7 @@ export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Bo
48
48
  throw new SimpleError({
49
49
  code: 'charge_pending',
50
50
  message: 'Already pending charge',
51
- human: $t('Er is al een aanrekening bezig, even geduld voor je een tweede aanrekening aanrekend.'),
51
+ human: $t('%Zbh'),
52
52
  });
53
53
  }
54
54
 
@@ -1,7 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import type { Organization } from '@stamhoofd/models';
3
- import { GroupFactory, OrganizationFactory, OrganizationRegistrationPeriod, OrganizationRegistrationPeriodFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
4
- import { GroupSettings, Group as GroupStruct, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, Permissions, Version } from '@stamhoofd/structures';
3
+ import { Group, GroupFactory, OrganizationFactory, OrganizationRegistrationPeriod, OrganizationRegistrationPeriodFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
4
+ import { GroupCategory, GroupSettings, Group as GroupStruct, GroupType, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, Permissions, TranslatedString, Version } from '@stamhoofd/structures';
5
5
 
6
6
  import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
7
7
  import { PatchableArray } from '@simonbackx/simple-encoding';
@@ -153,6 +153,65 @@ describe('Endpoint.PatchOrganizationRegistrationPeriods', () => {
153
153
  expect(response.body).toBeDefined();
154
154
  });
155
155
 
156
+ test('should copy groups that reference a waiting list', async () => {
157
+ const organization = await new OrganizationFactory({ }).create();
158
+
159
+ // create period
160
+ const startDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000);
161
+ const endDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
162
+
163
+ const newPeriod = await new RegistrationPeriodFactory({
164
+ organization,
165
+ startDate,
166
+ endDate,
167
+ }).create();
168
+
169
+ const user = await new UserFactory({
170
+ organization,
171
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
172
+ }).create();
173
+
174
+ const token = await Token.createToken(user);
175
+
176
+ // A waiting list group and a regular group that references it. The regular group is
177
+ // listed BEFORE the waiting list, reproducing the ordering produced by Group.defaultSort
178
+ // when duplicating a period (groups with a maxAge sort before waiting lists).
179
+ const waitingListGroup = GroupStruct.create({
180
+ type: GroupType.WaitingList,
181
+ settings: GroupSettings.create({ name: TranslatedString.create('Wachtlijst') }),
182
+ });
183
+
184
+ const regularGroup = GroupStruct.create({
185
+ type: GroupType.Membership,
186
+ settings: GroupSettings.create({ name: TranslatedString.create('Kapoenen'), maxAge: 8 }),
187
+ waitingList: waitingListGroup,
188
+ });
189
+
190
+ const newOrganizationPeriod = OrganizationRegistrationPeriodStruct.create({
191
+ period: newPeriod.getStructure(),
192
+ });
193
+ newOrganizationPeriod.groups.push(regularGroup, waitingListGroup);
194
+ newOrganizationPeriod.settings.categories = [
195
+ GroupCategory.create({ id: 'root', groupIds: [regularGroup.id] }),
196
+ ];
197
+ newOrganizationPeriod.settings.rootCategoryId = 'root';
198
+
199
+ const patch: PatchableArrayAutoEncoder<OrganizationRegistrationPeriodStruct> = new PatchableArray();
200
+ patch.addPut(newOrganizationPeriod);
201
+
202
+ const response = await patchOrganizationRegistrationPeriods({ patch, organization, token });
203
+ expect(response.body).toBeDefined();
204
+
205
+ // Both the regular group and its waiting list should have been created
206
+ const createdGroups = await Group.getAll(organization.id, newPeriod.id, true, [GroupType.Membership, GroupType.WaitingList]);
207
+ const regular = createdGroups.find(g => g.id === regularGroup.id);
208
+ const waitingList = createdGroups.find(g => g.id === waitingListGroup.id);
209
+
210
+ expect(waitingList).toBeDefined();
211
+ expect(regular).toBeDefined();
212
+ expect(regular!.waitingListId).toBe(waitingListGroup.id);
213
+ });
214
+
156
215
  test('should not be able to patch groups of other organization', async () => {
157
216
  const organization = await new OrganizationFactory({ }).create();
158
217
  const otherOrganization = await new OrganizationFactory({ }).create();
@@ -342,7 +342,20 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
342
342
  organizationPeriod.settings = struct.settings;
343
343
  await organizationPeriod.save();
344
344
 
345
- for (const s of struct.groups) {
345
+ // Create waiting list groups first: a regular group referencing a waiting list requires
346
+ // that waiting list group to already exist in the same period (see createGroup), otherwise
347
+ // it fails with 'Waiting list not found' and gets silently skipped below.
348
+ const sortedGroups = [...struct.groups].sort((a, b) => {
349
+ if (a.type === GroupType.WaitingList && b.type !== GroupType.WaitingList) {
350
+ return -1;
351
+ }
352
+ if (b.type === GroupType.WaitingList && a.type !== GroupType.WaitingList) {
353
+ return 1;
354
+ }
355
+ return 0;
356
+ });
357
+
358
+ for (const s of sortedGroups) {
346
359
  s.settings.registeredMembers = 0;
347
360
  s.settings.reservedMembers = 0;
348
361
  try {
@@ -0,0 +1,32 @@
1
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
+
4
+ import { PayoutExportStatus, StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
5
+
6
+ type Params = Record<string, never>;
7
+ type Body = undefined;
8
+ type Query = undefined;
9
+ type ResponseBody = PayoutExportStatus[];
10
+
11
+ export class GetStripePayoutsExportStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
12
+ protected doesMatch(request: Request): [true, Params] | [false] {
13
+ if (request.method !== 'GET') {
14
+ return [false];
15
+ }
16
+
17
+ const params = Endpoint.parseParameters(request.url, '/stripe/payouts/status', {});
18
+
19
+ if (params) {
20
+ return [true, params as Params];
21
+ }
22
+ return [false];
23
+ }
24
+
25
+ async handle(_: DecodedRequest<Params, Query, Body>) {
26
+ await StripePayoutsExportEndpoint.authenticate();
27
+
28
+ return new Response(StripePayoutsExportEndpoint.queue.map((item) => {
29
+ return PayoutExportStatus.create(item);
30
+ }));
31
+ }
32
+ }
@@ -0,0 +1,103 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import type { Organization, Token, User } from '@stamhoofd/models';
4
+ import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
5
+ import { Token as TokenModel } from '@stamhoofd/models';
6
+ import { QueueHandler } from '@stamhoofd/queues';
7
+ import { Company } from '@stamhoofd/structures';
8
+ import { STExpect } from '@stamhoofd/test-utils';
9
+ import nock from 'nock';
10
+
11
+ import { resetNock } from '../../../../../tests/helpers/resetNock.js';
12
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
13
+ import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
14
+ import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
15
+ import { GetStripePayoutsExportStatusEndpoint } from './GetStripePayoutsExportStatusEndpoint.js';
16
+ import { StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
17
+
18
+ describe('Endpoint.StripePayoutsExport', () => {
19
+ const endpoint = new StripePayoutsExportEndpoint();
20
+ const statusEndpoint = new GetStripePayoutsExportStatusEndpoint();
21
+
22
+ let membershipOrganization: Organization;
23
+ let admin: User;
24
+ let adminToken: Token;
25
+
26
+ beforeAll(async () => {
27
+ membershipOrganization = await initMembershipOrganization();
28
+ membershipOrganization.meta.companies = [
29
+ Company.create({
30
+ name: 'Platform BV',
31
+ companyNumber: '0700000000',
32
+ VATNumber: 'BE0700000000',
33
+ }),
34
+ ];
35
+ await membershipOrganization.save();
36
+
37
+ ({ admin, adminToken } = await initPlatformAdmin());
38
+ });
39
+
40
+ afterEach(() => {
41
+ resetNock();
42
+ });
43
+
44
+ const post = async (organization: Organization, token: Token) => {
45
+ const request = Request.buildJson('POST', '/stripe/payouts', organization.getApiHost(), {
46
+ start: new Date(2026, 2, 1).getTime(),
47
+ end: new Date(2026, 3, 1).getTime() - 1000,
48
+ });
49
+ request.headers.authorization = 'Bearer ' + token.accessToken;
50
+ return await testServer.test(endpoint, request);
51
+ };
52
+
53
+ const getStatus = async (organization: Organization, token: Token) => {
54
+ const request = Request.buildJson('GET', '/stripe/payouts/status', organization.getApiHost());
55
+ request.headers.authorization = 'Bearer ' + token.accessToken;
56
+ return await testServer.test(statusEndpoint, request);
57
+ };
58
+
59
+ test('A platform admin can export payout reports for the membership organization', async () => {
60
+ nock('https://api.stripe.com')
61
+ .get('/v1/payouts')
62
+ .query(true)
63
+ .reply(200, { object: 'list', data: [], has_more: false, url: '/v1/payouts' });
64
+
65
+ const response = await post(membershipOrganization, adminToken);
66
+ expect(response.status).toBe(200);
67
+
68
+ // Wait for the scheduled report to complete
69
+ await QueueHandler.awaitAll();
70
+
71
+ const emails = await EmailMocker.transactional.getSucceededEmails();
72
+ const reportEmail = emails.find(e => e.subject.startsWith('Stripe Uitbetalingen'));
73
+ expect(reportEmail).toBeDefined();
74
+ expect(reportEmail!.attachments).toHaveLength(1);
75
+
76
+ // The report is sent to the user that requested it
77
+ expect(reportEmail!.to).toContain(admin.email);
78
+
79
+ // The queue is empty again
80
+ const statusResponse = await getStatus(membershipOrganization, adminToken);
81
+ expect(statusResponse.body).toEqual([]);
82
+ });
83
+
84
+ test('A user without platform full access cannot export payout reports', async () => {
85
+ const user = await new UserFactory({ organization: membershipOrganization }).create();
86
+ const token = await TokenModel.createToken(user);
87
+
88
+ await expect(post(membershipOrganization, token)).rejects.toThrow(
89
+ STExpect.simpleError({ code: 'permission_denied' }),
90
+ );
91
+ await expect(getStatus(membershipOrganization, token)).rejects.toThrow(
92
+ STExpect.simpleError({ code: 'permission_denied' }),
93
+ );
94
+ });
95
+
96
+ test('Payout reports are not available for other organizations', async () => {
97
+ const otherOrganization = await new OrganizationFactory({}).create();
98
+
99
+ await expect(post(otherOrganization, adminToken)).rejects.toThrow(
100
+ STExpect.simpleError({ code: 'not_available' }),
101
+ );
102
+ });
103
+ });