@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.
- package/package.json +17 -17
- package/src/crons/index.ts +1 -0
- package/src/crons/stripe-payout-reports.ts +69 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
- package/src/helpers/MembershipCharger.ts +26 -1
- package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
- package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
- package/src/helpers/StripePayoutExportData.ts +195 -0
- package/src/helpers/StripePayoutExportExcel.ts +280 -0
- package/src/helpers/StripePayoutReporter.test.ts +419 -0
- package/src/helpers/StripePayoutReporter.ts +585 -0
- package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
- package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
- package/src/services/uitpas/UitpasService.ts +1 -1
- package/src/services/uitpas/cancelTicketSales.ts +1 -1
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
- package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
- package/src/services/uitpas/registerTicketSales.ts +1 -1
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/tests/e2e/documents.test.ts +3 -1
- package/tests/vitest.global.setup.ts +1 -1
- package/tests/vitest.setup.ts +4 -2
- package/vitest.config.js +10 -0
- /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.
|
|
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.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
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.
|
|
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": "
|
|
110
|
+
"gitHead": "51de524c831dac65960440d9a5aabe9cad1feae8"
|
|
111
111
|
}
|
package/src/crons/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|