@stamhoofd/backend 2.5.0 → 2.7.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.7.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": "319a4de9fac39a31110cddbfa9a580d1b9c8f730"
54
54
  }
@@ -23,7 +23,7 @@ const filterCompilers: SQLFilterDefinitions = {
23
23
  startDate: createSQLColumnFilterCompiler('startDate'),
24
24
  endDate: createSQLColumnFilterCompiler('endDate'),
25
25
  groupIds: createSQLExpressionFilterCompiler(
26
- SQL.jsonValue(SQL.column('meta'), '$.value.groupIds'),
26
+ SQL.jsonValue(SQL.column('meta'), '$.value.groups[*].id'),
27
27
  undefined,
28
28
  true,
29
29
  true
@@ -35,7 +35,10 @@ const filterCompilers: SQLFilterDefinitions = {
35
35
  true
36
36
  ),
37
37
  organizationTagIds: createSQLExpressionFilterCompiler(
38
- SQL.jsonValue(SQL.column('meta'), '$.value.organizationTagIds')
38
+ SQL.jsonValue(SQL.column('meta'), '$.value.organizationTagIds'),
39
+ undefined,
40
+ true,
41
+ true
39
42
  )
40
43
  }
41
44
 
@@ -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
 
@@ -3,8 +3,8 @@ import { Decoder } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
5
  import { Email, Member, MemberWithRegistrations, Platform } from '@stamhoofd/models';
6
- import { SQL, SQLAge, SQLConcat, SQLFilterDefinitions, SQLJSONValue, SQLOrderBy, SQLOrderByDirection, SQLScalar, SQLSortDefinitions, baseSQLFilterCompilers, compileToSQLFilter, compileToSQLSorter, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler, createSQLFilterNamespace, createSQLRelationFilterCompiler, joinSQLQuery } from "@stamhoofd/sql";
7
- import { CountFilteredRequest, EmailRecipientFilterType, GroupStatus, LimitedFilteredRequest, MembersBlob, PaginatedResponse, PermissionLevel, StamhoofdFilter, getSortFilter, mergeFilters } from '@stamhoofd/structures';
6
+ import { SQL, SQLAge, SQLConcat, SQLFilterDefinitions, SQLOrderBy, SQLOrderByDirection, SQLScalar, SQLSortDefinitions, baseSQLFilterCompilers, compileToSQLFilter, compileToSQLSorter, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler, createSQLFilterNamespace, createSQLRelationFilterCompiler, joinSQLQuery } from "@stamhoofd/sql";
7
+ import { CountFilteredRequest, EmailRecipientFilterType, LimitedFilteredRequest, MembersBlob, PaginatedResponse, PermissionLevel, StamhoofdFilter, getSortFilter, mergeFilters } from '@stamhoofd/structures';
8
8
  import { DataValidator, Formatter } from '@stamhoofd/utility';
9
9
 
10
10
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -36,6 +36,7 @@ Email.recipientLoaders.set(EmailRecipientFilterType.Members, {
36
36
  return await q.count();
37
37
  }
38
38
  });
39
+
39
40
  Email.recipientLoaders.set(EmailRecipientFilterType.MemberParents, {
40
41
  fetch: async (query: LimitedFilteredRequest) => {
41
42
  const result = await GetMembersEndpoint.buildData(query)
@@ -54,6 +55,24 @@ Email.recipientLoaders.set(EmailRecipientFilterType.MemberParents, {
54
55
  }
55
56
  });
56
57
 
58
+ Email.recipientLoaders.set(EmailRecipientFilterType.MemberUnverified, {
59
+ fetch: async (query: LimitedFilteredRequest) => {
60
+ const result = await GetMembersEndpoint.buildData(query)
61
+
62
+ return new PaginatedResponse({
63
+ results: result.results.members.flatMap(m => m.getEmailRecipients(['unverified'])),
64
+ next: result.next
65
+ });
66
+ },
67
+
68
+ count: async (query: LimitedFilteredRequest) => {
69
+ const q = await GetMembersEndpoint.buildQuery(query)
70
+ return await q.sum(
71
+ SQL.jsonLength(SQL.column('details'), '$.value.unverifiedEmails')
72
+ );
73
+ }
74
+ });
75
+
57
76
  const registrationFilterCompilers: SQLFilterDefinitions = {
58
77
  ...baseSQLFilterCompilers,
59
78
  "price": createSQLColumnFilterCompiler('price'),
@@ -157,6 +176,15 @@ const filterCompilers: SQLFilterDefinitions = {
157
176
  .where(
158
177
  SQL.column('memberId'),
159
178
  SQL.column('members', 'id'),
179
+ ).whereNot(
180
+ SQL.column('registeredAt'),
181
+ null,
182
+ ).where(
183
+ SQL.column('deactivatedAt'),
184
+ null,
185
+ ).where(
186
+ SQL.column('groups', 'deletedAt'),
187
+ null
160
188
  ),
161
189
  {
162
190
  ...registrationFilterCompilers,
@@ -245,9 +273,12 @@ const filterCompilers: SQLFilterDefinitions = {
245
273
  ).whereNot(
246
274
  SQL.column('registeredAt'),
247
275
  null,
248
- ).whereNot(
249
- SQL.column('groups', 'status'),
250
- GroupStatus.Archived
276
+ ).where(
277
+ SQL.column('deactivatedAt'),
278
+ null,
279
+ ).where(
280
+ SQL.column('groups', 'deletedAt'),
281
+ null
251
282
  ),
252
283
  registrationFilterCompilers
253
284
  ),
@@ -276,9 +307,9 @@ const filterCompilers: SQLFilterDefinitions = {
276
307
  ).whereNot(
277
308
  SQL.column('registeredAt'),
278
309
  null,
279
- ).whereNot(
280
- SQL.column('groups', 'status'),
281
- GroupStatus.Archived
310
+ ).where(
311
+ SQL.column('groups', 'deletedAt'),
312
+ null
282
313
  ),
283
314
  organizationFilterCompilers
284
315
  ),
@@ -366,9 +397,6 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
366
397
  }
367
398
  },
368
399
  periodId: platform.periodId,
369
- registeredAt: {
370
- $neq: null
371
- },
372
400
  group: {
373
401
  defaultAgeGroupId: {
374
402
  $neq: null
@@ -385,16 +413,26 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
385
413
  const groups = await Context.auth.getAccessibleGroups(organization.id)
386
414
 
387
415
  if (groups === 'all') {
388
- scopeFilter = {
389
- registrations: {
390
- $elemMatch: {
391
- organizationId: organization.id,
392
- registeredAt: {
393
- $neq: null
416
+ if (await Context.auth.hasFullAccess(organization.id)) {
417
+ // Can access full history for now
418
+ scopeFilter = {
419
+ registrations: {
420
+ $elemMatch: {
421
+ organizationId: organization.id,
394
422
  }
395
423
  }
396
- }
397
- };
424
+ };
425
+ } else {
426
+ // Can only access current period
427
+ scopeFilter = {
428
+ registrations: {
429
+ $elemMatch: {
430
+ organizationId: organization.id,
431
+ periodId: organization.periodId,
432
+ }
433
+ }
434
+ };
435
+ }
398
436
  } else {
399
437
  scopeFilter = {
400
438
  registrations: {
@@ -403,9 +441,6 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
403
441
  periodId: organization.periodId,
404
442
  groupId: {
405
443
  $in: groups
406
- },
407
- registeredAt: {
408
- $neq: null
409
444
  }
410
445
  }
411
446
  }
@@ -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,14 +6,13 @@ 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';
13
13
  import { BuckarooHelper } from '../../../helpers/BuckarooHelper';
14
14
  import { Context } from '../../../helpers/Context';
15
15
  import { StripeHelper } from '../../../helpers/StripeHelper';
16
- import { ExchangePaymentEndpoint } from '../../organization/shared/ExchangePaymentEndpoint';
17
16
  type Params = Record<string, never>;
18
17
  type Query = undefined;
19
18
  type Body = IDRegisterCheckout
@@ -99,7 +98,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
99
98
  }
100
99
  }
101
100
 
102
- const memberIds = Formatter.uniqueArray(request.body.cart.items.map(i => i.memberId))
101
+ const memberIds = Formatter.uniqueArray(
102
+ [...request.body.cart.items.map(i => i.memberId), ...request.body.cart.deleteRegistrations.map(i => i.member.id)]
103
+ )
103
104
  const members = await Member.getBlobByIds(...memberIds)
104
105
  const groupIds = Formatter.uniqueArray(request.body.cart.items.map(i => i.groupId))
105
106
  const groups = await Group.getByIDs(...groupIds)
@@ -141,11 +142,19 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
141
142
  platformMembers.push(...family.members)
142
143
  }
143
144
 
145
+ const organizationStruct = await AuthenticatedStructures.organization(organization)
144
146
  const checkout = request.body.hydrate({
145
147
  members: platformMembers,
146
148
  groups: await AuthenticatedStructures.groups(groups),
147
- organizations: [await AuthenticatedStructures.organization(organization)]
149
+ organizations: [organizationStruct]
148
150
  })
151
+
152
+ // Set circular references
153
+ for (const member of platformMembers) {
154
+ member.family.checkout = checkout
155
+ }
156
+
157
+ checkout.setDefaultOrganization(organizationStruct)
149
158
 
150
159
  const registrations: RegistrationWithMemberAndGroup[] = []
151
160
  const payRegistrations: {registration: RegistrationWithMemberAndGroup, item: RegisterItem}[] = []
@@ -178,6 +187,8 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
178
187
  memberBalanceItems = await BalanceItem.getMemberStructure(balanceItems)
179
188
  }
180
189
 
190
+ console.log('isAdminFromSameOrganization', checkout.isAdminFromSameOrganization)
191
+
181
192
  // Validate the cart
182
193
  checkout.validate({memberBalanceItems})
183
194
 
@@ -230,7 +241,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
230
241
  .setRelation(Registration.group, group)
231
242
 
232
243
 
233
- if (existingRegistration.registeredAt !== null) {
244
+ if (existingRegistration.registeredAt !== null && existingRegistration.deactivatedAt === null) {
234
245
  throw new SimpleError({
235
246
  code: "already_registered",
236
247
  message: "Dit lid is reeds ingeschreven. Herlaad de pagina en probeer opnieuw."
@@ -260,18 +271,18 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
260
271
  }
261
272
 
262
273
  // 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
274
+ 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
275
 
265
276
  if (request.body.asOrganizationId && request.body.asOrganizationId === organization.id) {
266
277
  // Will get added to the outstanding amount of the member
267
- whoWillPay = 'nobody'
278
+ whoWillPayNow = 'nobody'
268
279
  } else if (request.body.asOrganizationId && request.body.asOrganizationId !== organization.id) {
269
280
  // 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'
281
+ whoWillPayNow = 'organization'
271
282
  }
272
283
 
273
284
  // Validate payment method
274
- if (totalPrice > 0 && whoWillPay !== 'nobody') {
285
+ if (totalPrice > 0 && whoWillPayNow !== 'nobody') {
275
286
  const allowedPaymentMethods = organization.meta.registrationPaymentConfiguration.paymentMethods
276
287
 
277
288
  if (!checkout.paymentMethod || !allowedPaymentMethods.includes(checkout.paymentMethod)) {
@@ -292,10 +303,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
292
303
  checkout.paymentMethod = PaymentMethod.Unknown
293
304
  }
294
305
 
295
- console.log('Registering members using whoWillPay', whoWillPay, checkout.paymentMethod, totalPrice)
306
+ console.log('Registering members using whoWillPayNow', whoWillPayNow, checkout.paymentMethod, totalPrice)
296
307
 
297
308
  const items: BalanceItem[] = []
298
- const shouldMarkValid = whoWillPay === 'nobody' || checkout.paymentMethod === PaymentMethod.Transfer || checkout.paymentMethod === PaymentMethod.PointOfSale
309
+ const shouldMarkValid = whoWillPayNow === 'nobody' || checkout.paymentMethod === PaymentMethod.Transfer || checkout.paymentMethod === PaymentMethod.PointOfSale
299
310
 
300
311
  // Save registrations and add extra data if needed
301
312
  for (const bundle of payRegistrations) {
@@ -330,7 +341,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
330
341
 
331
342
  // Who is responsible for payment?
332
343
  let balanceItem2: BalanceItem | null = null
333
- if (whoWillPay === 'organization' && request.body.asOrganizationId) {
344
+ if (whoWillPayNow === 'organization' && request.body.asOrganizationId) {
334
345
  // Create a separate balance item for this meber to pay back the paying organization
335
346
  // this is not yet associated with a payment but will be added to the outstanding balance of the member
336
347
 
@@ -391,7 +402,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
391
402
  items.push(balanceItem)
392
403
  }
393
404
 
394
- if (checkout.administrationFee && whoWillPay !== 'nobody') {
405
+ if (checkout.administrationFee && whoWillPayNow !== 'nobody') {
395
406
  // Create balance item
396
407
  const balanceItem = new BalanceItem();
397
408
  balanceItem.price = checkout.administrationFee
@@ -412,17 +423,68 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
412
423
  balanceItem.status = shouldMarkValid ? BalanceItemStatus.Pending : BalanceItemStatus.Hidden
413
424
  await balanceItem.save();
414
425
 
415
- items.push(balanceItem)
426
+ items.push(balanceItem);
416
427
  }
417
428
 
418
- if (checkout.cart.balanceItems.length && whoWillPay === 'nobody') {
419
- throw new Error('Not possible to pay balance items when whoWillPay is nobody')
429
+ if (checkout.cart.balanceItems.length && whoWillPayNow === 'nobody') {
430
+ throw new Error('Not possible to pay balance items when whoWillPayNow is nobody')
431
+ }
432
+
433
+ // Create negative balance items
434
+ for (const registrationStruct of checkout.cart.deleteRegistrations) {
435
+ if (whoWillPayNow !== 'nobody') {
436
+ // this also fixes the issue that we cannot delete the registration right away if we would need to wait for a payment
437
+ throw new SimpleError({
438
+ code: "forbidden",
439
+ message: "Permission denied: you are not allowed to delete registrations",
440
+ human: "Oeps, je hebt geen toestemming om inschrijvingen te verwijderen.",
441
+ statusCode: 403
442
+ })
443
+ }
444
+
445
+ const existingRegistration = await Registration.getByID(registrationStruct.id)
446
+ if (!existingRegistration || existingRegistration.organizationId !== organization.id) {
447
+ throw new SimpleError({
448
+ code: "invalid_data",
449
+ message: "Oeps, één of meerdere inschrijvingen die je probeert te verwijderen lijken niet meer te bestaan. Herlaad de pagina en probeer opnieuw."
450
+ })
451
+ }
452
+
453
+ if (!await Context.auth.canAccessRegistration(existingRegistration, PermissionLevel.Write)) {
454
+ throw new SimpleError({
455
+ code: "forbidden",
456
+ message: "Je hebt geen toegaansrechten om deze inschrijving te verwijderen.",
457
+ statusCode: 403
458
+ })
459
+ }
460
+
461
+ if (existingRegistration.deactivatedAt || !existingRegistration.registeredAt) {
462
+ throw new SimpleError({
463
+ code: "invalid_data",
464
+ message: "Oeps, één of meerdere inschrijvingen die je probeert te verwijderen was al verwijderd. Herlaad de pagina en probeer opnieuw."
465
+ })
466
+ }
467
+
468
+ // We can alter right away since whoWillPayNow is nobody, and shouldMarkValid will always be true
469
+ // Find all balance items of this registration and set them to zero
470
+ await BalanceItem.deleteForDeletedRegistration(existingRegistration.id)
471
+
472
+ // Clear the registration
473
+ await existingRegistration.deactivate()
474
+
475
+ const group = groups.find(g => g.id === existingRegistration.groupId)
476
+ if (!group) {
477
+ const g = await Group.getByID(existingRegistration.groupId)
478
+ if (g) {
479
+ groups.push(g)
480
+ }
481
+ }
420
482
  }
421
483
 
422
484
  let paymentUrl: string | null = null
423
485
  let payment: Payment | null = null
424
486
 
425
- if (whoWillPay !== 'nobody') {
487
+ if (whoWillPayNow !== 'nobody') {
426
488
  const mappedBalanceItems = new Map<BalanceItem, number>()
427
489
 
428
490
  for (const item of items) {
@@ -452,11 +514,11 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
452
514
  }
453
515
  }
454
516
 
455
- await ExchangePaymentEndpoint.updateOutstanding(items, organization.id)
517
+ await BalanceItem.updateOutstanding(items, organization.id)
456
518
 
457
519
  // Update occupancy
458
520
  for (const group of groups) {
459
- if (registrations.find(p => p.groupId === group.id)) {
521
+ if (registrations.find(p => p.groupId === group.id) || checkout.cart.deleteRegistrations.find(p => p.groupId === group.id)) {
460
522
  await group.updateOccupancy()
461
523
  await group.save()
462
524
  }
@@ -501,10 +563,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
501
563
  }
502
564
 
503
565
  if (totalPrice < 0) {
504
- throw new SimpleError({
505
- code: "empty_data",
506
- message: "Oeps! De totaalprijs is negatief."
507
- })
566
+ // No payment needed: the outstanding balance will be negative and can be used in the future
567
+ return;
568
+ // throw new SimpleError({
569
+ // code: "empty_data",
570
+ // message: "Oeps! De totaalprijs is negatief."
571
+ // })
508
572
  }
509
573
 
510
574
  if (totalPrice === 0) {
@@ -595,11 +659,14 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
595
659
  }
596
660
 
597
661
  const _redirectUrl = new URL(checkout.redirectUrl)
598
- _redirectUrl.searchParams.set('id', payment.id);
599
-
662
+ _redirectUrl.searchParams.set('paymentId', payment.id);
663
+ _redirectUrl.searchParams.set('organizationId', organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
664
+
600
665
  const _cancelUrl = new URL(checkout.cancelUrl)
601
- _cancelUrl.searchParams.set('id', payment.id);
602
-
666
+ _cancelUrl.searchParams.set('paymentId', payment.id);
667
+ _cancelUrl.searchParams.set('cancel', 'true');
668
+ _cancelUrl.searchParams.set('organizationId', organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
669
+
603
670
  const redirectUrl = _redirectUrl.href
604
671
  const cancelUrl = _cancelUrl.href
605
672
 
@@ -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
  }
@@ -917,6 +916,10 @@ export class AdminPermissionChecker {
917
916
  const isUserManager = this.isUserManager(member)
918
917
  if (isUserManager) {
919
918
  // For the user manager, we don't delete data, because when registering a new member, it doesn't have any organizations yet...
919
+
920
+ // Notes are not visible for the member.
921
+ data.details.notes = null;
922
+
920
923
  return data;
921
924
  }
922
925
 
@@ -956,48 +959,63 @@ export class AdminPermissionChecker {
956
959
  })
957
960
  }
958
961
 
959
- if (data.details.recordAnswers) {
960
- if (!(data.details.recordAnswers instanceof PatchMap)) {
961
- throw new SimpleError({
962
- code: 'invalid_request',
963
- message: 'Cannot PUT recordAnswers',
964
- statusCode: 400
965
- })
966
- }
967
- const isUserManager = this.isUserManager(member)
968
- const records = isUserManager ? new Set() : await this.getAccessibleRecordSet(member, PermissionLevel.Write)
962
+ const hasRecordAnswers = !!data.details.recordAnswers;
963
+ const hasNotes = data.details.notes !== undefined;
969
964
 
970
- for (const [key, value] of data.details.recordAnswers.entries()) {
971
- let name: string | undefined = undefined
972
- if (value) {
973
- if (value.isPatch()) {
974
- throw new SimpleError({
975
- code: 'invalid_request',
976
- message: 'Cannot PATCH a record answer object',
977
- statusCode: 400
978
- })
979
- }
965
+ if(hasRecordAnswers || hasNotes) {
966
+ const isUserManager = this.isUserManager(member);
980
967
 
981
- const id = value.settings.id
968
+ if (hasRecordAnswers) {
969
+ if (!(data.details.recordAnswers instanceof PatchMap)) {
970
+ throw new SimpleError({
971
+ code: 'invalid_request',
972
+ message: 'Cannot PUT recordAnswers',
973
+ statusCode: 400
974
+ })
975
+ }
976
+
977
+ const records = isUserManager ? new Set() : await this.getAccessibleRecordSet(member, PermissionLevel.Write)
978
+
979
+ for (const [key, value] of data.details.recordAnswers.entries()) {
980
+ let name: string | undefined = undefined
981
+ if (value) {
982
+ if (value.isPatch()) {
983
+ throw new SimpleError({
984
+ code: 'invalid_request',
985
+ message: 'Cannot PATCH a record answer object',
986
+ statusCode: 400
987
+ })
988
+ }
989
+
990
+ const id = value.settings.id
991
+
992
+ if (id !== key) {
993
+ throw new SimpleError({
994
+ code: 'invalid_request',
995
+ message: 'Record answer key does not match record id',
996
+ statusCode: 400
997
+ })
998
+ }
999
+
1000
+ name = value.settings.name
1001
+ }
982
1002
 
983
- if (id !== key) {
1003
+ if (!isUserManager && !records.has(key)) {
984
1004
  throw new SimpleError({
985
- code: 'invalid_request',
986
- message: 'Record answer key does not match record id',
1005
+ code: 'permission_denied',
1006
+ message: `Je hebt geen toegangsrechten om het antwoord op ${name ?? 'deze vraag'} aan te passen voor dit lid`,
987
1007
  statusCode: 400
988
1008
  })
989
1009
  }
990
-
991
- name = value.settings.name
992
1010
  }
1011
+ }
993
1012
 
994
- if (!isUserManager && !records.has(key)) {
995
- throw new SimpleError({
996
- code: 'permission_denied',
997
- message: `Je hebt geen toegangsrechten om het antwoord op ${name ?? 'deze vraag'} aan te passen voor dit lid`,
998
- statusCode: 400
999
- })
1000
- }
1013
+ if(hasNotes && isUserManager) {
1014
+ throw new SimpleError({
1015
+ code: 'permission_denied',
1016
+ message: 'Cannot edit notes',
1017
+ statusCode: 400
1018
+ })
1001
1019
  }
1002
1020
  }
1003
1021
 
@@ -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 unverifiedEmails: string[] = member.details.unverifiedEmails;
19
+ const parentAndUnverifiedEmails = member.details.parentsHaveAccess ? member.details.parents.flatMap(p => p.email ? [p.email, ...p.alternativeEmails] : p.alternativeEmails).concat(unverifiedEmails) : []
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 parentAndUnverifiedEmails) {
28
+ // Link parents and unverified 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) && !parentAndUnverifiedEmails.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
+ }