@stamhoofd/backend 2.130.0 → 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 (35) 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/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  6. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  8. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  12. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  13. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  14. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  15. package/src/helpers/MembershipCharger.ts +26 -1
  16. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  17. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  18. package/src/helpers/StripePayoutExportData.ts +195 -0
  19. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  20. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  21. package/src/helpers/StripePayoutReporter.ts +585 -0
  22. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  23. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  24. package/src/services/uitpas/UitpasService.ts +1 -1
  25. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  26. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  27. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  28. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  29. package/src/services/uitpas/registerTicketSales.ts +1 -1
  30. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  31. package/tests/e2e/documents.test.ts +3 -1
  32. package/tests/vitest.global.setup.ts +1 -1
  33. package/tests/vitest.setup.ts +4 -2
  34. package/vitest.config.js +10 -0
  35. /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.130.0",
3
+ "version": "2.131.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -57,20 +57,20 @@
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.130.0",
61
- "@stamhoofd/backend-i18n": "2.130.0",
62
- "@stamhoofd/backend-middleware": "2.130.0",
63
- "@stamhoofd/crons": "2.130.0",
64
- "@stamhoofd/email": "2.130.0",
65
- "@stamhoofd/excel-writer": "2.130.0",
66
- "@stamhoofd/logging": "2.130.0",
67
- "@stamhoofd/models": "2.130.0",
68
- "@stamhoofd/object-differ": "2.130.0",
69
- "@stamhoofd/queues": "2.130.0",
70
- "@stamhoofd/sql": "2.130.0",
71
- "@stamhoofd/structures": "2.130.0",
72
- "@stamhoofd/types": "2.130.0",
73
- "@stamhoofd/utility": "2.130.0",
60
+ "@stamhoofd/backend-env": "2.131.0",
61
+ "@stamhoofd/backend-i18n": "2.131.0",
62
+ "@stamhoofd/backend-middleware": "2.131.0",
63
+ "@stamhoofd/crons": "2.131.0",
64
+ "@stamhoofd/email": "2.131.0",
65
+ "@stamhoofd/excel-writer": "2.131.0",
66
+ "@stamhoofd/logging": "2.131.0",
67
+ "@stamhoofd/models": "2.131.0",
68
+ "@stamhoofd/object-differ": "2.131.0",
69
+ "@stamhoofd/queues": "2.131.0",
70
+ "@stamhoofd/sql": "2.131.0",
71
+ "@stamhoofd/structures": "2.131.0",
72
+ "@stamhoofd/types": "2.131.0",
73
+ "@stamhoofd/utility": "2.131.0",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.130.0",
94
+ "@stamhoofd/test-utils": "2.131.0",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "b87db6d1a28c82fb12e588f899bcc7dc68d4cd90"
110
+ "gitHead": "51de524c831dac65960440d9a5aabe9cad1feae8"
111
111
  }
@@ -11,6 +11,7 @@ import './invoices.js';
11
11
  import './service-fees.js';
12
12
  import './members-fees.js';
13
13
  import './stripe-invoices.js';
14
+ import './stripe-payout-reports.js';
14
15
  import './transfer-fees.js';
15
16
  import './drip-emails.js';
16
17
  import './update-organization-future-events.js';
@@ -0,0 +1,69 @@
1
+ import { registerCron } from '@stamhoofd/crons';
2
+ import { Organization, Platform } from '@stamhoofd/models';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+ import { StripePayoutReporter } from '../helpers/StripePayoutReporter.js';
5
+
6
+ registerCron('stripe-payout-reports', createStripePayoutReports);
7
+
8
+ let lastStripeReport: Date | null = null;
9
+
10
+ /**
11
+ * Sends a monthly report of all Stripe payouts of the previous month, so we can check
12
+ * whether everything we charged via Stripe application fees was invoiced correctly.
13
+ */
14
+ async function createStripePayoutReports() {
15
+ if (STAMHOOFD.environment !== 'production') {
16
+ return;
17
+ }
18
+
19
+ if (STAMHOOFD.userMode === 'platform') {
20
+ return;
21
+ }
22
+
23
+ if (STAMHOOFD.STRIPE_CONNECT_METHOD === 'standard') {
24
+ return;
25
+ }
26
+
27
+ if (new Date().getHours() > 5 || new Date().getHours() < 2) {
28
+ // Fix for sending emails after deployments
29
+ return;
30
+ }
31
+
32
+ // Send the report on the 6th day of the month, once
33
+ const today = new Date();
34
+ if (today.getDate() !== 6 || (lastStripeReport && Formatter.dateWithoutDay(lastStripeReport) === Formatter.dateWithoutDay(today))) {
35
+ return;
36
+ }
37
+
38
+ if (!STAMHOOFD.STRIPE_SECRET_KEY) {
39
+ console.log('No stripe key set');
40
+ return;
41
+ }
42
+
43
+ const membershipOrganizationId = (await Platform.getShared()).membershipOrganizationId;
44
+ if (!membershipOrganizationId) {
45
+ return;
46
+ }
47
+
48
+ const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
49
+
50
+ console.log('Creating Stripe payout report...');
51
+
52
+ // Previous month
53
+ const previousMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
54
+ const end = new Date(new Date(today.getFullYear(), today.getMonth(), 1).getTime() - 1000);
55
+
56
+ const reporter = new StripePayoutReporter({
57
+ secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
58
+ sellingOrganization: membershipOrganization,
59
+ });
60
+
61
+ await reporter.build(previousMonthStart, end);
62
+ await reporter.sendEmail({
63
+ to: [{ email: 'simon@stamhoofd.be' }],
64
+ // Only send the report to the extra (accounting) recipients when it is complete and valid
65
+ extraRecipientsWhenValid: (STAMHOOFD.STRIPE_PAYOUT_REPORT_EMAILS ?? []).map(email => ({ email })),
66
+ });
67
+
68
+ lastStripeReport = new Date();
69
+ }
@@ -156,7 +156,9 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
156
156
  }).create();
157
157
 
158
158
  const memberToPatch = await new MemberFactory({
159
- firstName: 'otherName',
159
+ // A typo of the existing member's first name, so correcting it (below) is allowed
160
+ // and still triggers the duplicate detection
161
+ firstName: firstName + 'n',
160
162
  lastName,
161
163
  birthDay,
162
164
  generateData: true,
@@ -618,7 +620,8 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
618
620
  const patch = MemberWithRegistrationsBlob.patch({
619
621
  id: member.id,
620
622
  details: MemberDetails.patch({
621
- firstName: 'Changed',
623
+ // Changing the last name is always allowed; it proves the registration grants write access
624
+ lastName: 'Changed',
622
625
  }),
623
626
  });
624
627
  arr.addPatch(patch);
@@ -639,7 +642,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
639
642
  expect(response.body.members.length).toBe(1);
640
643
  const memberStruct = response.body.members[0];
641
644
  expect(memberStruct.details).toMatchObject({
642
- firstName: 'Changed',
645
+ lastName: 'Changed',
643
646
  });
644
647
  });
645
648
 
@@ -801,6 +804,74 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
801
804
  });
802
805
  });
803
806
 
807
+ describe('Name and birth date changes', () => {
808
+ test('A non-full admin cannot drastically change the first name of a member', async () => {
809
+ const organization = await new OrganizationFactory({}).create();
810
+
811
+ const group = await new GroupFactory({ organization }).create();
812
+ const resources = new Map();
813
+ resources.set(
814
+ PermissionsResourceType.Groups, new Map([[
815
+ group.id,
816
+ ResourcePermissions.create({ level: PermissionLevel.Write }),
817
+ ]]),
818
+ );
819
+
820
+ const user = await new UserFactory({
821
+ permissions: Permissions.create({ level: PermissionLevel.None, resources }),
822
+ organization,
823
+ }).create();
824
+
825
+ const member = await new MemberFactory({ firstName, lastName, birthDay, generateData: false }).create();
826
+ await new RegistrationFactory({ member, group }).create();
827
+
828
+ const token = await Token.createToken(user);
829
+
830
+ const arr: Body = new PatchableArray();
831
+ arr.addPatch(MemberWithRegistrationsBlob.patch({
832
+ id: member.id,
833
+ details: MemberDetails.patch({ firstName: 'Michael' }),
834
+ }));
835
+
836
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
837
+ request.headers.authorization = 'Bearer ' + token.accessToken;
838
+ await expect(testServer.test(endpoint, request))
839
+ .rejects
840
+ .toThrow(STExpect.errorWithCode('not_allowed'));
841
+ });
842
+
843
+ test('A full admin can drastically change the name and birth date of a member', async () => {
844
+ const organization = await new OrganizationFactory({}).create();
845
+
846
+ const user = await new UserFactory({
847
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
848
+ organization,
849
+ }).create();
850
+
851
+ const member = await new MemberFactory({ firstName, lastName, birthDay, generateData: false }).create();
852
+ await new RegistrationFactory({ member, organization }).create();
853
+
854
+ const token = await Token.createToken(user);
855
+
856
+ const base = member.details.birthDay!;
857
+ const newBirthDay = new Date(base.getFullYear() + 5, base.getMonth() + 4, base.getDate() + 6);
858
+
859
+ const arr: Body = new PatchableArray();
860
+ arr.addPatch(MemberWithRegistrationsBlob.patch({
861
+ id: member.id,
862
+ details: MemberDetails.patch({ firstName: 'Michael', birthDay: newBirthDay }),
863
+ }));
864
+
865
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
866
+ request.headers.authorization = 'Bearer ' + token.accessToken;
867
+ const response = await testServer.test(endpoint, request);
868
+
869
+ expect(response.status).toBe(200);
870
+ expect(response.body.members[0].details.firstName).toBe('Michael');
871
+ expect(response.body.members[0].details.birthDay?.getFullYear()).toBe(base.getFullYear() + 5);
872
+ });
873
+ });
874
+
804
875
  describe('Record answers', () => {
805
876
  test('An admin can set records of its own organization', async () => {
806
877
  const commentsRecord = RecordSettings.create({
@@ -23,6 +23,7 @@ import { MemberNumberService } from '../../../services/MemberNumberService.js';
23
23
  import { PlatformMembershipService } from '../../../services/PlatformMembershipService.js';
24
24
  import { RegistrationService } from '../../../services/RegistrationService.js';
25
25
  import { shouldCheckIfMemberIsDuplicateForPatch } from './shouldCheckIfMemberIsDuplicate.js';
26
+ import { throwIfDrasticMemberDetailsChange } from './throwIfDrasticMemberDetailsChange.js';
26
27
  import { mergeTwoMembers } from '../../../helpers/MemberMerger.js';
27
28
 
28
29
  type Params = Record<string, never>;
@@ -212,6 +213,11 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
212
213
 
213
214
  shouldCheckDuplicate = shouldCheckIfMemberIsDuplicateForPatch(patch, originalDetails);
214
215
 
216
+ // Only full-permission admins may drastically change the name or birth date of an existing member
217
+ if (!await Context.auth.canAccessMember(member, PermissionLevel.Full)) {
218
+ throwIfDrasticMemberDetailsChange(patch.details, originalDetails);
219
+ }
220
+
215
221
  const wasReduced = member.details.shouldApplyReducedPrice;
216
222
 
217
223
  const originalReviewTimes = member.details.reviewTimes;
@@ -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