@stamhoofd/backend 2.5.0 → 2.6.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.5.0",
3
+ "version": "2.6.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -48,7 +48,7 @@
48
48
  "node-rsa": "1.1.1",
49
49
  "openid-client": "^5.4.0",
50
50
  "postmark": "4.0.2",
51
- "stripe": "^11.5.0"
51
+ "stripe": "^16.6.0"
52
52
  },
53
- "gitHead": "07cc26af0e3036e4ae4309a268fbf57b7f6ac2ed"
53
+ "gitHead": "7a3f9f6c08058dc8b671befbfad73184afdc6d7c"
54
54
  }
@@ -1,6 +1,6 @@
1
1
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, patchObject, StringDecoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
- import { Event, Organization, Platform, RegistrationPeriod } from '@stamhoofd/models';
3
+ import { Event, Group, Organization, Platform, RegistrationPeriod } from '@stamhoofd/models';
4
4
  import { Event as EventStruct, GroupType, PermissionLevel } from "@stamhoofd/structures";
5
5
 
6
6
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -80,6 +80,12 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
80
80
  event.startDate = put.startDate
81
81
  event.endDate = put.endDate
82
82
  event.meta = put.meta
83
+ event.typeId = await PatchEventsEndpoint.validateEventType(put.typeId)
84
+ await PatchEventsEndpoint.checkEventLimits(event)
85
+
86
+ if (!(await Context.auth.canAccessEvent(event, PermissionLevel.Full))) {
87
+ throw Context.auth.error()
88
+ }
83
89
 
84
90
  if (put.group) {
85
91
  const period = await RegistrationPeriod.getByDate(event.startDate)
@@ -99,14 +105,8 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
99
105
  put.group.organizationId,
100
106
  period.id
101
107
  )
108
+ await event.syncGroupRequirements(group)
102
109
  event.groupId = group.id
103
-
104
- }
105
- event.typeId = await PatchEventsEndpoint.validateEventType(put.typeId)
106
- await PatchEventsEndpoint.checkEventLimits(event)
107
-
108
- if (!(await Context.auth.canAccessEvent(event, PermissionLevel.Full))) {
109
- throw Context.auth.error()
110
110
  }
111
111
 
112
112
  await event.save()
@@ -130,7 +130,6 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
130
130
  event.endDate = patch.endDate ?? event.endDate
131
131
  event.meta = patchObject(event.meta, patch.meta)
132
132
 
133
-
134
133
  if (patch.organizationId !== undefined) {
135
134
  if (organization?.id && patch.organizationId !== organization.id) {
136
135
  throw new SimpleError({
@@ -210,6 +209,14 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
210
209
  }
211
210
 
212
211
  await event.save()
212
+
213
+ if (event.groupId) {
214
+ const group = await Group.getByID(event.groupId)
215
+ if (group) {
216
+ await event.syncGroupRequirements(group)
217
+ }
218
+ }
219
+
213
220
  events.push(event)
214
221
  }
215
222
 
@@ -157,6 +157,15 @@ const filterCompilers: SQLFilterDefinitions = {
157
157
  .where(
158
158
  SQL.column('memberId'),
159
159
  SQL.column('members', 'id'),
160
+ ).whereNot(
161
+ SQL.column('registeredAt'),
162
+ null,
163
+ ).where(
164
+ SQL.column('deactivatedAt'),
165
+ null,
166
+ ).where(
167
+ SQL.column('groups', 'deletedAt'),
168
+ null
160
169
  ),
161
170
  {
162
171
  ...registrationFilterCompilers,
@@ -245,9 +254,12 @@ const filterCompilers: SQLFilterDefinitions = {
245
254
  ).whereNot(
246
255
  SQL.column('registeredAt'),
247
256
  null,
248
- ).whereNot(
249
- SQL.column('groups', 'status'),
250
- GroupStatus.Archived
257
+ ).where(
258
+ SQL.column('deactivatedAt'),
259
+ null,
260
+ ).where(
261
+ SQL.column('groups', 'deletedAt'),
262
+ null
251
263
  ),
252
264
  registrationFilterCompilers
253
265
  ),
@@ -276,9 +288,9 @@ const filterCompilers: SQLFilterDefinitions = {
276
288
  ).whereNot(
277
289
  SQL.column('registeredAt'),
278
290
  null,
279
- ).whereNot(
280
- SQL.column('groups', 'status'),
281
- GroupStatus.Archived
291
+ ).where(
292
+ SQL.column('groups', 'deletedAt'),
293
+ null
282
294
  ),
283
295
  organizationFilterCompilers
284
296
  ),
@@ -18,6 +18,12 @@ class Body extends AutoEncoder {
18
18
  @field({ decoder: StringDecoder })
19
19
  id: string
20
20
 
21
+ /**
22
+ * Events for direct charges
23
+ */
24
+ @field({ decoder: StringDecoder, nullable: true, optional: true })
25
+ account: string|null = null
26
+
21
27
  @field({ decoder: AnyDecoder })
22
28
  data: any
23
29
  }
@@ -6,7 +6,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { I18n } from '@stamhoofd/backend-i18n';
7
7
  import { Email } from '@stamhoofd/email';
8
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";
9
+ import { BalanceItemStatus, IDRegisterCheckout, MemberBalanceItem, 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';
@@ -99,7 +99,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
99
99
  }
100
100
  }
101
101
 
102
- const memberIds = Formatter.uniqueArray(request.body.cart.items.map(i => i.memberId))
102
+ const memberIds = Formatter.uniqueArray(
103
+ [...request.body.cart.items.map(i => i.memberId), ...request.body.cart.deleteRegistrations.map(i => i.member.id)]
104
+ )
103
105
  const members = await Member.getBlobByIds(...memberIds)
104
106
  const groupIds = Formatter.uniqueArray(request.body.cart.items.map(i => i.groupId))
105
107
  const groups = await Group.getByIDs(...groupIds)
@@ -141,11 +143,19 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
141
143
  platformMembers.push(...family.members)
142
144
  }
143
145
 
146
+ const organizationStruct = await AuthenticatedStructures.organization(organization)
144
147
  const checkout = request.body.hydrate({
145
148
  members: platformMembers,
146
149
  groups: await AuthenticatedStructures.groups(groups),
147
- organizations: [await AuthenticatedStructures.organization(organization)]
150
+ organizations: [organizationStruct]
148
151
  })
152
+
153
+ // Set circular references
154
+ for (const member of platformMembers) {
155
+ member.family.checkout = checkout
156
+ }
157
+
158
+ checkout.setDefaultOrganization(organizationStruct)
149
159
 
150
160
  const registrations: RegistrationWithMemberAndGroup[] = []
151
161
  const payRegistrations: {registration: RegistrationWithMemberAndGroup, item: RegisterItem}[] = []
@@ -178,6 +188,8 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
178
188
  memberBalanceItems = await BalanceItem.getMemberStructure(balanceItems)
179
189
  }
180
190
 
191
+ console.log('isAdminFromSameOrganization', checkout.isAdminFromSameOrganization)
192
+
181
193
  // Validate the cart
182
194
  checkout.validate({memberBalanceItems})
183
195
 
@@ -230,7 +242,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
230
242
  .setRelation(Registration.group, group)
231
243
 
232
244
 
233
- if (existingRegistration.registeredAt !== null) {
245
+ if (existingRegistration.registeredAt !== null && existingRegistration.deactivatedAt === null) {
234
246
  throw new SimpleError({
235
247
  code: "already_registered",
236
248
  message: "Dit lid is reeds ingeschreven. Herlaad de pagina en probeer opnieuw."
@@ -260,18 +272,18 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
260
272
  }
261
273
 
262
274
  // 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
275
+ let whoWillPayNow: '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
276
 
265
277
  if (request.body.asOrganizationId && request.body.asOrganizationId === organization.id) {
266
278
  // Will get added to the outstanding amount of the member
267
- whoWillPay = 'nobody'
279
+ whoWillPayNow = 'nobody'
268
280
  } else if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
269
281
  // 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'
282
+ whoWillPayNow = 'organization'
271
283
  }
272
284
 
273
285
  // Validate payment method
274
- if (totalPrice > 0 && whoWillPay !== 'nobody') {
286
+ if (totalPrice > 0 && whoWillPayNow !== 'nobody') {
275
287
  const allowedPaymentMethods = organization.meta.registrationPaymentConfiguration.paymentMethods
276
288
 
277
289
  if (!checkout.paymentMethod || !allowedPaymentMethods.includes(checkout.paymentMethod)) {
@@ -292,10 +304,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
292
304
  checkout.paymentMethod = PaymentMethod.Unknown
293
305
  }
294
306
 
295
- console.log('Registering members using whoWillPay', whoWillPay, checkout.paymentMethod, totalPrice)
307
+ console.log('Registering members using whoWillPayNow', whoWillPayNow, checkout.paymentMethod, totalPrice)
296
308
 
297
309
  const items: BalanceItem[] = []
298
- const shouldMarkValid = whoWillPay === 'nobody' || checkout.paymentMethod === PaymentMethod.Transfer || checkout.paymentMethod === PaymentMethod.PointOfSale
310
+ const shouldMarkValid = whoWillPayNow === 'nobody' || checkout.paymentMethod === PaymentMethod.Transfer || checkout.paymentMethod === PaymentMethod.PointOfSale
299
311
 
300
312
  // Save registrations and add extra data if needed
301
313
  for (const bundle of payRegistrations) {
@@ -330,7 +342,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
330
342
 
331
343
  // Who is responsible for payment?
332
344
  let balanceItem2: BalanceItem | null = null
333
- if (whoWillPay === 'organization' && request.body.asOrganizationId) {
345
+ if (whoWillPayNow === 'organization' && request.body.asOrganizationId) {
334
346
  // Create a separate balance item for this meber to pay back the paying organization
335
347
  // this is not yet associated with a payment but will be added to the outstanding balance of the member
336
348
 
@@ -391,7 +403,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
391
403
  items.push(balanceItem)
392
404
  }
393
405
 
394
- if (checkout.administrationFee && whoWillPay !== 'nobody') {
406
+ if (checkout.administrationFee && whoWillPayNow !== 'nobody') {
395
407
  // Create balance item
396
408
  const balanceItem = new BalanceItem();
397
409
  balanceItem.price = checkout.administrationFee
@@ -415,14 +427,67 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
415
427
  items.push(balanceItem)
416
428
  }
417
429
 
418
- if (checkout.cart.balanceItems.length && whoWillPay === 'nobody') {
419
- throw new Error('Not possible to pay balance items when whoWillPay is nobody')
430
+ if (checkout.cart.balanceItems.length && whoWillPayNow === 'nobody') {
431
+ throw new Error('Not possible to pay balance items when whoWillPayNow is nobody')
432
+ }
433
+
434
+ // Create negative balance items
435
+ for (const registrationStruct of checkout.cart.deleteRegistrations) {
436
+ if (whoWillPayNow !== 'nobody') {
437
+ // this also fixes the issue that we cannot delete the registration right away if we would need to wait for a payment
438
+ throw new SimpleError({
439
+ code: "forbidden",
440
+ message: "Permission denied: you are not allowed to delete registrations",
441
+ human: "Oeps, je hebt geen toestemming om inschrijvingen te verwijderen.",
442
+ statusCode: 403
443
+ })
444
+ }
445
+
446
+ const existingRegistration = await Registration.getByID(registrationStruct.id)
447
+ if (!existingRegistration || existingRegistration.organizationId !== organization.id) {
448
+ throw new SimpleError({
449
+ code: "invalid_data",
450
+ message: "Oeps, één of meerdere inschrijvingen die je probeert te verwijderen lijken niet meer te bestaan. Herlaad de pagina en probeer opnieuw."
451
+ })
452
+ }
453
+
454
+ if (!await Context.auth.canAccessRegistration(existingRegistration, PermissionLevel.Write)) {
455
+ throw new SimpleError({
456
+ code: "forbidden",
457
+ message: "Je hebt geen toegaansrechten om deze inschrijving te verwijderen.",
458
+ statusCode: 403
459
+ })
460
+ }
461
+
462
+ if (existingRegistration.deactivatedAt || !existingRegistration.registeredAt) {
463
+ throw new SimpleError({
464
+ code: "invalid_data",
465
+ message: "Oeps, één of meerdere inschrijvingen die je probeert te verwijderen was al verwijderd. Herlaad de pagina en probeer opnieuw."
466
+ })
467
+ }
468
+
469
+ // We can alter right away since whoWillPayNow is nobody, and shouldMarkValid will always be true
470
+ // Find all balance items of this registration and set them to zero
471
+ await BalanceItem.deleteForDeletedRegistration(existingRegistration.id)
472
+
473
+ // Clear the registration
474
+ existingRegistration.deactivatedAt = new Date()
475
+ await existingRegistration.save()
476
+ existingRegistration.scheduleStockUpdate()
477
+
478
+ const group = groups.find(g => g.id === existingRegistration.groupId)
479
+ if (!group) {
480
+ const g = await Group.getByID(existingRegistration.groupId)
481
+ if (g) {
482
+ groups.push(g)
483
+ }
484
+ }
420
485
  }
421
486
 
422
487
  let paymentUrl: string | null = null
423
488
  let payment: Payment | null = null
424
489
 
425
- if (whoWillPay !== 'nobody') {
490
+ if (whoWillPayNow !== 'nobody') {
426
491
  const mappedBalanceItems = new Map<BalanceItem, number>()
427
492
 
428
493
  for (const item of items) {
@@ -452,11 +517,11 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
452
517
  }
453
518
  }
454
519
 
455
- await ExchangePaymentEndpoint.updateOutstanding(items, organization.id)
520
+ await BalanceItem.updateOutstanding(items, organization.id)
456
521
 
457
522
  // Update occupancy
458
523
  for (const group of groups) {
459
- if (registrations.find(p => p.groupId === group.id)) {
524
+ if (registrations.find(p => p.groupId === group.id) || checkout.cart.deleteRegistrations.find(p => p.groupId === group.id)) {
460
525
  await group.updateOccupancy()
461
526
  await group.save()
462
527
  }
@@ -501,10 +566,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
501
566
  }
502
567
 
503
568
  if (totalPrice < 0) {
504
- throw new SimpleError({
505
- code: "empty_data",
506
- message: "Oeps! De totaalprijs is negatief."
507
- })
569
+ // No payment needed: the outstanding balance will be negative and can be used in the future
570
+ return;
571
+ // throw new SimpleError({
572
+ // code: "empty_data",
573
+ // message: "Oeps! De totaalprijs is negatief."
574
+ // })
508
575
  }
509
576
 
510
577
  if (totalPrice === 0) {
@@ -595,11 +662,14 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
595
662
  }
596
663
 
597
664
  const _redirectUrl = new URL(checkout.redirectUrl)
598
- _redirectUrl.searchParams.set('id', payment.id);
599
-
665
+ _redirectUrl.searchParams.set('paymentId', payment.id);
666
+ _redirectUrl.searchParams.set('organizationId', organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
667
+
600
668
  const _cancelUrl = new URL(checkout.cancelUrl)
601
- _cancelUrl.searchParams.set('id', payment.id);
602
-
669
+ _cancelUrl.searchParams.set('paymentId', payment.id);
670
+ _cancelUrl.searchParams.set('cancel', 'true');
671
+ _cancelUrl.searchParams.set('organizationId', organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
672
+
603
673
  const redirectUrl = _redirectUrl.href
604
674
  const cancelUrl = _cancelUrl.href
605
675
 
@@ -132,6 +132,7 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
132
132
 
133
133
  for (const groupPut of patch.groups.getPuts()) {
134
134
  await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(groupPut.put, organization.id, organizationPeriod.periodId, {allowedIds})
135
+ deleteUnreachable = true
135
136
  }
136
137
 
137
138
  for (const struct of patch.groups.getPatches()) {
@@ -46,17 +46,22 @@ export class ConnectMollieEndpoint extends Endpoint<Params, Query, Body, Respons
46
46
  })
47
47
  }
48
48
 
49
- const type = 'express'
49
+ const type = STAMHOOFD.STRIPE_CONNECT_METHOD
50
50
 
51
- let expressData: Stripe.AccountCreateParams = {
52
- country: organization.address.country,
53
- // Problem: we cannot set company or business_type, because then it defaults the structure of the company to one that requires a company number
51
+ const sharedData: Stripe.AccountCreateParams = {
54
52
  capabilities: {
55
53
  card_payments: { requested: true },
56
54
  transfers: { requested: true },
57
55
  bancontact_payments: { requested: true },
58
56
  ideal_payments: { requested: true },
59
57
  },
58
+ }
59
+
60
+ let expressData: Stripe.AccountCreateParams = {
61
+ country: organization.address.country,
62
+ controller: {
63
+ requirement_collection: 'application',
64
+ },
60
65
  settings: {
61
66
  payouts: {
62
67
  schedule: {
@@ -76,6 +81,7 @@ export class ConnectMollieEndpoint extends Endpoint<Params, Query, Body, Respons
76
81
  const stripe = StripeHelper.getInstance()
77
82
  const account = await stripe.accounts.create({
78
83
  type,
84
+ ...sharedData,
79
85
  ...expressData
80
86
  });
81
87
 
@@ -68,7 +68,10 @@ export class GetStripeAccountLinkEndpoint extends Endpoint<Params, Query, Body,
68
68
  refresh_url: request.body.refreshUrl,
69
69
  return_url: request.body.returnUrl,
70
70
  type: 'account_onboarding',
71
- collect: model.meta.type === 'express' ? 'eventually_due' : undefined, // Collect all at the start
71
+ collection_options: {
72
+ fields: 'eventually_due',
73
+ future_requirements: 'include'
74
+ }
72
75
  });
73
76
 
74
77
  return new Response(ResponseBody.create({
@@ -59,6 +59,12 @@ export class GetStripeLoginLinkEndpoint extends Endpoint<Params, Query, Body, Re
59
59
  })
60
60
  }
61
61
 
62
+ if (model.meta.type === 'standard') {
63
+ return new Response(ResponseBody.create({
64
+ url: 'https://dashboard.stripe.com/'
65
+ }));
66
+ }
67
+
62
68
  const stripe = StripeHelper.getInstance()
63
69
  const accountLink = await stripe.accounts.createLoginLink(model.accountId);
64
70
 
@@ -243,13 +243,18 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
243
243
  // Update balance item prices for this order if price has changed
244
244
  if (previousToPay !== model.totalToPay) {
245
245
  const items = await BalanceItem.where({ orderId: model.id })
246
- if (items.length === 1) {
246
+ if (items.length >= 1) {
247
247
  model.markUpdated()
248
248
  items[0].price = model.totalToPay
249
249
  items[0].description = model.generateBalanceDescription(webshop)
250
250
  items[0].updateStatus();
251
251
  await items[0].save()
252
- } else if (items.length === 0 && model.totalToPay > 0) {
252
+
253
+ // Zero out the other items
254
+ const otherItems = items.slice(1)
255
+ await BalanceItem.deleteItems(otherItems)
256
+ } else if (items.length === 0
257
+ && model.totalToPay > 0) {
253
258
  model.markUpdated()
254
259
  const balanceItem = new BalanceItem();
255
260
  balanceItem.orderId = model.id;
@@ -2,11 +2,11 @@ import { createMollieClient } from '@mollie/api-client';
2
2
  import { AutoEncoder, BooleanDecoder, Decoder, field } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
4
4
  import { SimpleError } from "@simonbackx/simple-errors";
5
- import { BalanceItem, BalanceItemPayment, Member, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, Registration, STPendingInvoice } from '@stamhoofd/models';
5
+ import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, STPendingInvoice } from '@stamhoofd/models';
6
6
  import { QueueHandler } from '@stamhoofd/queues';
7
- import { Payment as PaymentStruct, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, STInvoiceItem } from "@stamhoofd/structures";
8
- import { Formatter } from '@stamhoofd/utility';
7
+ import { PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, STInvoiceItem } from "@stamhoofd/structures";
9
8
 
9
+ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
10
10
  import { BuckarooHelper } from '../../../helpers/BuckarooHelper';
11
11
  import { Context } from '../../../helpers/Context';
12
12
  import { StripeHelper } from '../../../helpers/StripeHelper';
@@ -27,7 +27,7 @@ class Query extends AutoEncoder {
27
27
  cancel = false
28
28
  }
29
29
  type Body = undefined
30
- type ResponseBody = PaymentStruct | undefined;
30
+ type ResponseBody = PaymentGeneral | undefined
31
31
 
32
32
  /**
33
33
  * One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
@@ -51,6 +51,9 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
51
51
 
52
52
  async handle(request: DecodedRequest<Params, Query, Body>) {
53
53
  const organization = await Context.setOrganizationScope()
54
+ if (!request.query.exchange) {
55
+ await Context.authenticate()
56
+ }
54
57
 
55
58
  // Not method on payment because circular references (not supprted in ts)
56
59
  const payment = await ExchangePaymentEndpoint.pollStatus(request.params.id, organization, request.query.cancel)
@@ -66,28 +69,9 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
66
69
  }
67
70
 
68
71
  return new Response(
69
- PaymentStruct.create({
70
- id: payment.id,
71
- method: payment.method,
72
- provider: payment.provider,
73
- status: payment.status,
74
- price: payment.price,
75
- transferDescription: payment.transferDescription,
76
- paidAt: payment.paidAt,
77
- createdAt: payment.createdAt,
78
- updatedAt: payment.updatedAt
79
- })
72
+ await AuthenticatedStructures.paymentGeneral(payment, true)
80
73
  );
81
74
  }
82
-
83
- static async updateOutstanding(items: BalanceItem[], organizationId: string) {
84
- // Update outstanding amount of related members and registrations
85
- const memberIds: string[] = Formatter.uniqueArray(items.map(p => p.memberId).filter(id => id !== null)) as any
86
- await Member.updateOutstandingBalance(memberIds)
87
-
88
- const registrationIds: string[] = Formatter.uniqueArray(items.map(p => p.registrationId).filter(id => id !== null)) as any
89
- await Registration.updateOutstandingBalance(registrationIds, organizationId)
90
- }
91
75
 
92
76
  static async handlePaymentStatusUpdate(payment: Payment, organization: Organization, status: PaymentStatus) {
93
77
  if (payment.status === status) {
@@ -110,7 +94,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
110
94
  await balanceItemPayment.markPaid(organization);
111
95
  }
112
96
 
113
- await this.updateOutstanding(balanceItemPayments.map(p => p.balanceItem), organization.id)
97
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem), organization.id)
114
98
  })
115
99
 
116
100
  if (!wasPaid && payment.provider === PaymentProvider.Buckaroo && payment.method) {
@@ -153,7 +137,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
153
137
  await balanceItemPayment.undoPaid(organization);
154
138
  }
155
139
 
156
- await this.updateOutstanding(balanceItemPayments.map(p => p.balanceItem), organization.id)
140
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem), organization.id)
157
141
  })
158
142
  }
159
143
 
@@ -167,7 +151,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
167
151
  await balanceItemPayment.markFailed(organization);
168
152
  }
169
153
 
170
- await this.updateOutstanding(balanceItemPayments.map(p => p.balanceItem), organization.id)
154
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem), organization.id)
171
155
  })
172
156
  }
173
157
 
@@ -366,8 +366,7 @@ export class AdminPermissionChecker {
366
366
  permissionLevel: PermissionLevel = PermissionLevel.Read,
367
367
  data?: {
368
368
  registrations: Registration[],
369
- orders: Order[],
370
- members: Member[]
369
+ orders: Order[]
371
370
  }
372
371
  ): Promise<boolean> {
373
372
  for (const balanceItem of balanceItems) {
@@ -403,7 +402,7 @@ export class AdminPermissionChecker {
403
402
  }
404
403
 
405
404
  // Slight optimization possible here
406
- const {registrations, orders, members} = data ?? (this.user.permissions || permissionLevel === PermissionLevel.Read) ? (await Payment.loadBalanceItemRelations(balanceItems)) : {registrations: [], members: [] as Member[], orders: []}
405
+ const {registrations, orders} = data ?? (this.user.permissions || permissionLevel === PermissionLevel.Read) ? (await Payment.loadBalanceItemRelations(balanceItems)) : {registrations: [], orders: []}
407
406
 
408
407
  if (this.user.permissions) {
409
408
  // We grant permission for a whole payment when the user has at least permission for a part of that payment.
@@ -431,7 +430,7 @@ export class AdminPermissionChecker {
431
430
  // Check members
432
431
  const userMembers = await Member.getMembersWithRegistrationForUser(this.user)
433
432
  for (const member of userMembers) {
434
- if (members.find(m => m.id === member.id)) {
433
+ if (balanceItems.find(m => m.memberId === member.id)) {
435
434
  return true;
436
435
  }
437
436
  }
@@ -26,11 +26,11 @@ export class AuthenticatedStructures {
26
26
  }
27
27
 
28
28
  const {balanceItemPayments, balanceItems} = await Payment.loadBalanceItems(payments)
29
- const {registrations, orders, members, groups} = await Payment.loadBalanceItemRelations(balanceItems);
29
+ const {registrations, orders, groups} = await Payment.loadBalanceItemRelations(balanceItems);
30
30
 
31
31
  if (checkPermissions) {
32
32
  // Note: permission checking is moved here for performacne to avoid loading the data multiple times
33
- if (!(await Context.auth.canAccessBalanceItems(balanceItems, PermissionLevel.Read, {registrations, orders, members}))) {
33
+ if (!(await Context.auth.canAccessBalanceItems(balanceItems, PermissionLevel.Read, {registrations, orders}))) {
34
34
  throw new SimpleError({
35
35
  code: "not_found",
36
36
  message: "Payment not found",
@@ -41,13 +41,12 @@ export class AuthenticatedStructures {
41
41
 
42
42
  const includeSettlements = checkPermissions && !!Context.user && !!Context.user.permissions
43
43
 
44
- return Payment.getGeneralStructureFromRelations({
44
+ return await Payment.getGeneralStructureFromRelations({
45
45
  payments,
46
46
  balanceItemPayments,
47
47
  balanceItems,
48
48
  registrations,
49
49
  orders,
50
- members,
51
50
  groups
52
51
  }, includeSettlements)
53
52
  }
@@ -15,7 +15,8 @@ export class MemberUserSyncerStatic {
15
15
  userEmails.push(member.details.email)
16
16
  }
17
17
 
18
- const parentEmails = member.details.parentsHaveAccess ? member.details.parents.flatMap(p => p.email ? [p.email, ...p.alternativeEmails] : p.alternativeEmails) : []
18
+ const uncategorizedEmails: string[] = member.details.uncategorizedEmails;
19
+ const parentAndUncategorizedEmails = member.details.parentsHaveAccess ? member.details.parents.flatMap(p => p.email ? [p.email, ...p.alternativeEmails] : p.alternativeEmails).concat(uncategorizedEmails) : []
19
20
 
20
21
  // Make sure all these users have access to the member
21
22
  for (const email of userEmails) {
@@ -23,18 +24,17 @@ export class MemberUserSyncerStatic {
23
24
  await this.linkUser(email, member, false)
24
25
  }
25
26
 
26
- for (const email of parentEmails) {
27
- // Link parents
27
+ for (const email of parentAndUncategorizedEmails) {
28
+ // Link parents and uncategorized emails
28
29
  await this.linkUser(email, member, true)
29
30
  }
30
31
 
31
32
  // Remove access of users that are not in this list
32
33
  for (const user of member.users) {
33
- if (!userEmails.includes(user.email) && !parentEmails.includes(user.email)) {
34
+ if (!userEmails.includes(user.email) && !parentAndUncategorizedEmails.includes(user.email)) {
34
35
  await this.unlinkUser(user, member)
35
36
  }
36
37
  }
37
-
38
38
  }
39
39
 
40
40
  async onDeleteMember(member: MemberWithRegistrations) {
@@ -6,8 +6,46 @@ import { Formatter } from '@stamhoofd/utility';
6
6
  import Stripe from 'stripe';
7
7
 
8
8
  export class StripeHelper {
9
- static getInstance() {
10
- return new Stripe(STAMHOOFD.STRIPE_SECRET_KEY, {apiVersion: '2022-11-15', typescript: true, maxNetworkRetries: 0, timeout: 10000});
9
+ static getInstance(accountId: string|null = null) {
10
+ return new Stripe(STAMHOOFD.STRIPE_SECRET_KEY, {apiVersion: '2024-06-20', typescript: true, maxNetworkRetries: 0, timeout: 10000, stripeAccount: accountId ?? undefined});
11
+ }
12
+
13
+ static async saveChargeInfo(model: StripePaymentIntent|StripeCheckoutSession, charge: Stripe.Charge, payment: Payment) {
14
+ try {
15
+ if (model.accountId) {
16
+ // This is a direct charge
17
+
18
+ if (charge.balance_transaction !== null && typeof charge.balance_transaction !== 'string') {
19
+ const fees = charge.balance_transaction.fee;
20
+ payment.transferFee = fees;
21
+ }
22
+ }
23
+
24
+ if (charge.billing_details.name) {
25
+ payment.ibanName = charge.billing_details.name
26
+ }
27
+
28
+ if (charge.payment_method_details?.bancontact) {
29
+ if (charge.payment_method_details.bancontact.iban_last4) {
30
+ payment.iban = "xxxx " + charge.payment_method_details.bancontact.iban_last4
31
+ }
32
+ payment.ibanName = charge.payment_method_details.bancontact.verified_name
33
+ }
34
+ if (charge.payment_method_details?.ideal) {
35
+ if (charge.payment_method_details.ideal.iban_last4) {
36
+ payment.iban = "xxxx " + charge.payment_method_details.ideal.iban_last4
37
+ }
38
+ payment.ibanName = charge.payment_method_details.ideal.verified_name
39
+ }
40
+ if (charge.payment_method_details?.card) {
41
+ if (charge.payment_method_details.card.last4) {
42
+ payment.iban = "xxxx " + charge.payment_method_details.card.last4
43
+ }
44
+ }
45
+ await payment.save()
46
+ } catch (e) {
47
+ console.error('Failed processing charge', e)
48
+ }
11
49
  }
12
50
 
13
51
  static async getStatus(payment: Payment, cancel = false, testMode = false): Promise<PaymentStatus> {
@@ -22,37 +60,15 @@ export class StripeHelper {
22
60
  return await this.getStatusFromCheckoutSession(payment, cancel)
23
61
  }
24
62
 
25
- const stripe = this.getInstance()
63
+ const stripe = this.getInstance(model.accountId)
26
64
 
27
- let intent = await stripe.paymentIntents.retrieve(model.stripeIntentId)
65
+ let intent = await stripe.paymentIntents.retrieve(model.stripeIntentId, {
66
+ expand: ['latest_charge.balance_transaction']
67
+ })
28
68
  console.log(intent);
29
69
  if (intent.status === "succeeded") {
30
- if (intent.latest_charge) {
31
- try {
32
- const charge = await stripe.charges.retrieve(typeof intent.latest_charge === 'string' ? intent.latest_charge : intent.latest_charge.id)
33
- if (charge.payment_method_details?.bancontact) {
34
- if (charge.payment_method_details.bancontact.iban_last4) {
35
- payment.iban = "xxxx " + charge.payment_method_details.bancontact.iban_last4
36
- }
37
- payment.ibanName = charge.payment_method_details.bancontact.verified_name
38
- await payment.save()
39
- }
40
- if (charge.payment_method_details?.ideal) {
41
- if (charge.payment_method_details.ideal.iban_last4) {
42
- payment.iban = "xxxx " + charge.payment_method_details.ideal.iban_last4
43
- }
44
- payment.ibanName = charge.payment_method_details.ideal.verified_name
45
- await payment.save()
46
- }
47
- if (charge.payment_method_details?.card) {
48
- if (charge.payment_method_details.card.last4) {
49
- payment.iban = "xxxx " + charge.payment_method_details.card.last4
50
- }
51
- await payment.save()
52
- }
53
- } catch (e) {
54
- console.error('Failed fatching charge', e)
55
- }
70
+ if (intent.latest_charge !== null && typeof intent.latest_charge !== 'string') {
71
+ await this.saveChargeInfo(model, intent.latest_charge, payment)
56
72
  }
57
73
  return PaymentStatus.Succeeded
58
74
  }
@@ -93,10 +109,22 @@ export class StripeHelper {
93
109
  return PaymentStatus.Failed
94
110
  }
95
111
 
96
- const stripe = this.getInstance()
97
- const session = await stripe.checkout.sessions.retrieve(model.stripeSessionId)
112
+ const stripe = this.getInstance(model.accountId)
113
+ const session = await stripe.checkout.sessions.retrieve(model.stripeSessionId, {
114
+ expand: ['payment_intent.latest_charge.balance_transaction']
115
+ })
116
+
98
117
  console.log("session", session);
118
+
99
119
  if (session.status === "complete") {
120
+ // This is a direct charge
121
+ const payment_intent = session.payment_intent
122
+ if (payment_intent !== null && typeof payment_intent !== 'string') {
123
+ const charge = payment_intent.latest_charge
124
+ if (charge !== null && typeof charge !== 'string') {
125
+ await this.saveChargeInfo(model, charge, payment)
126
+ }
127
+ }
100
128
  return PaymentStatus.Succeeded
101
129
  }
102
130
  if (session.status === "expired") {
@@ -145,6 +173,7 @@ export class StripeHelper {
145
173
  const totalPrice = payment.price;
146
174
 
147
175
  let fee = 0;
176
+ let directCharge = false;
148
177
  const vat = calculateVATPercentage(organization.address, organization.meta.VATNumber)
149
178
  function calculateFee(fixed: number, percentageTimes100: number) {
150
179
  return Math.round(Math.round(fixed + Math.max(1, totalPrice * percentageTimes100 / 100 / 100)) * (100 + vat) / 100); // € 0,21 + 0,2%
@@ -158,6 +187,12 @@ export class StripeHelper {
158
187
  fee = calculateFee(25, 150); // € 0,25 + 1,5%
159
188
  }
160
189
 
190
+ if (stripeAccount.meta.type === 'standard') {
191
+ // Submerchant is charged by Stripe for the fees directly
192
+ fee = 0;
193
+ directCharge = true;
194
+ }
195
+
161
196
  payment.transferFee = fee;
162
197
 
163
198
  const fullMetadata = {
@@ -165,7 +200,7 @@ export class StripeHelper {
165
200
  organizationVATNumber: organization.meta.VATNumber
166
201
  }
167
202
 
168
- const stripe = StripeHelper.getInstance()
203
+ const stripe = StripeHelper.getInstance(directCharge ? stripeAccount.accountId : null)
169
204
  let paymentUrl: string
170
205
 
171
206
  // Bancontact or iDEAL: use payment intends
@@ -185,12 +220,12 @@ export class StripeHelper {
185
220
  payment_method_types: [payment.method.toLowerCase()],
186
221
  statement_descriptor: Formatter.slug(statementDescriptor).substring(0, 22).toUpperCase(),
187
222
  application_fee_amount: fee,
188
- on_behalf_of: stripeAccount.accountId,
223
+ on_behalf_of: !directCharge ? stripeAccount.accountId : undefined,
189
224
  confirm: true,
190
225
  return_url: redirectUrl,
191
- transfer_data: {
226
+ transfer_data: !directCharge ? {
192
227
  destination: stripeAccount.accountId,
193
- },
228
+ } : undefined,
194
229
  metadata: fullMetadata,
195
230
  payment_method_options: {bancontact: {preferred_language: ['nl', 'fr', 'de', 'en'].includes(i18n.language) ? i18n.language as 'en' : 'nl'}},
196
231
  });
@@ -213,6 +248,10 @@ export class StripeHelper {
213
248
  paymentIntentModel.paymentId = payment.id
214
249
  paymentIntentModel.stripeIntentId = paymentIntent.id
215
250
  paymentIntentModel.organizationId = organization.id
251
+
252
+ if (directCharge) {
253
+ paymentIntentModel.accountId = stripeAccount.accountId
254
+ }
216
255
  await paymentIntentModel.save()
217
256
  } else {
218
257
  // Build Stripe line items
@@ -253,11 +292,11 @@ export class StripeHelper {
253
292
  currency: 'eur',
254
293
  locale: i18n.language as 'nl',
255
294
  payment_intent_data: {
256
- on_behalf_of: stripeAccount.accountId,
295
+ on_behalf_of: !directCharge ? stripeAccount.accountId : undefined,
257
296
  application_fee_amount: fee,
258
- transfer_data: {
297
+ transfer_data: !directCharge ? {
259
298
  destination: stripeAccount.accountId,
260
- },
299
+ } : undefined,
261
300
  metadata: fullMetadata,
262
301
  statement_descriptor: Formatter.slug(statementDescriptor).substring(0, 22).toUpperCase(),
263
302
  },
@@ -281,6 +320,10 @@ export class StripeHelper {
281
320
  paymentIntentModel.paymentId = payment.id
282
321
  paymentIntentModel.stripeSessionId = session.id
283
322
  paymentIntentModel.organizationId = organization.id
323
+
324
+ if (directCharge) {
325
+ paymentIntentModel.accountId = stripeAccount.accountId
326
+ }
284
327
  await paymentIntentModel.save()
285
328
  }
286
329
 
@@ -290,4 +333,4 @@ export class StripeHelper {
290
333
  paymentUrl
291
334
  }
292
335
  }
293
- }
336
+ }
@@ -9,7 +9,7 @@ export class StripePayoutChecker {
9
9
  constructor({secretKey, stripeAccount}: { secretKey: string, stripeAccount?: string}) {
10
10
  this.stripe = new Stripe(
11
11
  secretKey, {
12
- apiVersion: '2022-11-15',
12
+ apiVersion: '2024-06-20',
13
13
  typescript: true,
14
14
  maxNetworkRetries: 1,
15
15
  timeout: 10000,
@@ -18,7 +18,7 @@ export class StripePayoutChecker {
18
18
 
19
19
  this.stripePlatform = new Stripe(
20
20
  secretKey, {
21
- apiVersion: '2022-11-15',
21
+ apiVersion: '2024-06-20',
22
22
  typescript: true,
23
23
  maxNetworkRetries: 1,
24
24
  timeout: 10000
@@ -147,6 +147,8 @@ export class StripePayoutChecker {
147
147
  }
148
148
 
149
149
  const applicationFee = balanceItem.source.application_fee_amount;
150
+ const otherFees = balanceItem.fee
151
+ const totalFees = otherFees + (applicationFee ?? 0);
150
152
 
151
153
  // Cool, we can store this in the database now.
152
154
 
@@ -167,11 +169,11 @@ export class StripePayoutChecker {
167
169
  settledAt: new Date(payout.arrival_date * 1000),
168
170
  amount: payout.amount,
169
171
  // Set only if application fee is witheld
170
- fee: applicationFee ?? 0
172
+ fee: totalFees
171
173
  });
172
174
 
173
175
  payment.settlement = settlement;
174
- payment.transferFee = applicationFee ?? 0;
176
+ payment.transferFee = totalFees;
175
177
 
176
178
  // Force an updatedAt timestamp of the related order
177
179
  // Mark order as 'updated', or the frontend won't pull in the updates
@@ -185,4 +187,4 @@ export class StripePayoutChecker {
185
187
  await payment.save();
186
188
  }
187
189
 
188
- }
190
+ }