@stamhoofd/backend 2.36.2 → 2.38.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 (24) hide show
  1. package/package.json +10 -10
  2. package/src/endpoints/admin/memberships/ChargeMembershipsEndpoint.ts +1 -2
  3. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +8 -0
  4. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -3
  5. package/src/endpoints/auth/PatchUserEndpoint.ts +2 -2
  6. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +12 -0
  7. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -0
  8. package/src/endpoints/global/email/PatchEmailEndpoint.ts +6 -0
  9. package/src/endpoints/global/events/PatchEventsEndpoint.ts +1 -1
  10. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -10
  11. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +46 -24
  12. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +11 -4
  13. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +2 -0
  14. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +453 -0
  15. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +19 -2
  16. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +13 -2
  17. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +1 -1
  18. package/src/excel-loaders/members.ts +87 -24
  19. package/src/helpers/AddressValidator.ts +11 -0
  20. package/src/helpers/AdminPermissionChecker.ts +14 -1
  21. package/src/helpers/Context.ts +2 -2
  22. package/src/helpers/MembershipCharger.ts +84 -2
  23. package/src/helpers/fetchToAsyncIterator.ts +3 -4
  24. package/src/seeds/1726494420-update-cached-outstanding-balance-from-items.ts +40 -0
@@ -0,0 +1,453 @@
1
+ import { Request } from "@simonbackx/simple-endpoints";
2
+ import { Group, GroupFactory, MemberFactory, MemberWithRegistrations, Organization, OrganizationFactory, RegistrationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from "@stamhoofd/models";
3
+ import { GroupPrice, IDRegisterCart, IDRegisterCheckout, IDRegisterItem, PayconiqAccount, PaymentMethod, PermissionLevel, Permissions, Version } from "@stamhoofd/structures";
4
+ import nock from "nock";
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import { testServer } from "../../../../tests/helpers/TestServer";
7
+ import { RegisterMembersEndpoint } from "./RegisterMembersEndpoint";
8
+
9
+ const baseUrl = `/v${Version}/members/register`
10
+
11
+ describe("Endpoint.RegisterMembers", () => {
12
+ //#region global
13
+ const endpoint = new RegisterMembersEndpoint();
14
+ let period: RegistrationPeriod;
15
+ let organization: Organization;
16
+ let user: User;
17
+ let token: Token;
18
+ let member: MemberWithRegistrations;
19
+ let group1: Group;
20
+ let groupPrice1: GroupPrice;
21
+ let group2: Group;
22
+ let groupPrice2: GroupPrice;
23
+
24
+ //#region helpers
25
+ const post = async (body: IDRegisterCheckout) => {
26
+ const request = Request.buildJson("POST", baseUrl,organization.getApiHost(), body);
27
+ request.headers.authorization = "Bearer "+token.accessToken;
28
+ return await testServer.test(endpoint, request);
29
+ }
30
+ //#endregion
31
+
32
+ //#endregion
33
+
34
+ beforeAll(async () => {
35
+ period = await new RegistrationPeriodFactory({}).create();
36
+ organization = await new OrganizationFactory({ period }).create();
37
+ organization.meta.registrationPaymentConfiguration.paymentMethods = [PaymentMethod.PointOfSale, PaymentMethod.Payconiq];
38
+
39
+ organization.privateMeta.payconiqAccounts = [PayconiqAccount.create({
40
+ id: uuidv4(),
41
+ apiKey: 'test',
42
+ merchantId: 'test',
43
+ profileId: 'test',
44
+ name: 'test',
45
+ iban: 'BE56587127952688', // = random IBAN
46
+ callbackUrl: 'https://example.com'
47
+ })]
48
+
49
+ user = await new UserFactory({
50
+ organization,
51
+ permissions: Permissions.create({
52
+ level: PermissionLevel.Full
53
+ })
54
+ }).create();
55
+ token = await Token.createToken(user);
56
+ member = await new MemberFactory({ organization, user }).create();
57
+ });
58
+
59
+ beforeEach(async () => {
60
+ //#region groups
61
+ group1 = await new GroupFactory({
62
+ organization,
63
+ price: 25,
64
+ stock: 5
65
+ }).create();
66
+
67
+ groupPrice1 = group1.settings.prices[0];
68
+
69
+ group2 = await new GroupFactory({
70
+ organization,
71
+ price: 15,
72
+ stock: 4,
73
+ maxMembers: 1
74
+ }).create();
75
+
76
+ groupPrice2 = group2.settings.prices[0];
77
+ //#endregion
78
+ });
79
+
80
+ describe('Register member', () => {
81
+
82
+ test("Should update registered mmebers", async () => {
83
+ //#region arrange
84
+ const body = IDRegisterCheckout.create({
85
+ cart: IDRegisterCart.create({
86
+ items: [
87
+ IDRegisterItem.create({
88
+ id: uuidv4(),
89
+ replaceRegistrationIds: [],
90
+ options: [],
91
+ groupPrice: groupPrice1,
92
+ organizationId: organization.id,
93
+ groupId: group1.id,
94
+ memberId: member.id
95
+ })
96
+ ],
97
+ balanceItems: [],
98
+ deleteRegistrationIds: []
99
+ }),
100
+ administrationFee: 0,
101
+ freeContribution: 0,
102
+ paymentMethod: PaymentMethod.PointOfSale,
103
+ totalPrice: 25,
104
+ asOrganizationId: organization.id,
105
+ customer: null,
106
+ });
107
+ //#endregion
108
+
109
+ // act
110
+ const response = await post(body);
111
+
112
+ // assert
113
+ expect(response.body).toBeDefined();
114
+ expect(response.body.registrations.length).toBe(1);
115
+
116
+ const updatedGroup = await Group.getByID(group1.id);
117
+ expect(updatedGroup!.settings.registeredMembers).toBe(1);
118
+ expect(updatedGroup!.settings.reservedMembers).toBe(0);
119
+ })
120
+
121
+ test("Should update reserved members", async () => {
122
+ //#region arrange
123
+ const body = IDRegisterCheckout.create({
124
+ cart: IDRegisterCart.create({
125
+ items: [
126
+ IDRegisterItem.create({
127
+ id: uuidv4(),
128
+ replaceRegistrationIds: [],
129
+ options: [],
130
+ groupPrice: groupPrice2,
131
+ organizationId: organization.id,
132
+ groupId: group2.id,
133
+ memberId: member.id
134
+ })
135
+ ],
136
+ balanceItems: [],
137
+ deleteRegistrationIds: []
138
+ }),
139
+ administrationFee: 0,
140
+ freeContribution: 0,
141
+ paymentMethod: PaymentMethod.Payconiq,
142
+ redirectUrl: new URL("https://www.example.com"),
143
+ cancelUrl: new URL("https://www.example.com"),
144
+ totalPrice: 15,
145
+ customer: null,
146
+ });
147
+
148
+ nock('https://api.ext.payconiq.com')
149
+ .post('/v3/payments')
150
+ .reply(200, {
151
+ paymentId: 'testPaymentId',
152
+ _links: {
153
+ checkout: {
154
+ href: 'https://www.example.com'
155
+ }
156
+ }
157
+ });
158
+ //#endregion
159
+
160
+ // act
161
+ const response = await post(body);
162
+
163
+ // assert
164
+ expect(response.body).toBeDefined();
165
+ expect(response.body.registrations.length).toBe(1);
166
+
167
+ const updatedGroup = await Group.getByID(group2.id);
168
+ expect(updatedGroup!.settings.registeredMembers).toBe(0);
169
+ expect(updatedGroup!.settings.reservedMembers).toBe(1);
170
+ })
171
+ })
172
+
173
+ describe('Register member with replace registration', () => {
174
+
175
+ test("Should update registered members", async () => {
176
+ //#region arrange
177
+ const registration = await new RegistrationFactory({
178
+ member,
179
+ group: group1,
180
+ groupPrice: groupPrice1
181
+ })
182
+ .create();
183
+
184
+ const group = await new GroupFactory({
185
+ organization,
186
+ price: 30,
187
+ stock: 5
188
+ }).create();
189
+
190
+ const groupPrice = group.settings.prices[0];
191
+
192
+ const body = IDRegisterCheckout.create({
193
+ cart: IDRegisterCart.create({
194
+ items: [
195
+ IDRegisterItem.create({
196
+ id: uuidv4(),
197
+ replaceRegistrationIds: [registration.id],
198
+ options: [],
199
+ groupPrice,
200
+ organizationId: organization.id,
201
+ groupId: group.id,
202
+ memberId: member.id
203
+ })
204
+ ],
205
+ balanceItems: [],
206
+ deleteRegistrationIds: []
207
+ }),
208
+ administrationFee: 0,
209
+ freeContribution: 0,
210
+ paymentMethod: PaymentMethod.PointOfSale,
211
+ totalPrice: 5,
212
+ asOrganizationId: organization.id,
213
+ customer: null,
214
+ });
215
+ //#endregion
216
+
217
+ //#region act and assert
218
+
219
+ // update occupancy to be sure occupancy is 1
220
+ await group1.updateOccupancy();
221
+ expect(group1.settings.registeredMembers).toBe(1);
222
+
223
+ // send request and check occupancy
224
+ const response = await post(body);
225
+
226
+ expect(response.body).toBeDefined();
227
+ expect(response.body.registrations.length).toBe(1);
228
+
229
+ const updatedGroup = await Group.getByID(group.id);
230
+ expect(updatedGroup!.settings.registeredMembers).toBe(1);
231
+ expect(updatedGroup!.settings.reservedMembers).toBe(0);
232
+
233
+ const updatedGroup1After = await Group.getByID(group1.id);
234
+ // occupancy should go from 1 to 0 because the registration should be replaced
235
+ expect(updatedGroup1After!.settings.registeredMembers).toBe(0);
236
+ expect(updatedGroup1After!.settings.reservedMembers).toBe(0);
237
+ //#endregion
238
+ })
239
+
240
+ test("Should throw error if with payment", async () => {
241
+ //#region arrange
242
+ const registration = await new RegistrationFactory({
243
+ member,
244
+ group: group1,
245
+ groupPrice: groupPrice1
246
+ })
247
+ .create();
248
+
249
+ const group = await new GroupFactory({
250
+ organization,
251
+ price: 30,
252
+ stock: 5,
253
+ maxMembers: 1
254
+ }).create();
255
+
256
+ const groupPrice = group.settings.prices[0];
257
+
258
+ const body = IDRegisterCheckout.create({
259
+ cart: IDRegisterCart.create({
260
+ items: [
261
+ IDRegisterItem.create({
262
+ id: uuidv4(),
263
+ replaceRegistrationIds: [registration.id],
264
+ options: [],
265
+ groupPrice,
266
+ organizationId: organization.id,
267
+ groupId: group.id,
268
+ memberId: member.id
269
+ })
270
+ ],
271
+ balanceItems: [],
272
+ deleteRegistrationIds: []
273
+ }),
274
+ administrationFee: 0,
275
+ freeContribution: 0,
276
+ paymentMethod: PaymentMethod.Payconiq,
277
+ redirectUrl: new URL("https://www.example.com"),
278
+ cancelUrl: new URL("https://www.example.com"),
279
+ totalPrice: 5,
280
+ customer: null,
281
+ });
282
+ //#endregion
283
+
284
+ //#region act and assert
285
+
286
+ // update occupancy to be sure occupancy is 1
287
+ await group1.updateOccupancy();
288
+ expect(group1.settings.registeredMembers).toBe(1);
289
+
290
+ await expect(async () => await post(body)).rejects.toThrow("Not allowed to move registrations");
291
+ //#endregion
292
+ })
293
+ })
294
+
295
+ describe('Register member with delete registration', () => {
296
+
297
+ test("Should update registered members", async () => {
298
+ //#region arrange
299
+ const registration = await new RegistrationFactory({
300
+ member,
301
+ group: group1,
302
+ groupPrice: groupPrice1
303
+ })
304
+ .create();
305
+
306
+ const group = await new GroupFactory({
307
+ organization,
308
+ price: 30,
309
+ stock: 5
310
+ }).create();
311
+
312
+ const groupPrice = group.settings.prices[0];
313
+
314
+ const body = IDRegisterCheckout.create({
315
+ cart: IDRegisterCart.create({
316
+ items: [
317
+ IDRegisterItem.create({
318
+ id: uuidv4(),
319
+ replaceRegistrationIds: [],
320
+ options: [],
321
+ groupPrice,
322
+ organizationId: organization.id,
323
+ groupId: group.id,
324
+ memberId: member.id
325
+ })
326
+ ],
327
+ balanceItems: [],
328
+ deleteRegistrationIds: [registration.id]
329
+ }),
330
+ administrationFee: 0,
331
+ freeContribution: 0,
332
+ paymentMethod: PaymentMethod.PointOfSale,
333
+ totalPrice: 5,
334
+ asOrganizationId: organization.id,
335
+ customer: null,
336
+ });
337
+ //#endregion
338
+
339
+ //#region act and assert
340
+
341
+ // update occupancy to be sure occupancy is 1
342
+ await group1.updateOccupancy();
343
+ expect(group1.settings.registeredMembers).toBe(1);
344
+
345
+ // send request and check occupancy
346
+ const response = await post(body);
347
+
348
+ expect(response.body).toBeDefined();
349
+ expect(response.body.registrations.length).toBe(1);
350
+
351
+ const updatedGroup = await Group.getByID(group.id);
352
+ expect(updatedGroup!.settings.registeredMembers).toBe(1);
353
+ expect(updatedGroup!.settings.reservedMembers).toBe(0);
354
+
355
+ const updatedGroup1After = await Group.getByID(group1.id);
356
+ // occupancy should go from 1 to 0 because the registration should be deleted
357
+ expect(updatedGroup1After!.settings.registeredMembers).toBe(0);
358
+ expect(updatedGroup1After!.settings.reservedMembers).toBe(0);
359
+ //#endregion
360
+ })
361
+
362
+ test("Should throw error if with payment", async () => {
363
+ //#region arrange
364
+ const registration = await new RegistrationFactory({
365
+ member,
366
+ group: group1,
367
+ groupPrice: groupPrice1
368
+ })
369
+ .create();
370
+
371
+ const group = await new GroupFactory({
372
+ organization,
373
+ price: 30,
374
+ stock: 5,
375
+ maxMembers: 1
376
+ }).create();
377
+
378
+ const groupPrice = group.settings.prices[0];
379
+
380
+ const body = IDRegisterCheckout.create({
381
+ cart: IDRegisterCart.create({
382
+ items: [
383
+ IDRegisterItem.create({
384
+ id: uuidv4(),
385
+ replaceRegistrationIds: [],
386
+ options: [],
387
+ groupPrice,
388
+ organizationId: organization.id,
389
+ groupId: group.id,
390
+ memberId: member.id
391
+ })
392
+ ],
393
+ balanceItems: [],
394
+ deleteRegistrationIds: [registration.id]
395
+ }),
396
+ administrationFee: 0,
397
+ freeContribution: 0,
398
+ paymentMethod: PaymentMethod.Payconiq,
399
+ redirectUrl: new URL("https://www.example.com"),
400
+ cancelUrl: new URL("https://www.example.com"),
401
+ totalPrice: 5,
402
+ customer: null,
403
+ });
404
+ //#endregion
405
+
406
+ //#region act and assert
407
+
408
+ // update occupancy to be sure occupancy is 1
409
+ await group1.updateOccupancy();
410
+ expect(group1.settings.registeredMembers).toBe(1);
411
+
412
+ await expect(async () => await post(body)).rejects.toThrow("Permission denied: you are not allowed to delete registrations");
413
+ //#endregion
414
+ })
415
+ })
416
+
417
+ it('Register member that is already registered should throw error', async () => {
418
+ // create existing registration
419
+ await new RegistrationFactory({
420
+ member,
421
+ group: group1,
422
+ groupPrice: groupPrice1
423
+ })
424
+ .create();
425
+
426
+ // register again
427
+ const body = IDRegisterCheckout.create({
428
+ cart: IDRegisterCart.create({
429
+ items: [
430
+ IDRegisterItem.create({
431
+ id: uuidv4(),
432
+ replaceRegistrationIds: [],
433
+ options: [],
434
+ groupPrice: groupPrice1,
435
+ organizationId: organization.id,
436
+ groupId: group1.id,
437
+ memberId: member.id
438
+ })
439
+ ],
440
+ balanceItems: [],
441
+ deleteRegistrationIds: []
442
+ }),
443
+ administrationFee: 0,
444
+ freeContribution: 0,
445
+ paymentMethod: PaymentMethod.PointOfSale,
446
+ totalPrice: groupPrice1.price.price,
447
+ asOrganizationId: organization.id,
448
+ customer: null,
449
+ });
450
+
451
+ await expect(async () => await post(body)).rejects.toThrow("Already registered");
452
+ })
453
+ })
@@ -37,12 +37,22 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
37
37
  }
38
38
 
39
39
  async handle(request: DecodedRequest<Params, Query, Body>) {
40
- const organization = await Context.setOrganizationScope();
41
- const {user} = await Context.authenticate()
40
+ const organization = await Context.setOrganizationScope({allowInactive: true});
41
+ await Context.authenticate()
42
42
 
43
43
  if (!await Context.auth.hasSomeAccess(organization.id)) {
44
44
  throw Context.auth.error()
45
45
  }
46
+
47
+ if (!organization.active && !Context.auth.hasPlatformFullAccess()) {
48
+ throw new SimpleError({
49
+ code: "permission_denied",
50
+ message: "You do not have permissions to edit an inactive organization",
51
+ human: 'Je hebt geen toegangsrechten om een inactieve groep te bewerken',
52
+ statusCode: 403
53
+ })
54
+
55
+ }
46
56
 
47
57
  // check if organization ID matches
48
58
  if (request.body.id !== organization.id) {
@@ -295,6 +305,13 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
295
305
  }
296
306
  }
297
307
 
308
+ if (request.body.active !== undefined) {
309
+ if (!Context.auth.hasPlatformFullAccess()) {
310
+ throw Context.auth.error('Enkel een platform hoofdbeheerder kan een groep (in)actief maken')
311
+ }
312
+ organization.active = request.body.active;
313
+ }
314
+
298
315
  if (request.body.uri) {
299
316
  if (!Context.auth.hasPlatformFullAccess()) {
300
317
  throw Context.auth.error()
@@ -46,7 +46,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
46
46
  }
47
47
 
48
48
  async handle(request: DecodedRequest<Params, Query, Body>) {
49
- const organization = await Context.setOrganizationScope()
49
+ const organization = await Context.setOptionalOrganizationScope()
50
50
  if (!request.query.exchange) {
51
51
  await Context.authenticate()
52
52
  }
@@ -152,7 +152,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
152
152
  /**
153
153
  * ID of payment is needed because of race conditions (need to fetch payment in a race condition save queue)
154
154
  */
155
- static async pollStatus(paymentId: string, organization: Organization, cancel = false): Promise<Payment | undefined> {
155
+ static async pollStatus(paymentId: string, org: Organization|null, cancel = false): Promise<Payment | undefined> {
156
156
  // Prevent polling the same payment multiple times at the same time: create a queue to prevent races
157
157
  QueueHandler.cancel("payments/"+paymentId); // Prevent creating more than one queue item for the same payment
158
158
  return await QueueHandler.schedule("payments/"+paymentId, async () => {
@@ -162,6 +162,17 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
162
162
  return
163
163
  }
164
164
 
165
+ if (!payment.organizationId) {
166
+ console.error('Payment without organization not supported', payment.id)
167
+ return
168
+ }
169
+
170
+ const organization = org ?? await Organization.getByID(payment.organizationId)
171
+ if (!organization) {
172
+ console.error('Organization not found for payment', payment.id)
173
+ return
174
+ }
175
+
165
176
  const testMode = organization.privateMeta.useTestPayments ?? STAMHOOFD.environment != 'production'
166
177
 
167
178
  if (payment.status == PaymentStatus.Pending || payment.status == PaymentStatus.Created || (payment.provider === PaymentProvider.Buckaroo && payment.status == PaymentStatus.Failed)) {
@@ -26,7 +26,7 @@ export class GetPaymentEndpoint extends Endpoint<Params, Query, Body, ResponseBo
26
26
  }
27
27
 
28
28
  async handle(request: DecodedRequest<Params, Query, Body>) {
29
- await Context.setOrganizationScope()
29
+ await Context.setOptionalOrganizationScope()
30
30
  await Context.authenticate()
31
31
 
32
32
  const payment = await Payment.getByID(request.params.id);