@stamhoofd/backend 2.4.0 → 2.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -50,5 +50,5 @@
50
50
  "postmark": "4.0.2",
51
51
  "stripe": "^11.5.0"
52
52
  },
53
- "gitHead": "5313c328ca0e90544bf198bc6bebc90d7dced2aa"
53
+ "gitHead": "07cc26af0e3036e4ae4309a268fbf57b7f6ac2ed"
54
54
  }
@@ -169,7 +169,7 @@ export class GetInvoicesEndpoint extends Endpoint<Params, Query, Body, ResponseB
169
169
  STInvoicePrivate.create({
170
170
  ...invoice,
171
171
  payment: payment ? PaymentStruct.create(payment) : null,
172
- organization: organization ? (await organization.getStructure({emptyGroups: true})) : undefined,
172
+ organization: organization ? organization.getBaseStructure() : undefined,
173
173
  settlement: payment?.settlement ?? null,
174
174
  })
175
175
  )
@@ -106,16 +106,29 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
106
106
  duplicate.details.merge(member.details)
107
107
  member = duplicate
108
108
 
109
- // Only save after checking permissions
109
+ // You need write permissions, because a user can potentially earn write permissions on a member
110
+ // by registering it
111
+ if (!await Context.auth.canAccessMember(duplicate, PermissionLevel.Write)) {
112
+ throw new SimpleError({
113
+ code: "known_member_missing_rights",
114
+ message: "Creating known member without sufficient access rights",
115
+ human: "Dit lid is al bekend in het systeem, maar je hebt er geen toegang tot. Vraag iemand met de juiste toegangsrechten om dit lid voor jou toe te voegen, of vraag het lid om zelf in te schrijven via het ledenportaal.",
116
+ statusCode: 400
117
+ })
118
+ }
110
119
  }
111
120
 
112
121
  if (struct.registrations.length === 0) {
113
- throw new SimpleError({
114
- code: "missing_group",
115
- message: "Missing group",
116
- human: "Schrijf een nieuw lid altijd in voor minstens één groep",
117
- statusCode: 400
118
- })
122
+ // We risk creating a new member without being able to access it manually afterwards
123
+
124
+ if ((organization && !await Context.auth.hasFullAccess(organization.id)) || (!organization && !Context.auth.hasPlatformFullAccess())) {
125
+ throw new SimpleError({
126
+ code: "missing_group",
127
+ message: "Missing group",
128
+ human: "Je moet hoofdbeheerder zijn om een lid toe te voegen zonder inschrijving in het systeem",
129
+ statusCode: 400
130
+ })
131
+ }
119
132
  }
120
133
 
121
134
  // Throw early
@@ -223,7 +236,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
223
236
  // Update registrations
224
237
  for (const patchRegistration of patch.registrations.getPatches()) {
225
238
  const registration = member.registrations.find(r => r.id === patchRegistration.id)
226
- if (!registration || registration.memberId != member.id) {
239
+ if (!registration || registration.memberId != member.id || (!await Context.auth.canAccessRegistration(registration, PermissionLevel.Write))) {
227
240
  throw new SimpleError({
228
241
  code: "permission_denied",
229
242
  message: "You don't have permissions to access this endpoint",
@@ -4,6 +4,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import { Organization } from '@stamhoofd/models';
5
5
  import { Organization as OrganizationStruct } from "@stamhoofd/structures";
6
6
  import { GoogleTranslateHelper } from "@stamhoofd/utility";
7
+ import { AuthenticatedStructures } from "../../../helpers/AuthenticatedStructures";
7
8
  type Params = Record<string, never>;
8
9
 
9
10
  class Query extends AutoEncoder {
@@ -60,7 +61,7 @@ export class GetOrganizationFromDomainEndpoint extends Endpoint<Params, Query, B
60
61
  statusCode: 404
61
62
  })
62
63
  }
63
- return new Response(await organization.getStructure());
64
+ return new Response(await AuthenticatedStructures.organization(organization));
64
65
  }
65
66
  }
66
67
 
@@ -75,6 +76,6 @@ export class GetOrganizationFromDomainEndpoint extends Endpoint<Params, Query, B
75
76
  statusCode: 404
76
77
  })
77
78
  }
78
- return new Response(await organization.getStructure());
79
+ return new Response(await AuthenticatedStructures.organization(organization));
79
80
  }
80
81
  }
@@ -4,6 +4,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import { Organization } from '@stamhoofd/models';
5
5
  import { Organization as OrganizationStruct } from "@stamhoofd/structures";
6
6
  import { GoogleTranslateHelper } from "@stamhoofd/utility";
7
+ import { AuthenticatedStructures } from "../../../helpers/AuthenticatedStructures";
7
8
  type Params = Record<string, never>;
8
9
 
9
10
  class Query extends AutoEncoder {
@@ -44,6 +45,6 @@ export class GetOrganizationFromUriEndpoint extends Endpoint<Params, Query, Body
44
45
  statusCode: 404
45
46
  })
46
47
  }
47
- return new Response(await organization.getStructure());
48
+ return new Response(await AuthenticatedStructures.organization(organization));
48
49
  }
49
50
  }
@@ -2,6 +2,7 @@ import { AutoEncoder, Decoder, field, StringDecoder } from '@simonbackx/simple-e
2
2
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
3
  import { Organization } from "@stamhoofd/models";
4
4
  import { Organization as OrganizationStruct,OrganizationSimple } from "@stamhoofd/structures";
5
+ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
5
6
 
6
7
  type Params = Record<string, never>;
7
8
 
@@ -57,6 +58,6 @@ export class SearchOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
57
58
  if (request.request.getVersion() < 169) {
58
59
  return new Response(organizations.map(o => OrganizationSimple.create(o)));
59
60
  }
60
- return new Response(await Promise.all(organizations.map(o => o.getStructure())));
61
+ return new Response(await Promise.all(organizations.map(o => AuthenticatedStructures.organization(o))));
61
62
  }
62
63
  }
@@ -5,8 +5,8 @@ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { I18n } from '@stamhoofd/backend-i18n';
7
7
  import { Email } from '@stamhoofd/email';
8
- import { BalanceItem, BalanceItemPayment, Group, Member, MolliePayment, MollieToken, PayconiqPayment, Payment, Platform, RateLimiter, Registration } from '@stamhoofd/models';
9
- import { BalanceItemStatus, IDRegisterCheckout, MemberBalanceItem, MemberDetails, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PlatformFamily, RegisterItem, RegisterResponse, Version } from "@stamhoofd/structures";
8
+ import { BalanceItem, BalanceItemPayment, Group, Member, MemberWithRegistrations, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
9
+ import { BalanceItemStatus, IDRegisterCheckout, MemberBalanceItem, MemberDetails, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PermissionLevel, PlatformFamily, PlatformMember, RegisterItem, RegisterResponse, Version } from "@stamhoofd/structures";
10
10
  import { Formatter } from '@stamhoofd/utility';
11
11
 
12
12
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -66,6 +66,17 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
66
66
  const organization = await Context.setOrganizationScope();
67
67
  const {user} = await Context.authenticate()
68
68
 
69
+ if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
70
+ if (!await Context.auth.hasFullAccess(request.body.asOrganizationId)) {
71
+ throw new SimpleError({
72
+ code: "forbidden",
73
+ message: "No permission to register as this organization for a different organization",
74
+ human: 'Je hebt niet de juiste toegangsrechten om leden in te schrijven bij een andere organisatie.',
75
+ statusCode: 403
76
+ });
77
+ }
78
+ }
79
+
69
80
  // For non paid organizations, limit amount of tests
70
81
  if (!organization.meta.packages.isPaid) {
71
82
  const limiter = demoLimiter
@@ -88,12 +99,53 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
88
99
  }
89
100
  }
90
101
 
91
- const members = await Member.getMembersWithRegistrationForUser(user)
92
- const groups = await Group.getAll(organization.id, organization.periodId)
102
+ const memberIds = Formatter.uniqueArray(request.body.cart.items.map(i => i.memberId))
103
+ const members = await Member.getBlobByIds(...memberIds)
104
+ const groupIds = Formatter.uniqueArray(request.body.cart.items.map(i => i.groupId))
105
+ const groups = await Group.getByIDs(...groupIds)
106
+
107
+ for (const group of groups) {
108
+ if (group.organizationId !== organization.id) {
109
+ throw new SimpleError({
110
+ code: "invalid_data",
111
+ message: "Oeps, één of meerdere groepen waarin je probeert in te schrijven lijken niet meer te bestaan. Herlaad de pagina en probeer opnieuw."
112
+ })
113
+ }
114
+ }
115
+
116
+ for (const member of members) {
117
+ if (!await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
118
+ throw new SimpleError({
119
+ code: "forbidden",
120
+ message: "No permission to register this member",
121
+ human: 'Je hebt niet de juiste toegangsrechten om dit lid in te schrijven.',
122
+ statusCode: 403
123
+ });
124
+ }
125
+ }
93
126
 
94
127
  const blob = await AuthenticatedStructures.membersBlob(members, true)
95
- const family = PlatformFamily.create(blob, {platform: await Platform.getSharedStruct(), contextOrganization: await organization.getStructure()})
96
- const checkout = request.body.hydrate({family})
128
+ const platformMembers: PlatformMember[] = []
129
+
130
+ if (request.body.asOrganizationId) {
131
+ const _m = PlatformFamily.createSingles(blob, {
132
+ platform: await Platform.getSharedStruct(),
133
+ contextOrganization: await AuthenticatedStructures.organization(organization)
134
+ })
135
+ platformMembers.push(..._m)
136
+ } else {
137
+ const family = PlatformFamily.create(blob, {
138
+ platform: await Platform.getSharedStruct(),
139
+ contextOrganization: await AuthenticatedStructures.organization(organization)
140
+ })
141
+ platformMembers.push(...family.members)
142
+ }
143
+
144
+ const checkout = request.body.hydrate({
145
+ members: platformMembers,
146
+ groups: await AuthenticatedStructures.groups(groups),
147
+ organizations: [await AuthenticatedStructures.organization(organization)]
148
+ })
97
149
 
98
150
  const registrations: RegistrationWithMemberAndGroup[] = []
99
151
  const payRegistrations: {registration: RegistrationWithMemberAndGroup, item: RegisterItem}[] = []
@@ -108,9 +160,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
108
160
  // Update occupancies
109
161
  // TODO: might not be needed in the future (for performance)
110
162
  for (const group of groups) {
111
- if (request.body.cart.items.find(i => i.groupId == group.id)) {
112
- await group.updateOccupancy()
113
- }
163
+ await group.updateOccupancy()
114
164
  }
115
165
 
116
166
  // Validate balance items (can only happen serverside)
@@ -135,6 +185,13 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
135
185
  checkout.updatePrices()
136
186
 
137
187
  const totalPrice = checkout.totalPrice
188
+
189
+ if (totalPrice !== request.body.totalPrice) {
190
+ throw new SimpleError({
191
+ code: "changed_price",
192
+ message: "Oeps! De prijs is gewijzigd terwijl je aan het afrekenen was (naar "+Formatter.price(totalPrice)+"). Herlaad de pagina even om ervoor te zorgen dat je alle aangepaste prijzen ziet. Contacteer de webmaster als je dit probleem blijft ondervinden na het te herladen."
193
+ })
194
+ }
138
195
 
139
196
  if (totalPrice < 0) {
140
197
  throw new SimpleError({
@@ -146,7 +203,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
146
203
  const registrationMemberRelation = new ManyToOneRelation(Member, "member")
147
204
  registrationMemberRelation.foreignKey = "memberId"
148
205
 
149
- mainLoop: for (const item of checkout.cart.items) {
206
+ for (const item of checkout.cart.items) {
150
207
  const member = members.find(m => m.id == item.memberId)
151
208
  if (!member) {
152
209
  throw new SimpleError({
@@ -171,18 +228,13 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
171
228
  registration = existingRegistration
172
229
  .setRelation(registrationMemberRelation, member as Member)
173
230
  .setRelation(Registration.group, group)
231
+
174
232
 
175
- if (existingRegistration.waitingList && item.waitingList) {
176
- // already on waiting list, no need to repeat it
177
- // skip without error
178
- registrations.push(registration)
179
- continue mainLoop;
180
- }
181
-
182
- if (!existingRegistration.waitingList && existingRegistration.registeredAt !== null) {
183
- // already registered, no need to put it on the waiting list or register (and pay) again
184
- registrations.push(registration)
185
- continue mainLoop;
233
+ if (existingRegistration.registeredAt !== null) {
234
+ throw new SimpleError({
235
+ code: "already_registered",
236
+ message: "Dit lid is reeds ingeschreven. Herlaad de pagina en probeer opnieuw."
237
+ })
186
238
  }
187
239
  }
188
240
 
@@ -197,31 +249,29 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
197
249
  registration.memberId = member.id
198
250
  registration.groupId = group.id
199
251
  registration.cycle = group.cycle
252
+ registration.price = item.calculatedPrice
253
+
254
+ payRegistrations.push({
255
+ registration,
256
+ item
257
+ });
200
258
 
201
- if (item.waitingList) {
202
- registration.waitingList = true
203
- registration.canRegister = false
204
- registration.reservedUntil = null
205
- await registration.save()
206
- } else {
207
- if (registration.waitingList && registration.canRegister) {
208
- // Keep data: otherwise people cannot retry if the payment fails
209
- // We'll mark the registration as valid after the payment
210
- } else {
211
- registration.waitingList = false
212
- registration.canRegister = false
213
- }
214
- registration.price = item.calculatedPrice
215
- payRegistrations.push({
216
- registration,
217
- item
218
- });
219
- }
220
259
  registrations.push(registration)
221
260
  }
222
261
 
262
+ // Who is going to pay?
263
+ let whoWillPay: 'member'|'organization'|'nobody' = 'member' // if this is set to 'organization', there will also be created separate balance items so the member can pay back the paying organization
264
+
265
+ if (request.body.asOrganizationId && request.body.asOrganizationId === organization.id) {
266
+ // Will get added to the outstanding amount of the member
267
+ whoWillPay = 'nobody'
268
+ } else if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
269
+ // The organization will pay to the organizing organization, and it will get added to the outstanding amount of the member towards the paying organization
270
+ whoWillPay = 'organization'
271
+ }
272
+
223
273
  // Validate payment method
224
- if (totalPrice > 0) {
274
+ if (totalPrice > 0 && whoWillPay !== 'nobody') {
225
275
  const allowedPaymentMethods = organization.meta.registrationPaymentConfiguration.paymentMethods
226
276
 
227
277
  if (!checkout.paymentMethod || !allowedPaymentMethods.includes(checkout.paymentMethod)) {
@@ -230,116 +280,104 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
230
280
  message: "Oeps, je hebt geen geldige betaalmethode geselecteerd. Selecteer een betaalmethode en probeer opnieuw. Herlaad de pagina indien nodig."
231
281
  })
232
282
  }
233
- } else {
234
- checkout.paymentMethod = PaymentMethod.Unknown
235
- }
236
-
237
- const payment = new Payment()
238
- payment.userId = user.id
239
- payment.organizationId = organization.id
240
- payment.method = checkout.paymentMethod
241
- payment.status = PaymentStatus.Created
242
- payment.price = totalPrice
243
- payment.freeContribution = checkout.freeContribution
244
-
245
- if (payment.method == PaymentMethod.Transfer) {
246
- // remark: we cannot add the lastnames, these will get added in the frontend when it is decrypted
247
- payment.transferSettings = organization.mappedTransferSettings
248
283
 
249
- if (!payment.transferSettings.iban) {
284
+ if ((checkout.paymentMethod !== PaymentMethod.Transfer && checkout.paymentMethod !== PaymentMethod.PointOfSale) && (!request.body.redirectUrl || !request.body.cancelUrl)) {
250
285
  throw new SimpleError({
251
- code: "no_iban",
252
- message: "No IBAN",
253
- human: "Er is geen rekeningnummer ingesteld voor overschrijvingen. Contacteer de beheerder."
286
+ code: 'missing_fields',
287
+ message: 'redirectUrl or cancelUrl is missing and is required for non-zero online payments',
288
+ human: 'Er is iets mis. Contacteer de webmaster.'
254
289
  })
255
290
  }
256
-
257
- const m = [...payRegistrations.map(r => r.registration.member.details), ...memberBalanceItems.map(i => members.find(m => m.id === i.memberId)?.details).filter(n => n !== undefined)] as MemberDetails[]
258
- payment.generateDescription(
259
- organization,
260
- Formatter.groupNamesByFamily(m),
261
- {
262
- name: Formatter.groupNamesByFamily(m),
263
- naam: Formatter.groupNamesByFamily(m),
264
- email: user.email
265
- }
266
- )
267
- }
268
- payment.paidAt = null
269
-
270
- if (totalPrice == 0) {
271
- payment.status = PaymentStatus.Succeeded
272
- payment.method = PaymentMethod.Unknown
273
- payment.paidAt = new Date()
291
+ } else {
292
+ checkout.paymentMethod = PaymentMethod.Unknown
274
293
  }
275
294
 
276
- // Determine the payment provider
277
- // Throws if invalid
278
- const {provider, stripeAccount} = await organization.getPaymentProviderFor(payment.method, organization.privateMeta.registrationPaymentConfiguration)
279
- payment.provider = provider
280
- payment.stripeAccountId = stripeAccount?.id ?? null
295
+ console.log('Registering members using whoWillPay', whoWillPay, checkout.paymentMethod, totalPrice)
281
296
 
282
- await payment.save()
283
297
  const items: BalanceItem[] = []
284
- const itemPayments: (BalanceItemPayment & { balanceItem: BalanceItem })[] = []
298
+ const shouldMarkValid = whoWillPay === 'nobody' || checkout.paymentMethod === PaymentMethod.Transfer || checkout.paymentMethod === PaymentMethod.PointOfSale
285
299
 
286
300
  // Save registrations and add extra data if needed
287
301
  for (const bundle of payRegistrations) {
288
302
  const registration = bundle.registration;
289
303
 
290
- if (!registration.waitingList) {
291
- // Replaced with balance items
292
- // registration.paymentId = payment.id
304
+ registration.reservedUntil = null
293
305
 
294
- registration.reservedUntil = null
295
- registration.canRegister = false
296
-
297
- if (payment.method == PaymentMethod.Transfer || payment.method == PaymentMethod.PointOfSale || payment.status == PaymentStatus.Succeeded) {
298
- await registration.markValid()
299
- } else {
300
- // Reserve registration for 30 minutes (if needed)
301
- const group = groups.find(g => g.id === registration.groupId)
306
+ if (shouldMarkValid) {
307
+ await registration.markValid()
308
+ } else {
309
+ // Reserve registration for 30 minutes (if needed)
310
+ const group = groups.find(g => g.id === registration.groupId)
302
311
 
303
- if (group && group.settings.maxMembers !== null) {
304
- registration.reservedUntil = new Date(new Date().getTime() + 1000*60*30)
305
- }
306
- await registration.save()
312
+ if (group && group.settings.maxMembers !== null) {
313
+ registration.reservedUntil = new Date(new Date().getTime() + 1000*60*30)
307
314
  }
315
+ await registration.save()
316
+ }
317
+
318
+ if (bundle.item.calculatedPrice === 0) {
319
+ continue;
308
320
  }
309
-
310
- await registration.save()
311
321
 
312
322
  // Create balance item
313
323
  const balanceItem = new BalanceItem();
314
324
  balanceItem.registrationId = registration.id;
315
325
  balanceItem.price = bundle.item.calculatedPrice
316
326
  balanceItem.description = `Inschrijving ${registration.group.settings.name}`
317
- balanceItem.pricePaid = payment.status == PaymentStatus.Succeeded ? bundle.item.calculatedPrice : 0;
318
- balanceItem.memberId = registration.memberId;
319
- balanceItem.userId = user.id
327
+
328
+ // Who needs to receive this money?
320
329
  balanceItem.organizationId = organization.id;
321
- balanceItem.status = payment.status == PaymentStatus.Succeeded ? BalanceItemStatus.Paid : (payment.method == PaymentMethod.Transfer || payment.method == PaymentMethod.PointOfSale ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden);
322
- await balanceItem.save();
323
330
 
324
- // Create one balance item payment to pay it in one payment
325
- const balanceItemPayment = new BalanceItemPayment()
326
- balanceItemPayment.balanceItemId = balanceItem.id;
327
- balanceItemPayment.paymentId = payment.id;
328
- balanceItemPayment.organizationId = organization.id;
329
- balanceItemPayment.price = balanceItem.price;
330
- await balanceItemPayment.save();
331
+ // Who is responsible for payment?
332
+ let balanceItem2: BalanceItem | null = null
333
+ if (whoWillPay === 'organization' && request.body.asOrganizationId) {
334
+ // Create a separate balance item for this meber to pay back the paying organization
335
+ // this is not yet associated with a payment but will be added to the outstanding balance of the member
336
+
337
+ balanceItem.payingOrganizationId = request.body.asOrganizationId
338
+
339
+ balanceItem2 = new BalanceItem();
340
+
341
+ // NOTE: we don't connect the registrationId here
342
+ // because otherwise the total price and pricePaid for the registration would be incorrect
343
+ //balanceItem2.registrationId = registration.id;
344
+
345
+ balanceItem2.price = bundle.item.calculatedPrice
346
+ balanceItem2.description = `Inschrijving ${registration.group.settings.name}`
347
+
348
+ // Who needs to receive this money?
349
+ balanceItem2.organizationId = request.body.asOrganizationId;
350
+
351
+ // Who is responsible for payment?
352
+ balanceItem2.memberId = registration.memberId;
331
353
 
354
+ // If the paying organization hasn't paid yet, this should be hidden and move to pending as soon as the paying organization has paid
355
+ balanceItem2.status = shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden
356
+ await balanceItem2.save();
357
+
358
+ // do not add to items array because we don't want to add this to the payment if we create a payment
359
+ } else {
360
+ balanceItem.memberId = registration.memberId;
361
+ balanceItem.userId = user.id
362
+ }
363
+
364
+ balanceItem.status = shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden
365
+ balanceItem.pricePaid = 0
366
+
367
+ // Connect the 'pay back' balance item to this balance item. As soon as this balance item is paid, we'll mark the other one as pending so the outstanding balance for the member increases
368
+ balanceItem.dependingBalanceItemId = balanceItem2?.id ?? null
369
+
370
+ await balanceItem.save();
332
371
  items.push(balanceItem)
333
- itemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem))
334
372
  }
335
373
 
336
374
  const oldestMember = members.slice().sort((a, b) => b.details.defaultAge - a.details.defaultAge)[0]
337
- if (checkout.freeContribution) {
375
+ if (checkout.freeContribution && !request.body.asOrganizationId) {
338
376
  // Create balance item
339
377
  const balanceItem = new BalanceItem();
340
378
  balanceItem.price = checkout.freeContribution
341
379
  balanceItem.description = `Vrije bijdrage`
342
- balanceItem.pricePaid = payment.status == PaymentStatus.Succeeded ? balanceItem.price : 0;
380
+ balanceItem.pricePaid = 0;
343
381
  balanceItem.userId = user.id
344
382
  balanceItem.organizationId = organization.id;
345
383
 
@@ -348,67 +386,72 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
348
386
  if (oldestMember) {
349
387
  balanceItem.memberId = oldestMember.id;
350
388
  }
351
- balanceItem.status = payment.status == PaymentStatus.Succeeded ? BalanceItemStatus.Paid : (payment.method == PaymentMethod.Transfer || payment.method == PaymentMethod.PointOfSale ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden);
389
+ balanceItem.status = shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden
352
390
  await balanceItem.save();
353
-
354
- // Create one balance item payment to pay it in one payment
355
- const balanceItemPayment = new BalanceItemPayment()
356
- balanceItemPayment.balanceItemId = balanceItem.id;
357
- balanceItemPayment.paymentId = payment.id;
358
- balanceItemPayment.organizationId = organization.id;
359
- balanceItemPayment.price = balanceItem.price;
360
- await balanceItemPayment.save();
361
-
362
391
  items.push(balanceItem)
363
- itemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem))
364
392
  }
365
393
 
366
- if (checkout.administrationFee) {
394
+ if (checkout.administrationFee && whoWillPay !== 'nobody') {
367
395
  // Create balance item
368
396
  const balanceItem = new BalanceItem();
369
397
  balanceItem.price = checkout.administrationFee
370
398
  balanceItem.description = `Administratiekosten`
371
- balanceItem.pricePaid = payment.status == PaymentStatus.Succeeded ? balanceItem.price : 0;
372
- balanceItem.userId = user.id
399
+ balanceItem.pricePaid = 0;
373
400
  balanceItem.organizationId = organization.id;
374
401
 
375
- // Connect this to the oldest member
376
-
377
- if (oldestMember) {
378
- balanceItem.memberId = oldestMember.id;
402
+ if (request.body.asOrganizationId) {
403
+ balanceItem.payingOrganizationId = request.body.asOrganizationId
404
+ } else {
405
+ balanceItem.userId = user.id
406
+ // Connect this to the oldest member
407
+ if (oldestMember) {
408
+ balanceItem.memberId = oldestMember.id;
409
+ }
379
410
  }
380
- balanceItem.status = payment.status == PaymentStatus.Succeeded ? BalanceItemStatus.Paid : (payment.method == PaymentMethod.Transfer || payment.method == PaymentMethod.PointOfSale ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden);
381
- await balanceItem.save();
382
411
 
383
- // Create one balance item payment to pay it in one payment
384
- const balanceItemPayment = new BalanceItemPayment()
385
- balanceItemPayment.balanceItemId = balanceItem.id;
386
- balanceItemPayment.paymentId = payment.id;
387
- balanceItemPayment.organizationId = organization.id;
388
- balanceItemPayment.price = balanceItem.price;
389
- await balanceItemPayment.save();
412
+ balanceItem.status = shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden
413
+ await balanceItem.save();
390
414
 
391
415
  items.push(balanceItem)
392
- itemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem))
393
416
  }
394
417
 
395
- // Create a payment pending balance item
396
- for (const item of checkout.cart.balanceItems) {
397
- // Create one balance item payment to pay it in one payment
398
- const balanceItemPayment = new BalanceItemPayment()
399
- balanceItemPayment.balanceItemId = item.item.id;
400
- balanceItemPayment.paymentId = payment.id;
401
- balanceItemPayment.organizationId = organization.id;
402
- balanceItemPayment.price = item.price;
403
- await balanceItemPayment.save();
418
+ if (checkout.cart.balanceItems.length && whoWillPay === 'nobody') {
419
+ throw new Error('Not possible to pay balance items when whoWillPay is nobody')
420
+ }
421
+
422
+ let paymentUrl: string | null = null
423
+ let payment: Payment | null = null
424
+
425
+ if (whoWillPay !== 'nobody') {
426
+ const mappedBalanceItems = new Map<BalanceItem, number>()
404
427
 
405
- const balanceItem = balanceItems.find(i => i.id === item.item.id)
406
- if (!balanceItem) {
407
- throw new Error('Balance item not found')
428
+ for (const item of items) {
429
+ mappedBalanceItems.set(item, item.price)
430
+ }
431
+
432
+ for (const item of checkout.cart.balanceItems) {
433
+ const balanceItem = balanceItems.find(i => i.id === item.item.id)
434
+ if (!balanceItem) {
435
+ throw new Error('Balance item not found')
436
+ }
437
+ mappedBalanceItems.set(balanceItem, item.price)
438
+ items.push(balanceItem)
439
+ }
440
+
441
+ const response = await this.createPayment({
442
+ balanceItems: mappedBalanceItems,
443
+ organization,
444
+ user,
445
+ checkout: request.body,
446
+ members
447
+ })
448
+
449
+ if (response) {
450
+ paymentUrl = response.paymentUrl
451
+ payment = response.payment
408
452
  }
409
- itemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem))
410
453
  }
411
- items.push(...balanceItems)
454
+
412
455
  await ExchangePaymentEndpoint.updateOutstanding(items, organization.id)
413
456
 
414
457
  // Update occupancy
@@ -419,8 +462,126 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
419
462
  }
420
463
  }
421
464
 
422
- // Update balance items
465
+ return new Response(RegisterResponse.create({
466
+ payment: payment ? PaymentStruct.create(payment) : null,
467
+ members: await AuthenticatedStructures.membersBlob(members),
468
+ registrations: registrations.map(r => Member.getRegistrationWithMemberStructure(r)),
469
+ paymentUrl
470
+ }));
471
+ }
472
+
473
+ async createPayment({balanceItems, organization, user, checkout, members}: {balanceItems: Map<BalanceItem, number>, organization: Organization, user: User, checkout: IDRegisterCheckout, members: MemberWithRegistrations[]}) {
474
+ // Calculate total price to pay
475
+ let totalPrice = 0
476
+ const payMembers: MemberWithRegistrations[] = []
477
+
478
+ for (const [balanceItem, price] of balanceItems) {
479
+ if (organization.id !== balanceItem.organizationId) {
480
+ throw new Error('Unexpected balance item from other organization')
481
+ }
482
+
483
+ if (price < 0 || (price > 0 && price > balanceItem.price - balanceItem.pricePaid)) {
484
+ throw new SimpleError({
485
+ code: "invalid_data",
486
+ message: "Oeps, het bedrag dat je probeert te betalen is ongeldig. Herlaad de pagina en probeer opnieuw."
487
+ })
488
+ }
489
+ totalPrice += price
490
+
491
+ if (price > 0 && balanceItem.memberId) {
492
+ const member = members.find(m => m.id === balanceItem.memberId)
493
+ if (!member) {
494
+ throw new SimpleError({
495
+ code: "invalid_data",
496
+ message: "Oeps, het lid dat je probeert in te schrijven konden we niet meer terugvinden. Je herlaadt best even de pagina om opnieuw te proberen."
497
+ })
498
+ }
499
+ payMembers.push(member)
500
+ }
501
+ }
502
+
503
+ if (totalPrice < 0) {
504
+ throw new SimpleError({
505
+ code: "empty_data",
506
+ message: "Oeps! De totaalprijs is negatief."
507
+ })
508
+ }
509
+
510
+ if (totalPrice === 0) {
511
+ return;
512
+ }
513
+
514
+ if (!checkout.paymentMethod || checkout.paymentMethod === PaymentMethod.Unknown) {
515
+ throw new SimpleError({
516
+ code: "invalid_data",
517
+ message: "Oeps, je hebt geen betaalmethode geselecteerd. Selecteer een betaalmethode en probeer opnieuw."
518
+ })
519
+ }
520
+
521
+ const payment = new Payment()
522
+ payment.userId = user.id
523
+
524
+ // Who will receive this money?
525
+ payment.organizationId = organization.id
526
+
527
+ payment.method = checkout.paymentMethod
528
+ payment.status = PaymentStatus.Created
529
+ payment.price = totalPrice
530
+
423
531
  if (payment.method == PaymentMethod.Transfer) {
532
+ // remark: we cannot add the lastnames, these will get added in the frontend when it is decrypted
533
+ payment.transferSettings = organization.mappedTransferSettings
534
+
535
+ if (!payment.transferSettings.iban) {
536
+ throw new SimpleError({
537
+ code: "no_iban",
538
+ message: "No IBAN",
539
+ human: "Er is geen rekeningnummer ingesteld voor overschrijvingen. Contacteer de beheerder."
540
+ })
541
+ }
542
+
543
+ const m = payMembers.map(r => r.details)
544
+ payment.generateDescription(
545
+ organization,
546
+ Formatter.groupNamesByFamily(m),
547
+ {
548
+ name: Formatter.groupNamesByFamily(m),
549
+ naam: Formatter.groupNamesByFamily(m),
550
+ email: user.email
551
+ }
552
+ )
553
+ }
554
+ payment.paidAt = null
555
+
556
+ // Determine the payment provider
557
+ // Throws if invalid
558
+ const {provider, stripeAccount} = await organization.getPaymentProviderFor(payment.method, organization.privateMeta.registrationPaymentConfiguration)
559
+ payment.provider = provider
560
+ payment.stripeAccountId = stripeAccount?.id ?? null
561
+
562
+ await payment.save()
563
+
564
+ // Create balance item payments
565
+ const balanceItemPayments: (BalanceItemPayment & { balanceItem: BalanceItem })[] = []
566
+
567
+ for (const [balanceItem, price] of balanceItems) {
568
+ // Create one balance item payment to pay it in one payment
569
+ const balanceItemPayment = new BalanceItemPayment()
570
+ balanceItemPayment.balanceItemId = balanceItem.id;
571
+ balanceItemPayment.paymentId = payment.id;
572
+ balanceItemPayment.organizationId = organization.id;
573
+ balanceItemPayment.price = price;
574
+ await balanceItemPayment.save();
575
+
576
+ balanceItemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem))
577
+ }
578
+
579
+ const description = 'Inschrijving '+organization.name
580
+
581
+ let paymentUrl: string | null = null
582
+
583
+ // Update balance items
584
+ if (payment.method === PaymentMethod.Transfer) {
424
585
  // Send a small reminder email
425
586
  try {
426
587
  await Registration.sendTransferEmail(user, organization, payment)
@@ -428,13 +589,20 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
428
589
  console.error("Failed to send transfer email")
429
590
  console.error(e)
430
591
  }
431
- }
592
+ } else if (payment.method !== PaymentMethod.PointOfSale) {
593
+ if (!checkout.redirectUrl || !checkout.cancelUrl) {
594
+ throw new Error('Should have been caught earlier')
595
+ }
596
+
597
+ const _redirectUrl = new URL(checkout.redirectUrl)
598
+ _redirectUrl.searchParams.set('id', payment.id);
599
+
600
+ const _cancelUrl = new URL(checkout.cancelUrl)
601
+ _cancelUrl.searchParams.set('id', payment.id);
602
+
603
+ const redirectUrl = _redirectUrl.href
604
+ const cancelUrl = _cancelUrl.href
432
605
 
433
- let paymentUrl: string | null = null
434
- const description = 'Inschrijving '+organization.name
435
- if (payment.status != PaymentStatus.Succeeded) {
436
- const redirectUrl = "https://"+organization.getHost()+'/payment?id='+encodeURIComponent(payment.id)
437
- const cancelUrl = "https://"+organization.getHost()+'/payment?id='+encodeURIComponent(payment.id) + '&cancel=true'
438
606
  const webhookUrl = 'https://'+organization.getApiHost()+"/v"+Version+"/payments/"+encodeURIComponent(payment.id)+"?exchange=true"
439
607
 
440
608
  if (payment.provider === PaymentProvider.Stripe) {
@@ -449,11 +617,11 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
449
617
  user: user.id,
450
618
  payment: payment.id
451
619
  },
452
- i18n: request.i18n,
453
- lineItems: itemPayments,
620
+ i18n: Context.i18n,
621
+ lineItems: balanceItemPayments,
454
622
  organization,
455
623
  customer: {
456
- name: user.name ?? oldestMember?.details.name ?? 'Onbekend',
624
+ name: user.name ?? payMembers[0]?.details.name ?? 'Onbekend',
457
625
  email: user.email,
458
626
  }
459
627
  });
@@ -502,9 +670,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
502
670
  paymentUrl = await PayconiqPayment.createPayment(payment, organization, description, redirectUrl, webhookUrl)
503
671
  } else if (payment.provider == PaymentProvider.Buckaroo) {
504
672
  // Increase request timeout because buckaroo is super slow (in development)
505
- request.request.request?.setTimeout(60 * 1000)
673
+ Context.request.request?.setTimeout(60 * 1000)
506
674
  const buckaroo = new BuckarooHelper(organization.privateMeta?.buckarooSettings?.key ?? "", organization.privateMeta?.buckarooSettings?.secret ?? "", organization.privateMeta.useTestPayments ?? STAMHOOFD.environment != 'production')
507
- const ip = request.request.getIP()
675
+ const ip = Context.request.getIP()
508
676
  paymentUrl = await buckaroo.createPayment(payment, ip, description, redirectUrl, webhookUrl)
509
677
  await payment.save()
510
678
 
@@ -518,11 +686,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
518
686
  }
519
687
  }
520
688
 
521
- return new Response(RegisterResponse.create({
522
- payment: PaymentStruct.create(payment),
523
- members: await AuthenticatedStructures.membersBlob(members),
524
- registrations: registrations.map(r => Member.getRegistrationWithMemberStructure(r)),
689
+ return {
690
+ payment,
691
+ balanceItemPayments,
692
+ provider,
693
+ stripeAccount,
525
694
  paymentUrl
526
- }));
695
+ }
527
696
  }
528
697
  }
@@ -78,7 +78,7 @@ export class GetWebshopFromDomainEndpoint extends Endpoint<Params, Query, Body,
78
78
  }
79
79
 
80
80
  return new Response(GetWebshopFromDomainResult.create({
81
- organization: await organization.getStructure({emptyGroups: true}),
81
+ organization: organization.getBaseStructure(),
82
82
  webshop: WebshopStruct.create(webshop)
83
83
  }));
84
84
  }
@@ -113,14 +113,14 @@ export class GetWebshopFromDomainEndpoint extends Endpoint<Params, Query, Body,
113
113
  if (!webshop) {
114
114
  // Return organization, so we know the locale + can do some custom logic
115
115
  return new Response(GetWebshopFromDomainResult.create({
116
- organization: await organization.getStructure({emptyGroups: true}),
116
+ organization: organization.getBaseStructure(),
117
117
  webshop: null,
118
118
  webshops: []
119
119
  }));
120
120
  }
121
121
 
122
122
  return new Response(GetWebshopFromDomainResult.create({
123
- organization: await organization.getStructure({emptyGroups: true}),
123
+ organization: organization.getBaseStructure(),
124
124
  webshop: WebshopStruct.create(webshop)
125
125
  }));
126
126
  }
@@ -156,7 +156,7 @@ export class GetWebshopFromDomainEndpoint extends Endpoint<Params, Query, Body,
156
156
 
157
157
  // Return organization, and the known webshops on this domain
158
158
  return new Response(GetWebshopFromDomainResult.create({
159
- organization: await organization.getStructure({emptyGroups: true}),
159
+ organization: organization.getBaseStructure(),
160
160
  webshop: null,
161
161
  webshops: webshops.map(w => WebshopPreview.create(w)).filter(w => w.isClosed(0) === false).sort((a, b) => Sorter.byStringValue(a.meta.name, b.meta.name))
162
162
  }));
@@ -180,7 +180,7 @@ export class GetWebshopFromDomainEndpoint extends Endpoint<Params, Query, Body,
180
180
  }
181
181
 
182
182
  return new Response(GetWebshopFromDomainResult.create({
183
- organization: await organization.getStructure({emptyGroups: true}),
183
+ organization: organization.getBaseStructure(),
184
184
  webshop: WebshopStruct.create(webshop)
185
185
  }));
186
186
  }
@@ -1,10 +1,11 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
2
- import { Group as GroupStruct, GroupPrivateSettings, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, Version } from "@stamhoofd/structures";
2
+ import { Group as GroupStruct, GroupPrivateSettings, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, Version, GroupType } from "@stamhoofd/structures";
3
3
 
4
4
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from "@simonbackx/simple-encoding";
5
5
  import { Context } from "../../../../helpers/Context";
6
6
  import { Group, Member, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from "@stamhoofd/models";
7
7
  import { SimpleError } from "@simonbackx/simple-errors";
8
+ import { AuthenticatedStructures } from "../../../../helpers/AuthenticatedStructures";
8
9
 
9
10
  type Params = Record<string, never>;
10
11
  type Query = undefined;
@@ -33,13 +34,13 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
33
34
 
34
35
  async handle(request: DecodedRequest<Params, Query, Body>) {
35
36
  const organization = await Context.setOrganizationScope();
36
- const {user} = await Context.authenticate()
37
+ await Context.authenticate()
37
38
 
38
39
  if (!await Context.auth.hasFullAccess(organization.id)) {
39
40
  throw Context.auth.error()
40
41
  }
41
42
 
42
- const structs: OrganizationRegistrationPeriodStruct[] = [];
43
+ const periods: OrganizationRegistrationPeriod[] = [];
43
44
 
44
45
  for (const {put} of request.body.getPuts()) {
45
46
  if (!await Context.auth.hasFullAccess(organization.id)) {
@@ -70,7 +71,7 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
70
71
  // Delete unreachable categories first
71
72
  await organizationPeriod.cleanCategories(groups);
72
73
  await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
73
- structs.push(organizationPeriod.getPrivateStructure(period, groups));
74
+ periods.push(organizationPeriod);
74
75
  }
75
76
 
76
77
  for (const patch of request.body.getPatches()) {
@@ -137,22 +138,20 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
137
138
  await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(struct)
138
139
  }
139
140
 
140
- const period = await RegistrationPeriod.getByID(organizationPeriod.periodId);
141
- const groups = await Group.getAll(organization.id, organizationPeriod.periodId)
142
141
 
143
142
  if (deleteUnreachable) {
143
+ const groups = await Group.getAll(organization.id, organizationPeriod.periodId)
144
+
144
145
  // Delete unreachable categories first
145
146
  await organizationPeriod.cleanCategories(groups);
146
147
  await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
147
148
  }
148
149
 
149
- if (period) {
150
- structs.push(organizationPeriod.getPrivateStructure(period, groups));
151
- }
150
+ periods.push(organizationPeriod);
152
151
  }
153
152
 
154
153
  return new Response(
155
- structs
154
+ await AuthenticatedStructures.organizationRegistrationPeriods(periods),
156
155
  );
157
156
  }
158
157
 
@@ -223,10 +222,64 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
223
222
  if (struct.defaultAgeGroupId !== undefined) {
224
223
  model.defaultAgeGroupId = await this.validateDefaultGroupId(struct.defaultAgeGroupId)
225
224
  }
225
+
226
+ const patch = struct;
227
+ if (patch.waitingList !== undefined) {
228
+ if (patch.waitingList === null) {
229
+ // delete
230
+ if (model.waitingListId) {
231
+ // for now don't delete, as waiting lists can be shared between multiple groups
232
+ // await PatchOrganizationRegistrationPeriodsEndpoint.deleteGroup(model.waitingListId)
233
+ model.waitingListId = null;
234
+ }
235
+
236
+ } else if (patch.waitingList.isPatch()) {
237
+ if (!model.waitingListId) {
238
+ throw new SimpleError({
239
+ code: 'invalid_field',
240
+ field: 'waitingList',
241
+ message: 'Cannot patch waiting list before it is created'
242
+ })
243
+ }
244
+ patch.waitingList.id = model.waitingListId
245
+ patch.waitingList.type = GroupType.WaitingList
246
+ await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(patch.waitingList)
247
+ } else {
248
+ if (model.waitingListId) {
249
+ // for now don't delete, as waiting lists can be shared between multiple groups
250
+ // await PatchOrganizationRegistrationPeriodsEndpoint.deleteGroup(model.waitingListId)
251
+ model.waitingListId = null;
252
+ }
253
+ patch.waitingList.type = GroupType.WaitingList
254
+
255
+ const existing = await Group.getByID(patch.waitingList.id)
256
+ if (existing) {
257
+ if (existing.organizationId !== model.organizationId) {
258
+ throw new SimpleError({
259
+ code: 'invalid_field',
260
+ field: 'waitingList',
261
+ message: 'Waiting list group is already used in another organization'
262
+ })
263
+ }
264
+
265
+ model.waitingListId = existing.id
266
+ } else {
267
+ const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
268
+ patch.waitingList,
269
+ model.organizationId,
270
+ model.periodId
271
+ )
272
+ model.waitingListId = group.id
273
+ }
274
+ }
275
+ }
226
276
 
227
277
  await model.updateOccupancy()
228
278
  await model.save();
229
- Member.updateMembershipsForGroupId(model.id)
279
+
280
+ if (struct.deletedAt !== undefined || struct.defaultAgeGroupId !== undefined) {
281
+ Member.updateMembershipsForGroupId(model.id)
282
+ }
230
283
  }
231
284
 
232
285
 
@@ -278,6 +331,29 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
278
331
  })
279
332
  }
280
333
 
334
+ if (struct.waitingList) {
335
+ const existing = await Group.getByID(struct.waitingList.id)
336
+ if (existing) {
337
+ if (existing.organizationId !== model.organizationId) {
338
+ throw new SimpleError({
339
+ code: 'invalid_field',
340
+ field: 'waitingList',
341
+ message: 'Waiting list group is already used in another organization'
342
+ })
343
+ }
344
+
345
+ model.waitingListId = existing.id
346
+ } else {
347
+ struct.waitingList.type = GroupType.WaitingList
348
+ const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
349
+ struct.waitingList,
350
+ model.organizationId,
351
+ model.periodId
352
+ )
353
+ model.waitingListId = group.id
354
+ }
355
+ }
356
+
281
357
  await model.updateOccupancy()
282
358
  await model.save();
283
359
  return model;
@@ -101,8 +101,9 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
101
101
 
102
102
  // Prevent concurrency issues
103
103
  await QueueHandler.schedule("balance-item-update/"+organization.id, async () => {
104
+ const unloaded = (await BalanceItemPayment.where({paymentId: payment.id})).map(r => r.setRelation(BalanceItemPayment.payment, payment))
104
105
  const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
105
- (await BalanceItemPayment.where({paymentId: payment.id})).map(r => r.setRelation(BalanceItemPayment.payment, payment))
106
+ unloaded
106
107
  );
107
108
 
108
109
  for (const balanceItemPayment of balanceItemPayments) {
@@ -224,6 +224,12 @@ export class AdminPermissionChecker {
224
224
  return true
225
225
  }
226
226
 
227
+ if (member.registrations.length === 0 && permissionLevel !== PermissionLevel.Full && (this.organization && await this.hasFullAccess(this.organization.id, PermissionLevel.Full))) {
228
+ // Everyone with at least full access to at least one organization can access this member
229
+ // This allows organizations to register new members themselves
230
+ return true;
231
+ }
232
+
227
233
  for (const registration of member.registrations) {
228
234
  if (await this.canAccessRegistration(registration, permissionLevel)) {
229
235
  return true;
@@ -1,6 +1,7 @@
1
1
  import { SimpleError } from "@simonbackx/simple-errors";
2
2
  import { Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from "@stamhoofd/models";
3
3
  import { Event as EventStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberResponsibilityRecord as MemberResponsibilityRecordStruct, MemberWithRegistrationsBlob, MembersBlob, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateWebshop, User as UserStruct, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
+ import { OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, GroupCategory, GroupPrivateSettings, GroupSettings, GroupStatus, Group as GroupStruct, GroupType } from '@stamhoofd/structures';
4
5
 
5
6
  import { Context } from "./Context";
6
7
  import { Formatter } from "@stamhoofd/utility";
@@ -52,10 +53,71 @@ export class AuthenticatedStructures {
52
53
  }
53
54
 
54
55
  static async group(group: Group) {
55
- if (!await Context.optionalAuth?.canAccessGroup(group)) {
56
- return group.getStructure()
56
+ return (await this.groups([group]))[0]
57
+ }
58
+
59
+ static async groups(groups: Group[]) {
60
+ const waitingListIds = Formatter.uniqueArray(groups.map(g => g.waitingListId).filter(id => id !== null) as string[])
61
+ const waitingLists = waitingListIds.length > 0 ? await Group.getByIDs(...waitingListIds) : []
62
+
63
+ const structs: GroupStruct[] = []
64
+ for (const group of groups) {
65
+ const waitingList = waitingLists.find(g => g.id == group.waitingListId) ?? null
66
+ const waitingListStruct = waitingList ? GroupStruct.create(waitingList) : null
67
+ if (waitingList && waitingListStruct && !await Context.optionalAuth?.canAccessGroup(waitingList)) {
68
+ waitingListStruct.privateSettings = null;
69
+ }
70
+
71
+ const struct = GroupStruct.create({
72
+ ...group,
73
+ waitingList: waitingListStruct
74
+ })
75
+
76
+ if (!await Context.optionalAuth?.canAccessGroup(group)) {
77
+ struct.privateSettings = null;
78
+ }
79
+
80
+ structs.push(struct)
81
+ }
82
+
83
+ return structs;
84
+ }
85
+
86
+ static async organizationRegistrationPeriods(organizationRegistrationPeriods: OrganizationRegistrationPeriod[]) {
87
+ if (organizationRegistrationPeriods.length === 0) {
88
+ return [];
89
+ }
90
+
91
+ const periodIds = Formatter.uniqueArray(organizationRegistrationPeriods.map(p => p.periodId))
92
+ const periods = await RegistrationPeriod.getByIDs(...periodIds)
93
+
94
+ const groupIds = Formatter.uniqueArray(organizationRegistrationPeriods.flatMap(p => p.settings.categories.flatMap(c => c.groupIds)))
95
+ const groups = groupIds.length ? await Group.getByIDs(...groupIds) : []
96
+
97
+ const groupStructs = await this.groups(groups)
98
+
99
+ const structs: OrganizationRegistrationPeriodStruct[] = []
100
+ for (const organizationPeriod of organizationRegistrationPeriods) {
101
+ const period = periods.find(p => p.id == organizationPeriod.periodId) ?? null
102
+ if (!period) {
103
+ continue
104
+ }
105
+ const groupIds = Formatter.uniqueArray(organizationPeriod.settings.categories.flatMap(c => c.groupIds))
106
+
107
+ structs.push(
108
+ OrganizationRegistrationPeriodStruct.create({
109
+ ...organizationPeriod,
110
+ period: period.getStructure(),
111
+ groups: groupStructs.filter(gg => groupIds.includes(gg.id)).sort(GroupStruct.defaultSort)
112
+ })
113
+ )
57
114
  }
58
- return group.getPrivateStructure()
115
+
116
+ return structs
117
+ }
118
+
119
+ static async organizationRegistrationPeriod(organizationRegistrationPeriod: OrganizationRegistrationPeriod) {
120
+ return (await this.organizationRegistrationPeriods([organizationRegistrationPeriod]))[0]
59
121
  }
60
122
 
61
123
  static async webshop(webshop: Webshop) {
@@ -66,6 +128,8 @@ export class AuthenticatedStructures {
66
128
  }
67
129
 
68
130
  static async organization(organization: Organization): Promise<OrganizationStruct> {
131
+ const organizationPeriod = await organization.getPeriod()
132
+
69
133
  if (await Context.optionalAuth?.canAccessPrivateOrganizationData(organization)) {
70
134
  const webshops = await Webshop.where({ organizationId: organization.id }, { select: Webshop.selectColumnsWithout(undefined, "products", "categories")})
71
135
  const webshopStructures: WebshopPreview[] = []
@@ -77,35 +141,29 @@ export class AuthenticatedStructures {
77
141
  webshopStructures.push(WebshopPreview.create(w))
78
142
  }
79
143
 
80
- const {groups, organizationPeriod, period} = await organization.getPeriod({emptyGroups: false})
81
-
82
144
  return OrganizationStruct.create({
83
- id: organization.id,
84
- name: organization.name,
85
- meta: organization.meta,
86
- address: organization.address,
87
- registerDomain: organization.registerDomain,
88
- uri: organization.uri,
89
- website: organization.website,
145
+ ...organization.getBaseStructure(),
90
146
  privateMeta: organization.privateMeta,
91
147
  webshops: webshopStructures,
92
- createdAt: organization.createdAt,
93
- period: organizationPeriod.getPrivateStructure(period, groups)
148
+ period: await this.organizationRegistrationPeriod(organizationPeriod)
94
149
  })
95
150
  }
96
151
 
97
- return await organization.getStructure()
152
+ return OrganizationStruct.create({
153
+ ...organization.getBaseStructure(),
154
+ period: await this.organizationRegistrationPeriod(organizationPeriod)
155
+ })
98
156
  }
99
157
 
100
158
  static async adminOrganizations(organizations: Organization[]): Promise<OrganizationStruct[]> {
101
159
  const structs: OrganizationStruct[] = [];
102
160
 
103
161
  for (const organization of organizations) {
104
- const base = await organization.getStructure({emptyGroups: true})
162
+ const base = organization.getBaseStructure()
105
163
  structs.push(base)
106
164
  }
107
165
 
108
- return structs
166
+ return Promise.resolve(structs)
109
167
  }
110
168
 
111
169
  static async userWithMembers(user: User): Promise<UserWithMembers> {
@@ -114,7 +172,9 @@ export class AuthenticatedStructures {
114
172
  return UserWithMembers.create({
115
173
  ...user,
116
174
  hasAccount: user.hasAccount(),
117
- members: await this.membersBlob(members, false, user)
175
+
176
+ // Always include the current context organization - because it is possible we switch organization and we don't want to refetch every time
177
+ members: await this.membersBlob(members, true, user)
118
178
  })
119
179
  }
120
180
 
@@ -207,17 +267,19 @@ export class AuthenticatedStructures {
207
267
  // Load groups
208
268
  const groupIds = events.map(e => e.groupId).filter(id => id !== null) as string[]
209
269
  const groups = groupIds.length > 0 ? await Group.getByIDs(...groupIds) : []
270
+ const groupStructs = await this.groups(groups)
210
271
 
211
272
  const result: EventStruct[] = []
212
273
 
213
274
  for (const event of events) {
214
- const group = groups.find(g => g.id == event.groupId) ?? null
275
+ const group = groupStructs.find(g => g.id == event.groupId) ?? null
215
276
 
216
- if (group && await Context.auth.canAccessGroup(group)) {
217
- result.push(event.getPrivateStructure(group))
218
- } else {
219
- result.push(event.getStructure(group))
220
- }
277
+ const struct = EventStruct.create({
278
+ ...event,
279
+ group
280
+ })
281
+
282
+ result.push(struct)
221
283
  }
222
284
 
223
285
  return result
@@ -0,0 +1,57 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Member } from '@stamhoofd/models';
3
+
4
+ export default new Migration(async () => {
5
+ if (STAMHOOFD.environment == "test") {
6
+ console.log("skipped in tests")
7
+ return;
8
+ }
9
+
10
+ if(STAMHOOFD.userMode !== "platform") {
11
+ console.log("skipped seed update-membership because usermode not platform")
12
+ return;
13
+ }
14
+
15
+ process.stdout.write('\n');
16
+ let c = 0;
17
+ let id: string = '';
18
+
19
+ while(true) {
20
+ const rawMembers = await Member.where({
21
+ id: {
22
+ value: id,
23
+ sign: '>'
24
+ }
25
+ }, {limit: 100, sort: ['id']});
26
+
27
+ // const members = await Member.getByIDs(...rawMembers.map(m => m.id));
28
+
29
+ for (const member of rawMembers) {
30
+ const memberWithRegistrations = await Member.getWithRegistrations(member.id);
31
+ if(memberWithRegistrations) {
32
+ await memberWithRegistrations.updateMemberships();
33
+ await memberWithRegistrations.save();
34
+ } else {
35
+ throw new Error("Member with registrations not found: " + member.id);
36
+ }
37
+
38
+ c++;
39
+
40
+ if (c%1000 === 0) {
41
+ process.stdout.write('.');
42
+ }
43
+ if (c%10000 === 0) {
44
+ process.stdout.write('\n');
45
+ }
46
+ }
47
+
48
+ if (rawMembers.length === 0) {
49
+ break;
50
+ }
51
+
52
+ id = rawMembers[rawMembers.length - 1].id;
53
+ }
54
+
55
+ // Do something here
56
+ return Promise.resolve()
57
+ })