@stamhoofd/backend 2.111.0 → 2.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE.md +32 -0
  2. package/package.json +14 -11
  3. package/src/boot.ts +1 -0
  4. package/src/email-recipient-loaders/documents.ts +66 -0
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +701 -4
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +21 -10
  7. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +661 -4
  8. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +17 -6
  9. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +291 -8
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +22 -0
  11. package/src/endpoints/organization/dashboard/invoices/GetInvoicesCountEndpoint.ts +43 -0
  12. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +219 -0
  13. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +2 -2
  14. package/src/endpoints/organization/shared/GetUitpasNumberDetailsEndpoint.ts +72 -0
  15. package/src/endpoints/organization/webshops/RetrieveUitpasSocialTariffPriceEndpoint.ts +3 -2
  16. package/src/excel-loaders/members.ts +27 -27
  17. package/src/helpers/AdminPermissionChecker.ts +30 -10
  18. package/src/helpers/AuthenticatedStructures.ts +24 -5
  19. package/src/helpers/StripeHelper.ts +11 -1
  20. package/src/helpers/StripePayoutChecker.ts +7 -0
  21. package/src/helpers/UitpasTokenRepository.ts +7 -5
  22. package/src/helpers/passthroughFetch.ts +24 -0
  23. package/src/helpers/updateMemberDetailsUitpasNumber.ts +149 -0
  24. package/src/seeds/data/default-email-templates.sql +2 -1
  25. package/src/seeds/wip/1769088653-uitpas-status.ts +129 -0
  26. package/src/services/InvoiceService.ts +2 -2
  27. package/src/services/uitpas/PassholderEndpoints.ts +190 -0
  28. package/src/services/uitpas/UitpasService.ts +37 -12
  29. package/src/services/uitpas/checkUitpasNumbers.ts +16 -140
  30. package/src/services/uitpas/handleUitpasResponse.ts +89 -0
  31. package/src/sql-filters/invoiced-balance-items.ts +20 -0
  32. package/src/sql-filters/invoices.ts +122 -0
  33. package/src/sql-filters/payments.ts +11 -1
  34. package/src/sql-sorters/invoices.ts +83 -0
  35. package/src/sql-sorters/payments.ts +33 -0
  36. package/tests/e2e/bundle-discounts.test.ts +8 -8
  37. package/tests/e2e/tests-disable-net-connect.test.ts +5 -0
  38. package/tests/helpers/StripeMocker.ts +5 -5
  39. package/tests/helpers/UitpasApiMocker.ts +175 -0
  40. package/tests/helpers/index.ts +1 -0
  41. package/tests/helpers/resetNock.ts +7 -0
  42. package/tests/init/index.ts +1 -0
  43. package/tests/init/initPayconiq.ts +2 -2
  44. package/tests/init/initStripe.ts +1 -1
  45. package/tests/init/initUitpasApi.ts +14 -0
  46. package/tests/jest.global.setup.ts +6 -4
  47. package/tests/jest.setup.ts +12 -6
  48. package/LICENSE +0 -665
@@ -0,0 +1,72 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { UitpasNumberDetails, UitpasNumbersGetDetailsRequest } from '@stamhoofd/structures';
3
+
4
+ import { Decoder } from '@simonbackx/simple-encoding';
5
+ import { isSimpleError, isSimpleErrors, SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
6
+ import { uitpasApiResponseToSocialTariff } from '../../../helpers/updateMemberDetailsUitpasNumber.js';
7
+ import { UitpasService } from '../../../services/uitpas/UitpasService.js';
8
+
9
+ type Params = Record<string, never>;
10
+ type Query = UitpasNumbersGetDetailsRequest;
11
+ type Body = undefined;
12
+ type ResponseBody = UitpasNumberDetails[];
13
+
14
+ /**
15
+ * Get the details such as the social tariff for a list of uitpas numbers.
16
+ */
17
+ export class GetUitpasNumberDetailsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
18
+ queryDecoder = UitpasNumbersGetDetailsRequest as Decoder<UitpasNumbersGetDetailsRequest>;
19
+
20
+ protected doesMatch(request: Request): [true, Params] | [false] {
21
+ if (request.method !== 'GET') {
22
+ return [false];
23
+ }
24
+
25
+ const params = Endpoint.parseParameters(request.url, '/uitpas/details', {});
26
+
27
+ if (params) {
28
+ return [true, params as Params];
29
+ }
30
+ return [false];
31
+ }
32
+
33
+ async handle(request: DecodedRequest<Params, Query, Body>) {
34
+ const uitpasNumbers: string[] = request.query.uitpasNumbers;
35
+ if (uitpasNumbers.length > 5) {
36
+ throw new SimpleError({
37
+ code: 'maximum_limit',
38
+ message: 'Please only request up to 5 numbers at the same time',
39
+ });
40
+ }
41
+
42
+ const results: UitpasNumberDetails[] = [];
43
+ const simpleErrors = new SimpleErrors();
44
+
45
+ for (let i = 0; i < uitpasNumbers.length; i++) {
46
+ const uitpasNumber = uitpasNumbers[i];
47
+
48
+ try {
49
+ const result = await UitpasService.getPassByUitpasNumber(uitpasNumber);
50
+ const socialTariff = uitpasApiResponseToSocialTariff(result);
51
+ results.push(UitpasNumberDetails.create({
52
+ uitpasNumber,
53
+ socialTariff,
54
+ }));
55
+ }
56
+ catch (error) {
57
+ if (isSimpleError(error) || isSimpleErrors(error)) {
58
+ error.addNamespace(i.toString());
59
+ error.addNamespace('uitpasNumbers');
60
+ simpleErrors.addError(error);
61
+ }
62
+ else {
63
+ throw error;
64
+ }
65
+ }
66
+ }
67
+
68
+ simpleErrors.throwIfNotEmpty();
69
+
70
+ return new Response(results);
71
+ }
72
+ }
@@ -3,8 +3,9 @@ import { SimpleError } from '@simonbackx/simple-errors';
3
3
  import { UitpasPriceCheckRequest, UitpasPriceCheckResponse } from '@stamhoofd/structures';
4
4
 
5
5
  import { Decoder } from '@simonbackx/simple-encoding';
6
- import { UitpasService } from '../../../services/uitpas/UitpasService';
7
- import { Context } from '../../../helpers/Context';
6
+ import { Context } from '../../../helpers/Context.js';
7
+ import { UitpasService } from '../../../services/uitpas/UitpasService.js';
8
+
8
9
  type Params = Record<string, never>;
9
10
  type Query = undefined;
10
11
  type Body = UitpasPriceCheckRequest;
@@ -122,7 +122,7 @@ export const baseMemberColumns: XlsxTransformerColumn<PlatformMember>[] = [
122
122
  name: $t(`87c1a48c-fef5-44c3-ae56-c83463fcfb84`),
123
123
  width: 20,
124
124
  getValue: ({ patchedMember: object }: PlatformMember) => ({
125
- value: object.details.uitpasNumber,
125
+ value: object.details.uitpasNumberDetails?.uitpasNumber ?? null,
126
126
  }),
127
127
  },
128
128
  {
@@ -160,6 +160,32 @@ export const baseMemberColumns: XlsxTransformerColumn<PlatformMember>[] = [
160
160
  };
161
161
  },
162
162
  },
163
+ {
164
+ id: 'organization',
165
+ name: $t(`afd7843d-f355-445b-a158-ddacf469a5b1`),
166
+ width: 40,
167
+ getValue: (member: PlatformMember) => {
168
+ const organizations = member.filterOrganizations({ currentPeriod: true, types: [GroupType.Membership] });
169
+ const str = Formatter.joinLast(organizations.map(o => o.name).sort(), ', ', ' ' + $t(`c1843768-2bf4-42f2-baa4-42f49028463d`) + ' ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f');
170
+
171
+ return {
172
+ value: str,
173
+ };
174
+ },
175
+ },
176
+ {
177
+ id: 'uri',
178
+ name: $t(`27cfaf26-6b88-4ebc-a50a-627a9f0f9e64`),
179
+ width: 30,
180
+ getValue: (member: PlatformMember) => {
181
+ const organizations = member.filterOrganizations({ currentPeriod: true, types: [GroupType.Membership] });
182
+ const str = Formatter.joinLast(organizations.map(o => o.uri).sort(), ', ', ' ' + $t(`c1843768-2bf4-42f2-baa4-42f49028463d`) + ' ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f');
183
+
184
+ return {
185
+ value: str,
186
+ };
187
+ },
188
+ },
163
189
 
164
190
  ...XlsxTransformerColumnHelper.creatColumnsForParents(),
165
191
 
@@ -216,32 +242,6 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformMember> = {
216
242
  name: $t(`fb35c140-e936-4e91-aa92-ef4dfc59fb51`),
217
243
  columns: [
218
244
  ...baseMemberColumns,
219
- {
220
- id: 'organization',
221
- name: $t(`afd7843d-f355-445b-a158-ddacf469a5b1`),
222
- width: 40,
223
- getValue: (member: PlatformMember) => {
224
- const organizations = member.filterOrganizations({ currentPeriod: true, types: [GroupType.Membership] });
225
- const str = Formatter.joinLast(organizations.map(o => o.name).sort(), ', ', ' ' + $t(`c1843768-2bf4-42f2-baa4-42f49028463d`) + ' ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f');
226
-
227
- return {
228
- value: str,
229
- };
230
- },
231
- },
232
- {
233
- id: 'uri',
234
- name: $t(`27cfaf26-6b88-4ebc-a50a-627a9f0f9e64`),
235
- width: 30,
236
- getValue: (member: PlatformMember) => {
237
- const organizations = member.filterOrganizations({ currentPeriod: true, types: [GroupType.Membership] });
238
- const str = Formatter.joinLast(organizations.map(o => o.uri).sort(), ', ', ' ' + $t(`c1843768-2bf4-42f2-baa4-42f49028463d`) + ' ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f');
239
-
240
- return {
241
- value: str,
242
- };
243
- },
244
- },
245
245
  {
246
246
  id: 'group',
247
247
  name: $t(`0c230001-c3be-4a8e-8eab-23dc3fd96e52`),
@@ -1,10 +1,10 @@
1
1
  import { AutoEncoderPatchType, PatchMap } from '@simonbackx/simple-encoding';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { BalanceItem, CachedBalance, Document, Email, EmailTemplate, Event, EventNotification, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
4
- import { AccessRight, EmailTemplate as EmailTemplateStruct, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, ReceivableBalanceType, RecordSettings, ResourcePermissions } from '@stamhoofd/structures';
4
+ import { AccessRight, EmailTemplate as EmailTemplateStruct, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, ReceivableBalanceType, RecordSettings, ResourcePermissions, UitpasNumberDetails, UitpasSocialTariff, UitpasSocialTariffStatus } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
- import { MemberRecordStore } from '../services/MemberRecordStore';
7
- import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
6
+ import { MemberRecordStore } from '../services/MemberRecordStore.js';
7
+ import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess.js';
8
8
 
9
9
  /**
10
10
  * One class with all the responsabilities of checking permissions to each resource in the system by a given user, possibly in an organization context.
@@ -1478,7 +1478,7 @@ export class AdminPermissionChecker {
1478
1478
  // Has financial read access?
1479
1479
  if (!options?.forAdminCartCalculation && !await this.hasFinancialMemberAccess(member, PermissionLevel.Read)) {
1480
1480
  cloned.details.requiresFinancialSupport = null;
1481
- cloned.details.uitpasNumber = null;
1481
+ cloned.details.uitpasNumberDetails = null;
1482
1482
  cloned.outstandingBalance = 0;
1483
1483
 
1484
1484
  for (const registration of cloned.registrations) {
@@ -1507,7 +1507,7 @@ export class AdminPermissionChecker {
1507
1507
  /**
1508
1508
  * Only for creating new members
1509
1509
  */
1510
- filterMemberPut(member: MemberWithRegistrations, data: MemberWithRegistrationsBlob, options: {asUserManager: boolean}) {
1510
+ filterMemberPut(member: MemberWithRegistrations, data: MemberWithRegistrationsBlob, options: { asUserManager: boolean }) {
1511
1511
  if (options.asUserManager || STAMHOOFD.userMode === 'platform') {
1512
1512
  // A user manager cannot choose the member number + in platform mode, nobody can choose the member number
1513
1513
  data.details.memberNumber = null;
@@ -1515,7 +1515,12 @@ export class AdminPermissionChecker {
1515
1515
 
1516
1516
  // Do not allow setting the security code
1517
1517
  data.details.securityCode = null;
1518
- }
1518
+ if (data.details.uitpasNumberDetails) {
1519
+ data.details.uitpasNumberDetails.socialTariff = UitpasSocialTariff.create({
1520
+ status: UitpasSocialTariffStatus.Unknown,
1521
+ });
1522
+ }
1523
+ }
1519
1524
 
1520
1525
  async filterMemberPatch(member: MemberWithRegistrations, data: AutoEncoderPatchType<MemberWithRegistrationsBlob>): Promise<AutoEncoderPatchType<MemberWithRegistrationsBlob>> {
1521
1526
  if (!data.details) {
@@ -1537,10 +1542,9 @@ export class AdminPermissionChecker {
1537
1542
  });
1538
1543
  }
1539
1544
 
1540
-
1541
1545
  const hasRecordAnswers = !!data.details.recordAnswers;
1542
1546
  const hasNotes = data.details.notes !== undefined;
1543
- const isSetFinancialSupportTrue = data.details.shouldApplyReducedPrice;
1547
+ const isSetFinancialSupportTrue = data.details.didSetManualFinancialSupport;
1544
1548
 
1545
1549
  if (data.details.securityCode !== undefined || data.details.trackingYear !== undefined) {
1546
1550
  const hasFullAccess = await this.canAccessMember(member, PermissionLevel.Full);
@@ -1561,6 +1565,22 @@ export class AdminPermissionChecker {
1561
1565
  }
1562
1566
  }
1563
1567
 
1568
+ if (data.details.uitpasNumberDetails && data.details.uitpasNumberDetails.socialTariff !== undefined) {
1569
+ if (data.details.uitpasNumberDetails.uitpasNumber === undefined) {
1570
+ data.details.uitpasNumberDetails = undefined;
1571
+ }
1572
+ else if (data.details.uitpasNumberDetails.uitpasNumber !== member.details.uitpasNumberDetails?.uitpasNumber) {
1573
+ // if uitpas number did change -> status should be reset
1574
+ data.details.uitpasNumberDetails = UitpasNumberDetails.create({
1575
+ uitpasNumber: data.details.uitpasNumberDetails.uitpasNumber,
1576
+ });
1577
+ }
1578
+ else {
1579
+ // if uitpas number did not change
1580
+ data.details.uitpasNumberDetails.socialTariff = member.details.uitpasNumberDetails?.socialTariff;
1581
+ }
1582
+ }
1583
+
1564
1584
  if (hasRecordAnswers) {
1565
1585
  if (!(data.details.recordAnswers instanceof PatchMap)) {
1566
1586
  throw new SimpleError({
@@ -1594,7 +1614,7 @@ export class AdminPermissionChecker {
1594
1614
  // A user manager cannot choose the member number + in platform mode, nobody can choose the member number
1595
1615
  delete data.details.memberNumber;
1596
1616
  }
1597
-
1617
+
1598
1618
  if (hasNotes && isUserManager && !(await this.canAccessMember(member, PermissionLevel.Full))) {
1599
1619
  throw new SimpleError({
1600
1620
  code: 'permission_denied',
@@ -1633,7 +1653,7 @@ export class AdminPermissionChecker {
1633
1653
  }
1634
1654
 
1635
1655
  if (!isUserManager) {
1636
- if (data.details.uitpasNumber) {
1656
+ if (data.details.uitpasNumberDetails) {
1637
1657
  throw new SimpleError({
1638
1658
  code: 'permission_denied',
1639
1659
  message: 'Je hebt geen toegangsrechten om het UiTPAS-nummer van dit lid aan te passen',
@@ -1,12 +1,12 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { AuditLog, BalanceItem, CachedBalance, Document, Event, EventNotification, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
- import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, MemberPlatformMembership as MemberPlatformMembershipStruct, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
2
+ import { AuditLog, BalanceItem, CachedBalance, Document, Event, EventNotification, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
+ import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
  import { Sorter } from '@stamhoofd/utility';
5
5
 
6
6
  import { SQL } from '@stamhoofd/sql';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
- import { BalanceItemService } from '../services/BalanceItemService';
9
- import { Context } from './Context';
8
+ import { BalanceItemService } from '../services/BalanceItemService.js';
9
+ import { Context } from './Context.js';
10
10
 
11
11
  /**
12
12
  * Builds authenticated structures for the current user
@@ -28,9 +28,10 @@ export class AuthenticatedStructures {
28
28
  }
29
29
 
30
30
  const { balanceItemPayments, balanceItems } = await Payment.loadBalanceItems(payments);
31
- const { registrations, orders } = await Payment.loadBalanceItemRelations(balanceItems);
32
31
 
33
32
  if (checkPermissions) {
33
+ const { registrations, orders } = await Payment.loadBalanceItemRelations(balanceItems);
34
+
34
35
  // Note: permission checking is moved here for performacne to avoid loading the data multiple times
35
36
  if (!(await Context.auth.canAccessBalanceItems(balanceItems, PermissionLevel.Read, { registrations, orders }))) {
36
37
  throw new SimpleError({
@@ -53,6 +54,24 @@ export class AuthenticatedStructures {
53
54
  }, includeSettlements);
54
55
  }
55
56
 
57
+ static async invoices(invoices: Invoice[]): Promise<InvoiceStruct[]> {
58
+ if (invoices.length === 0) {
59
+ return [];
60
+ }
61
+
62
+ const { invoicedBalanceItems } = await Invoice.loadBalanceItems(invoices);
63
+
64
+ return invoices.map((invoice) => {
65
+ const items = invoicedBalanceItems.filter(i => i.invoiceId === invoice.id);
66
+ return InvoiceStruct.create({
67
+ ...invoice,
68
+ items: items.map((item) => {
69
+ return InvoicedBalanceItem.create(item);
70
+ }),
71
+ });
72
+ });
73
+ }
74
+
56
75
  static async group(group: Group) {
57
76
  return (await this.groups([group]))[0];
58
77
  }
@@ -4,6 +4,7 @@ import { BalanceItem, BalanceItemPayment, Organization, Payment, StripeAccount,
4
4
  import { calculateVATPercentage, PaymentMethod, PaymentMethodHelper, PaymentStatus } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import Stripe from 'stripe';
7
+ import { passthroughFetch } from './passthroughFetch.js';
7
8
 
8
9
  export class StripeHelper {
9
10
  static get notConfiguredError() {
@@ -18,7 +19,16 @@ export class StripeHelper {
18
19
  if (!STAMHOOFD.STRIPE_SECRET_KEY) {
19
20
  throw this.notConfiguredError;
20
21
  }
21
- return new Stripe(STAMHOOFD.STRIPE_SECRET_KEY, { apiVersion: '2024-06-20', typescript: true, maxNetworkRetries: 0, timeout: 10000, stripeAccount: accountId ?? undefined });
22
+ return new Stripe(STAMHOOFD.STRIPE_SECRET_KEY, {
23
+ apiVersion: '2024-06-20',
24
+ typescript: true,
25
+ maxNetworkRetries: 0,
26
+ timeout: 10000,
27
+ stripeAccount: accountId ?? undefined,
28
+ httpClient: STAMHOOFD.environment === 'test'
29
+ ? Stripe.createFetchHttpClient(passthroughFetch)
30
+ : undefined,
31
+ });
22
32
  }
23
33
 
24
34
  static async saveChargeInfo(model: StripePaymentIntent | StripeCheckoutSession, charge: Stripe.Charge, payment: Payment) {
@@ -1,6 +1,7 @@
1
1
  import { Order, Payment, StripeCheckoutSession, StripePaymentIntent } from '@stamhoofd/models';
2
2
  import { Settlement } from '@stamhoofd/structures';
3
3
  import Stripe from 'stripe';
4
+ import { passthroughFetch } from './passthroughFetch.js';
4
5
 
5
6
  export class StripePayoutChecker {
6
7
  private stripe: Stripe;
@@ -14,6 +15,9 @@ export class StripePayoutChecker {
14
15
  maxNetworkRetries: 1,
15
16
  timeout: 10000,
16
17
  stripeAccount,
18
+ httpClient: STAMHOOFD.environment === 'test'
19
+ ? Stripe.createFetchHttpClient(passthroughFetch)
20
+ : undefined,
17
21
  });
18
22
 
19
23
  this.stripePlatform = new Stripe(
@@ -22,6 +26,9 @@ export class StripePayoutChecker {
22
26
  typescript: true,
23
27
  maxNetworkRetries: 1,
24
28
  timeout: 10000,
29
+ httpClient: STAMHOOFD.environment === 'test'
30
+ ? Stripe.createFetchHttpClient(passthroughFetch)
31
+ : undefined,
25
32
  });
26
33
  }
27
34
 
@@ -73,10 +73,6 @@ export class UitpasTokenRepository {
73
73
  }
74
74
 
75
75
  private static async getModelFromDb(organizationId: string | null) {
76
- let model = await UitpasClientCredential.select().where('organizationId', organizationId).first(false);
77
- if (model) {
78
- return model; // found in database
79
- }
80
76
  if (organizationId === null) {
81
77
  // platform client id and secret are not yet in the database, but should be configured in the environment variables
82
78
  if (!STAMHOOFD.UITPAS_API_CLIENT_ID || !STAMHOOFD.UITPAS_API_CLIENT_SECRET) {
@@ -86,12 +82,18 @@ export class UitpasTokenRepository {
86
82
  human: $t('71a8218b-c58e-4e95-9626-551b80eb8367'),
87
83
  });
88
84
  }
89
- model = new UitpasClientCredential();
85
+ const model = new UitpasClientCredential();
90
86
  model.clientId = STAMHOOFD.UITPAS_API_CLIENT_ID;
91
87
  model.clientSecret = STAMHOOFD.UITPAS_API_CLIENT_SECRET;
92
88
  model.organizationId = null; // null means platform
93
89
  return model;
94
90
  }
91
+
92
+ const model = await UitpasClientCredential.select().where('organizationId', organizationId).first(false);
93
+ if (model) {
94
+ return model; // found in database
95
+ }
96
+
95
97
  return null; // not found in database
96
98
  }
97
99
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A passthrough wrapper around the global `fetch` function.
3
+ *
4
+ * This is necessary because passing `fetch` directly to
5
+ * `Stripe.createFetchHttpClient` does not guarantee correct `this` binding
6
+ * in all environments (such as Node.js or test runners).
7
+ *
8
+ * In particular, in certain test environments (e.g., using MSW, Nock, or when
9
+ * mocks are applied), passing `fetch` point-free (i.e., just `fetch`) may
10
+ * result in `this` being undefined, leading to unexpected errors like
11
+ * `TypeError: Illegal invocation`.
12
+ *
13
+ * Wrapping `fetch` inside a new function (`passthroughFetch`) ensures:
14
+ * - Correct argument forwarding
15
+ * - Proper binding of `this` context (implicitly bound to `globalThis`)
16
+ * - More predictable async behavior across environments
17
+ *
18
+ * See also: https://github.com/nock/nock/issues/2785#issuecomment-2427076034
19
+ *
20
+ * @param args - The arguments to pass to `fetch`, matching
21
+ * `Parameters<typeof fetch>`.
22
+ * @returns A `Promise<Response>` from calling the global `fetch`.
23
+ */
24
+ export const passthroughFetch = (...args: Parameters<typeof fetch>) => fetch(...args);
@@ -0,0 +1,149 @@
1
+ import { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
+ import { isSimpleError, SimpleError } from '@simonbackx/simple-errors';
3
+ import { Member } from '@stamhoofd/models';
4
+ import { MemberDetails, ReviewTimes, UitpasSocialTariff, UitpasSocialTariffStatus } from '@stamhoofd/structures';
5
+ import { GetPassResponse } from '../services/uitpas/PassholderEndpoints.js';
6
+ import { UitpasService } from '../services/uitpas/UitpasService.js';
7
+ import { throwIfInvalidUitpasNumber } from '../services/uitpas/checkUitpasNumbers.js';
8
+
9
+ /**
10
+ * Updates the social tariff if an uitpas number is set.
11
+ * @param details
12
+ * @returns whether the social tariff was updated
13
+ */
14
+ export async function updateMemberDetailsUitpasNumber(details: MemberDetails): Promise<boolean> {
15
+ if (!details.uitpasNumberDetails) {
16
+ return false;
17
+ }
18
+
19
+ const uitpasNumber = details.uitpasNumberDetails.uitpasNumber;
20
+ throwIfInvalidUitpasNumber(uitpasNumber);
21
+ const result = await UitpasService.getPassByUitpasNumber(uitpasNumber);
22
+ const socialTariff = uitpasApiResponseToSocialTariff(result);
23
+ details.uitpasNumberDetails.socialTariff = socialTariff;
24
+ return true;
25
+ }
26
+
27
+ /**
28
+ * Updates the social tariff if an uitpas number is set.
29
+ * Removes uitpas number reviewed on error.
30
+ * @param details
31
+ * @returns whether the social tariff was updated
32
+ */
33
+ export async function updateMemberDetailsUitpasNumberForPatch(memberId: string, details: MemberDetails, previousUitpasNumber: string | null): Promise<boolean> {
34
+ if (!details.uitpasNumberDetails) {
35
+ return false;
36
+ }
37
+
38
+ const wasActive = details.uitpasNumberDetails.isActive;
39
+
40
+ let isUpdated = false;
41
+
42
+ try {
43
+ isUpdated = await updateMemberDetailsUitpasNumber(details);
44
+ }
45
+ catch (error: any) {
46
+ const isUitpasEqual = previousUitpasNumber !== null && previousUitpasNumber === details.uitpasNumberDetails.uitpasNumber;
47
+
48
+ if (isSimpleError(error)) {
49
+ if (error.hasCode('https://api.publiq.be/probs/uitpas/pass-not-found') || error.hasCode('https://api.publiq.be/probs/uitpas/invalid-uitpas-number')) {
50
+ if (isUitpasEqual) {
51
+ // set the status of the social tariff to unknown if the uitpas number did not change and the number is unknown
52
+ const member = await Member.getByID(memberId);
53
+ if (member && member.details.uitpasNumberDetails) {
54
+ member.details.uitpasNumberDetails.socialTariff = UitpasSocialTariff.create({
55
+ status: UitpasSocialTariffStatus.Unknown,
56
+ });
57
+ member.details.reviewTimes.removeReview('uitpasNumber');
58
+
59
+ await member.save();
60
+ }
61
+ }
62
+
63
+ // always throw an error if the number is unknown by the uitpas api
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ // do not throw if the number did not change
69
+ if (isUitpasEqual) {
70
+ console.error(`Catched error while updating social tariff for member (uitpas number did not change) ${memberId}:`, error.message);
71
+ return false;
72
+ }
73
+
74
+ throw error;
75
+ }
76
+
77
+ // force a review if the social tariff changed from active to not active (and the number did not change)
78
+ if (isUpdated
79
+ // is uitpas equal
80
+ && previousUitpasNumber !== null && previousUitpasNumber === details.uitpasNumberDetails.uitpasNumber
81
+ // did change from active to not active
82
+ && wasActive && !details.uitpasNumberDetails.isActive) {
83
+ // force a review
84
+ details.reviewTimes.removeReview('uitpasNumber');
85
+ }
86
+
87
+ return isUpdated;
88
+ }
89
+
90
+ export function uitpasApiResponseToSocialTariff(response: GetPassResponse): UitpasSocialTariff {
91
+ let endDate: Date | null = null;
92
+
93
+ if (response.socialTariff.endDate) {
94
+ try {
95
+ endDate = new Date(response.socialTariff.endDate);
96
+ }
97
+ catch (e) {
98
+ console.error(e);
99
+ console.error('endDate: ', response.socialTariff.endDate);
100
+
101
+ // prevent unreadable error message if invalid end date
102
+ throw new SimpleError({
103
+ code: 'invalid_data',
104
+ message: 'Invalid social tariff end date',
105
+ human: $t('abc1a491-8038-4435-9007-c77cc00c0886'),
106
+ });
107
+ }
108
+ }
109
+
110
+ const status = uitpasSocialTariffStatusToEnum(response.socialTariff.status);
111
+
112
+ return UitpasSocialTariff.create({
113
+ status,
114
+ endDate,
115
+ updatedAt: new Date(),
116
+ });
117
+ }
118
+
119
+ export function didUitpasReviewChange(reviewTimesPatch: ReviewTimes | AutoEncoderPatchType<ReviewTimes> | undefined, originalReviewTimes: ReviewTimes): boolean {
120
+ const newReview = reviewTimesPatch?.times.find(t => t.name === 'uitpasNumber');
121
+ if (!newReview) {
122
+ return false;
123
+ }
124
+
125
+ const lastReviewTime = originalReviewTimes.getLastReview('uitpasNumber');
126
+ if (!lastReviewTime) {
127
+ return true;
128
+ }
129
+
130
+ return newReview.reviewedAt.getTime() !== lastReviewTime.getTime();
131
+ }
132
+
133
+ /**
134
+ * Prevent bad input from uitpas api
135
+ * @param status
136
+ * @returns
137
+ */
138
+ function uitpasSocialTariffStatusToEnum(status: 'ACTIVE' | 'EXPIRED' | 'NONE' | 'UNKNOWN'): UitpasSocialTariffStatus {
139
+ switch (status) {
140
+ case 'ACTIVE':
141
+ return UitpasSocialTariffStatus.Active;
142
+ case 'EXPIRED':
143
+ return UitpasSocialTariffStatus.Expired;
144
+ case 'NONE':
145
+ return UitpasSocialTariffStatus.None;
146
+ default:
147
+ return UitpasSocialTariffStatus.Unknown;
148
+ }
149
+ }