@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.
- package/package.json +17 -17
- package/src/crons/index.ts +1 -0
- package/src/crons/stripe-payout-reports.ts +69 -0
- package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.test.ts +324 -2
- 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 +44 -3
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +61 -2
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +14 -1
- 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/AuthenticatedStructures.ts +5 -7
- 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/migrations/1783342463-remove-platform-uitpas-flag.ts +25 -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/searchUitpasEvents.ts +22 -18
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/src/sql-filters/organizations.ts +52 -0
- 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
|
@@ -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(/
|
|
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
|
-
|
|
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,
|
package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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
|
+
});
|