@stamhoofd/backend 2.89.2 → 2.90.1

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 (66) hide show
  1. package/package.json +12 -11
  2. package/src/boot.ts +2 -0
  3. package/src/crons/balance-emails.ts +1 -6
  4. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +1 -1
  5. package/src/endpoints/admin/organizations/SearchUitpasOrganizersEndpoint.ts +42 -0
  6. package/src/endpoints/global/audit-logs/GetAuditLogsEndpoint.ts +4 -4
  7. package/src/endpoints/global/events/GetEventNotificationsEndpoint.ts +3 -3
  8. package/src/endpoints/global/events/GetEventsEndpoint.ts +2 -2
  9. package/src/endpoints/global/events/PatchEventsEndpoint.ts +23 -2
  10. package/src/endpoints/global/groups/GetGroupsEndpoint.ts +6 -6
  11. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +8 -6
  12. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +2 -2
  13. package/src/endpoints/global/platform/GetPlatformEndpoint.ts +1 -0
  14. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +10 -8
  15. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +11 -0
  16. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +2 -2
  17. package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +3 -6
  18. package/src/endpoints/organization/dashboard/organization/GetUitpasClientIdEndpoint.ts +38 -0
  19. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +31 -1
  20. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +108 -0
  21. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +1 -1
  22. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -2
  23. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +1 -1
  24. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +9 -1
  25. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +3 -2
  26. package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +1 -1
  27. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +2 -9
  28. package/src/endpoints/organization/webshops/GetWebshopEndpoint.ts +1 -7
  29. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +68 -1
  30. package/src/endpoints/organization/webshops/RetrieveUitpasSocialTariffPriceEndpoint.ts +27 -20
  31. package/src/helpers/AdminPermissionChecker.ts +129 -22
  32. package/src/helpers/AuthenticatedStructures.ts +13 -10
  33. package/src/helpers/Context.ts +1 -1
  34. package/src/helpers/UitpasTokenRepository.ts +125 -35
  35. package/src/helpers/ViesHelper.ts +2 -1
  36. package/src/seeds/0000000002-clear-stamhoofd-email-templates.ts +13 -0
  37. package/src/seeds/0000000003-default-email-templates.ts +20 -0
  38. package/src/seeds/data/default-email-templates.sql +55 -0
  39. package/src/services/RegistrationService.ts +6 -4
  40. package/src/services/uitpas/UitpasService.test.ts +23 -0
  41. package/src/services/uitpas/UitpasService.ts +222 -0
  42. package/src/services/uitpas/checkPermissionsFor.ts +111 -0
  43. package/src/services/uitpas/checkUitpasNumbers.ts +180 -0
  44. package/src/services/uitpas/getSocialTariffForEvent.ts +90 -0
  45. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +181 -0
  46. package/src/services/uitpas/searchUitpasOrganizers.ts +93 -0
  47. package/src/sql-filters/audit-logs.ts +26 -6
  48. package/src/sql-filters/balance-item-payments.ts +23 -8
  49. package/src/sql-filters/base-registration-filter-compilers.ts +74 -23
  50. package/src/sql-filters/documents.ts +46 -13
  51. package/src/sql-filters/event-notifications.ts +48 -12
  52. package/src/sql-filters/events.ts +62 -26
  53. package/src/sql-filters/groups.ts +12 -12
  54. package/src/sql-filters/members.ts +325 -137
  55. package/src/sql-filters/orders.ts +96 -48
  56. package/src/sql-filters/organization-registration-periods.ts +16 -4
  57. package/src/sql-filters/organizations.ts +105 -99
  58. package/src/sql-filters/payments.ts +97 -47
  59. package/src/sql-filters/receivable-balances.ts +56 -19
  60. package/src/sql-filters/registration-periods.ts +16 -4
  61. package/src/sql-filters/registrations.ts +2 -2
  62. package/src/sql-filters/shared/EmailRelationFilterCompilers.ts +14 -8
  63. package/src/sql-filters/tickets.ts +26 -6
  64. package/tests/e2e/charge-members.test.ts +1 -0
  65. package/src/helpers/UitpasNumberValidator.test.ts +0 -23
  66. package/src/helpers/UitpasNumberValidator.ts +0 -185
@@ -6,7 +6,7 @@ import { GroupService } from './GroupService';
6
6
  import { PlatformMembershipService } from './PlatformMembershipService';
7
7
  import { QueueHandler } from '@stamhoofd/queues';
8
8
  import { Formatter } from '@stamhoofd/utility';
9
- import { encodeObject, patchContainsChanges } from '@simonbackx/simple-encoding';
9
+ import { encodeObject } from '@simonbackx/simple-encoding';
10
10
 
11
11
  export const RegistrationService = {
12
12
  async markValid(registrationId: string) {
@@ -28,9 +28,11 @@ export const RegistrationService = {
28
28
 
29
29
  await PlatformMembershipService.updateMembershipsForId(registration.memberId);
30
30
 
31
- await registration.sendEmailTemplate({
32
- type: EmailTemplateType.RegistrationConfirmation,
33
- });
31
+ if (registration.sendConfirmationEmail) {
32
+ await registration.sendEmailTemplate({
33
+ type: EmailTemplateType.RegistrationConfirmation,
34
+ });
35
+ }
34
36
 
35
37
  const member = await Member.getByID(registration.memberId);
36
38
  if (member) {
@@ -0,0 +1,23 @@
1
+ import { STExpect } from '@stamhoofd/test-utils';
2
+ import { UitpasService } from './UitpasService';
3
+
4
+ describe.skip('UitpasService', () => {
5
+ it('should validate a correct Uitpas number with kansentarief', async () => {
6
+ const validNumbers = ['0900000067513'];
7
+ await expect(UitpasService.checkUitpasNumbers(validNumbers)).resolves.toBeUndefined();
8
+ });
9
+
10
+ it('should throw an error for an invalid Uitpas number', async () => {
11
+ const invalidNumbers = ['1234567890123'];
12
+ await expect(UitpasService.checkUitpasNumbers(invalidNumbers)).rejects.toThrow(
13
+ STExpect.simpleError({ code: 'unsuccessful_but_expected_response_retrieving_pass_by_uitpas_number' }),
14
+ );
15
+ });
16
+
17
+ it('should throw an error for a Uitpas number with kansentarief expired', async () => {
18
+ const expiredNumbers = ['0900000058918'];
19
+ await expect(UitpasService.checkUitpasNumbers(expiredNumbers)).rejects.toThrow(
20
+ STExpect.simpleError({ code: 'uitpas_number_issue' }),
21
+ );
22
+ });
23
+ });
@@ -0,0 +1,222 @@
1
+ import { Model } from '@simonbackx/simple-database';
2
+ import { Order, WebshopUitpasNumber } from '@stamhoofd/models';
3
+ import { OrderStatus, UitpasClientCredentialsStatus, UitpasOrganizersResponse } from '@stamhoofd/structures';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { UitpasTokenRepository } from '../../helpers/UitpasTokenRepository';
6
+ import { searchUitpasOrganizers } from './searchUitpasOrganizers';
7
+ import { checkPermissionsFor } from './checkPermissionsFor';
8
+ import { checkUitpasNumbers } from './checkUitpasNumbers';
9
+ import { getSocialTariffForEvent } from './getSocialTariffForEvent';
10
+ import { getSocialTariffForUitpasNumbers } from './getSocialTariffForUitpasNumbers';
11
+
12
+ function shouldReserveUitpasNumbers(status: OrderStatus): boolean {
13
+ return status !== OrderStatus.Canceled && status !== OrderStatus.Deleted;
14
+ }
15
+
16
+ function mapUitpasNumbersToProducts(order: Order): Map<string, string[]> {
17
+ const items = order.data.cart.items;
18
+ const productIdToUitpasNumbers: Map<string, string[]> = new Map();
19
+ for (const item of items) {
20
+ const a = productIdToUitpasNumbers.get(item.product.id);
21
+ if (a) {
22
+ a.push(...item.uitpasNumbers.map(p => p.uitpasNumber));
23
+ }
24
+ else {
25
+ productIdToUitpasNumbers.set(item.product.id, [...item.uitpasNumbers.map(p => p.uitpasNumber)]); // make a copy
26
+ }
27
+ }
28
+ return productIdToUitpasNumbers;
29
+ }
30
+
31
+ function areUitpasNumbersChanged(oldOrder: Order, newOrder: Order): boolean {
32
+ const oldMap = mapUitpasNumbersToProducts(oldOrder);
33
+ const newMap = mapUitpasNumbersToProducts(newOrder);
34
+ if (oldMap.size !== newMap.size) {
35
+ return true;
36
+ }
37
+ for (const [productId, uitpasNumbers] of oldMap.entries()) {
38
+ const newUitpasNumbers = newMap.get(productId);
39
+ if (!newUitpasNumbers) {
40
+ return true;
41
+ }
42
+ if (newUitpasNumbers.length !== uitpasNumbers.length) {
43
+ return true;
44
+ }
45
+ for (const uitpasNumber of uitpasNumbers) {
46
+ if (!newUitpasNumbers.includes(uitpasNumber)) {
47
+ return true;
48
+ }
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+
54
+ export class UitpasService {
55
+ static listening = false;
56
+
57
+ static async updateUitpasNumbers(order: Order) {
58
+ await this.deleteUitpasNumbers(order);
59
+ await this.createUitpasNumbers(order);
60
+ }
61
+
62
+ static async createUitpasNumbers(order: Order) {
63
+ const mappedUitpasNumbers = mapUitpasNumbersToProducts(order); // productId -> Set of uitpas numbers
64
+ // add to DB
65
+ const insert = WebshopUitpasNumber.insert();
66
+ insert.columns(
67
+ 'id',
68
+ 'webshopId',
69
+ 'orderId',
70
+ 'productId',
71
+ 'uitpasNumber',
72
+ );
73
+ const rows = [...mappedUitpasNumbers].flatMap(([productId, uitpasNumbers]) => {
74
+ return uitpasNumbers.map(uitpasNumber => [
75
+ uuidv4(),
76
+ order.webshopId,
77
+ order.id,
78
+ productId,
79
+ uitpasNumber,
80
+ ]);
81
+ });
82
+ if (rows.length === 0) {
83
+ // No uitpas numbers to insert, skipping
84
+ return;
85
+ }
86
+ insert.values(...rows);
87
+ await insert.insert();
88
+ }
89
+
90
+ static async deleteUitpasNumbers(order: Order) {
91
+ await WebshopUitpasNumber.delete().where('webshopId', order.webshopId)
92
+ .andWhere('orderId', order.id);
93
+ }
94
+
95
+ static listen() {
96
+ if (this.listening) {
97
+ return;
98
+ }
99
+ this.listening = true;
100
+ Model.modelEventBus.addListener(this, async (event) => {
101
+ try {
102
+ if (event.model instanceof Order) {
103
+ // event.type ==='deteled' -> not needed as foreign key will delete the order
104
+ if (event.type === 'created' && shouldReserveUitpasNumbers(event.model.status)) {
105
+ await this.createUitpasNumbers(event.model);
106
+ return;
107
+ }
108
+ if (event.type === 'updated') {
109
+ if (event.changedFields.status) {
110
+ const statusBefore = event.originalFields.status as OrderStatus;
111
+ const statusAfter = event.changedFields.status as OrderStatus;
112
+ const shouldReserveAfter = shouldReserveUitpasNumbers(statusAfter);
113
+ if (shouldReserveUitpasNumbers(statusBefore) !== shouldReserveAfter) {
114
+ if (shouldReserveAfter) {
115
+ await this.createUitpasNumbers(event.model);
116
+ return;
117
+ }
118
+ await this.deleteUitpasNumbers(event.model);
119
+ return;
120
+ }
121
+ }
122
+ if (event.changedFields.data) {
123
+ const oldOrder = event.getOldModel() as Order;
124
+ if (areUitpasNumbersChanged(oldOrder, event.model)) {
125
+ await this.updateUitpasNumbers(event.model);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ catch (e) {
132
+ console.error('Failed to update UiTPAS-numbers after order update', e);
133
+ }
134
+ });
135
+ }
136
+
137
+ static async getSocialTariffForUitpasNumbers(organisationId: string, uitpasNumbers: string[], basePrice: number, uitpasEventUrl: string) {
138
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/list-tariffs
139
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(organisationId);
140
+ return await getSocialTariffForUitpasNumbers(access_token, uitpasNumbers, basePrice, uitpasEventUrl);
141
+ }
142
+
143
+ static async getSocialTariffForEvent(organisationId: string, basePrice: number, uitpasEventUrl: string) {
144
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/get-a-tariff-static
145
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(organisationId);
146
+ return await getSocialTariffForEvent(access_token, basePrice, uitpasEventUrl);
147
+ }
148
+
149
+ static async registerTicketSales() {
150
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/create-a-ticket-sale
151
+ }
152
+
153
+ static async cancelTicketSale() {
154
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/delete-a-ticket-sale
155
+ }
156
+
157
+ static async getTicketSales() {
158
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/list-ticket-sales
159
+ }
160
+
161
+ static async registerAttendance() {
162
+ // https://api-test.uitpas.be/checkins
163
+ }
164
+
165
+ static searchUitpasEvents(organizationId: string, uitpasOrganizerId: string, textQuery?: string) {
166
+ // input = client id of organization (never platform0 & uitpasOrganizerId
167
+ // https://docs.publiq.be/docs/uitpas/events/searching#searching-for-uitpas-events-of-one-specific-organizer
168
+ }
169
+
170
+ static async searchUitpasOrganizers(name: string): Promise<UitpasOrganizersResponse> {
171
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/list-organizers
172
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(); // uses platform credentials
173
+ return await searchUitpasOrganizers(access_token, name);
174
+ }
175
+
176
+ static async checkPermissionsFor(organizationId: string | null, uitpasOrganizerId: string): Promise<{
177
+ status: UitpasClientCredentialsStatus;
178
+ human?: string;
179
+ }> {
180
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/list-permissions
181
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(organizationId);
182
+ return await checkPermissionsFor(access_token, organizationId, uitpasOrganizerId);
183
+ }
184
+
185
+ /**
186
+ * Returns the client ID if it is configured for the organization, otherwise an empty string. Empty strings means no client ID and secret configured.
187
+ * @param organisationId
188
+ * @returns clientId or empty string if not configured
189
+ */
190
+ static async getClientIdFor(organizationId: string | null): Promise<string> {
191
+ // Get the uitpas client credentials for the organization
192
+ return await UitpasTokenRepository.getClientIdFor(organizationId);
193
+ }
194
+
195
+ /**
196
+ * Checks multiple uitpas numbers
197
+ * If any of the uitpas numbers is invalid, it will throw a SimpleErrors instance with all errors.
198
+ * The field of the error will be the index of the uitpas number in the array.
199
+ * @param uitpasNumbers The uitpas numbers to check
200
+ */
201
+ static async checkUitpasNumbers(uitpasNumbers: string[]) {
202
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/get-a-pass
203
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(); // use platform credentials
204
+ return await checkUitpasNumbers(access_token, uitpasNumbers);
205
+ }
206
+
207
+ /**
208
+ * Store the uitpas client credentials if they are valid
209
+ * @param organizationId null for platform
210
+ * @param clientId
211
+ * @param clientSecret
212
+ * @returns wether the credentials were valid and thus stored successfully
213
+ */
214
+ static async storeIfValid(organizationId: string | null, clientId: string, clientSecret: string): Promise<boolean> {
215
+ return await UitpasTokenRepository.storeIfValid(organizationId, clientId, clientSecret);
216
+ }
217
+
218
+ static async clearClientCredentialsFor(organizationId: string | null) {
219
+ // Clear the uitpas client credentials for the organization
220
+ await UitpasTokenRepository.clearClientCredentialsFor(organizationId);
221
+ }
222
+ };
@@ -0,0 +1,111 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { UitpasClientCredentialsStatus } from '@stamhoofd/structures';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+
5
+ type PermissionsResponse = Array<{
6
+ organizer: {
7
+ id: string;
8
+ name: string;
9
+ };
10
+ permissions: Array<string>;
11
+ }>;
12
+
13
+ function assertIsPermissionsResponse(json: unknown): asserts json is PermissionsResponse {
14
+ if (!Array.isArray(json)) {
15
+ console.error('Invalid PermissionsResponse:', json);
16
+ throw new SimpleError({
17
+ code: 'invalid_permissions_response',
18
+ message: 'Invalid response format for permissions',
19
+ human: $t('Er is iets misgelopen bij het ophalen van je rechten. Probeer het later opnieuw.'),
20
+ });
21
+ }
22
+
23
+ for (const item of json) {
24
+ if (
25
+ typeof item !== 'object' || item === null
26
+ || !('organizer' in item)
27
+ || typeof (item as any).organizer !== 'object' || (item as any).organizer === null
28
+ || typeof (item as any).organizer.id !== 'string'
29
+ || typeof (item as any).organizer.name !== 'string'
30
+ || !('permissions' in item)
31
+ || !Array.isArray((item as any).permissions)
32
+ || !(item as any).permissions.every((perm: unknown) => typeof perm === 'string')
33
+ ) {
34
+ console.error('Invalid PermissionsResponse:', json);
35
+ throw new SimpleError({
36
+ code: 'invalid_permissions_response',
37
+ message: 'Invalid response format for permissions',
38
+ human: $t('Er is iets misgelopen bij het ophalen van je rechten. Probeer het later opnieuw.'),
39
+ });
40
+ }
41
+ }
42
+ }
43
+
44
+ export async function checkPermissionsFor(access_token: string, organizationId: string | null, uitpasOrganizerId: string) {
45
+ const url = 'https://api-test.uitpas.be/permissions';
46
+ const myHeaders = new Headers();
47
+ myHeaders.append('Authorization', 'Bearer ' + access_token);
48
+ myHeaders.append('Accept', 'application/json');
49
+ const response = await fetch(url, {
50
+ method: 'GET',
51
+ headers: myHeaders,
52
+ }).catch(() => {
53
+ // Handle network errors
54
+ throw new SimpleError({
55
+ code: 'uitpas_unreachable_checking_permissions',
56
+ message: `Network issue when checking UiTPAS permissions`,
57
+ human: $t(`We konden UiTPAS niet bereiken om de rechten te controleren. Probeer het later opnieuw.`),
58
+ });
59
+ });
60
+ if (!response.ok) {
61
+ console.error(`Unsuccessful response whe n checking UiTPAS permissions for organization with id ${organizationId}:`, response.statusText);
62
+ throw new SimpleError({
63
+ code: 'unsuccessful_response_checking_permissions',
64
+ message: `Unsuccesful response when checking UiTPAS permissions`,
65
+ human: $t(`Er is een fout opgetreden bij het verbinden met UiTPAS. Probeer het later opnieuw.`),
66
+ });
67
+ }
68
+ const json = await response.json().catch(() => {
69
+ // Handle JSON parsing errors
70
+ throw new SimpleError({
71
+ code: 'invalid_json_checking_permissions',
72
+ message: `Invalid json when checking UiTPAS permissions`,
73
+ human: $t(`Er is een fout opgetreden bij het communiceren met UiTPAS. Probeer het later opnieuw.`),
74
+ });
75
+ });
76
+ assertIsPermissionsResponse(json);
77
+ const neededPermissions = organizationId
78
+ ? [{
79
+ permission: 'PASSES_READ',
80
+ human: 'Basis UiTPAS informatie ophalen met UiTPAS nummer',
81
+ }]
82
+ : [{
83
+ permission: 'TARIFFS_READ',
84
+ human: 'Tarieven opvragen',
85
+ }, {
86
+ permission: 'TICKETSALES_REGISTER',
87
+ human: 'Ticketsales registreren',
88
+ }, {
89
+ permission: 'TICKETSALES_SEARCH',
90
+ human: 'Ticketsales zoeken',
91
+ }];
92
+ const item = json.find(item => item.organizer.id === uitpasOrganizerId);
93
+ if (!item) {
94
+ const organizers = Formatter.joinLast(json.map(i => i.organizer.name), ', ', ' ' + $t(' en ') + ' ');
95
+ return {
96
+ status: UitpasClientCredentialsStatus.NoPermissions,
97
+ human: $t('Jouw UiTPAS-integratie heeft geen toegansrechten tot de geselecteerde UiTPAS-organisator, maar wel tot ') + organizers,
98
+ };
99
+ }
100
+ const missingPermissions = neededPermissions.filter(needed => !item.permissions.includes(needed.permission));
101
+ if (missingPermissions.length > 0) {
102
+ const missingPermissionsHuman = Formatter.joinLast(missingPermissions.map(p => p.human), ', ', ' ' + $t(' en ') + ' ');
103
+ return {
104
+ status: UitpasClientCredentialsStatus.MissingPermissions,
105
+ human: $t('Jouw UiTPAS-integratie mist de volgende toegangsrechten voor de geselecteerde UiTPAS-organisator: ') + missingPermissionsHuman,
106
+ };
107
+ }
108
+ return {
109
+ status: UitpasClientCredentialsStatus.Ok,
110
+ };
111
+ }
@@ -0,0 +1,180 @@
1
+ import { isSimpleError, isSimpleErrors, SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
2
+ import { DataValidator } from '@stamhoofd/utility';
3
+
4
+ type UitpasNumberSuccessfulResponse = {
5
+ socialTariff: {
6
+ status: 'ACTIVE' | 'EXPIRED' | 'NONE';
7
+ };
8
+ messages?: Array<{
9
+ text: string;
10
+ }>;
11
+ };
12
+
13
+ type UitpasNumberErrorResponse = {
14
+ title: string; // e.g., "Invalid uitpas number"
15
+ endUserMessage?: {
16
+ nl: string;
17
+ };
18
+ };
19
+
20
+ function assertIsUitpasNumberSuccessfulResponse(
21
+ json: unknown,
22
+ ): asserts json is UitpasNumberSuccessfulResponse {
23
+ if (
24
+ typeof json !== 'object'
25
+ || json === null
26
+ || !('socialTariff' in json)
27
+ || typeof json.socialTariff !== 'object'
28
+ || json.socialTariff === null
29
+ || !('status' in json.socialTariff)
30
+ || typeof json.socialTariff.status !== 'string'
31
+ || (json.socialTariff.status !== 'ACTIVE' && json.socialTariff.status !== 'EXPIRED' && json.socialTariff.status !== 'NONE')
32
+ || ('messages' in json && (!Array.isArray(json.messages) || !json.messages.every(
33
+ (message: unknown) => typeof message === 'object' && message !== null && 'text' in message && typeof message.text === 'string')))
34
+ ) {
35
+ console.error('Invalid response when retrieving pass by UiTPAS number:', json);
36
+ throw new SimpleError({
37
+ code: 'invalid_response_retrieving_pass_by_uitpas_number',
38
+ message: `Invalid response when retrieving pass by UiTPAS number`,
39
+ human: $t(`Er is een fout opgetreden bij het ophalen van je UiTPAS. Kijk je het nummer even na?`),
40
+ });
41
+ }
42
+ }
43
+
44
+ function isUitpasNumberErrorResponse(
45
+ json: unknown,
46
+ ): json is UitpasNumberErrorResponse {
47
+ return typeof json === 'object'
48
+ && json !== null
49
+ && 'title' in json
50
+ && typeof json.title === 'string'
51
+ && (!('endUserMessage' in json)
52
+ || (typeof json.endUserMessage === 'object' && json.endUserMessage !== null && 'nl' in json.endUserMessage && typeof json.endUserMessage.nl === 'string')
53
+ );
54
+ }
55
+
56
+ async function checkUitpasNumber(access_token: string, uitpasNumber: string) {
57
+ // static check (using regex)
58
+ if (!DataValidator.isUitpasNumberValid(uitpasNumber)) {
59
+ throw new SimpleError({
60
+ code: 'invalid_uitpas_number',
61
+ message: `Invalid UiTPAS number: ${uitpasNumber}`,
62
+ human: $t(
63
+ `Het opgegeven UiTPAS-nummer is ongeldig. Controleer het nummer en probeer het opnieuw.`,
64
+ ),
65
+ });
66
+ }
67
+ const baseUrl = 'https://api-test.uitpas.be'; // TO DO: Use the URL from environment variables
68
+
69
+ const url = `${baseUrl}/passes/${uitpasNumber}`;
70
+ const myHeaders = new Headers();
71
+ myHeaders.append('Authorization', 'Bearer ' + access_token);
72
+ const requestOptions = {
73
+ method: 'GET',
74
+ headers: myHeaders,
75
+ };
76
+
77
+ const response = await fetch(url, requestOptions).catch(() => {
78
+ // Handle network errors
79
+ throw new SimpleError({
80
+ code: 'uitpas_unreachable_retrieving_pass_by_uitpas_number',
81
+ message: `Network issue when retrieving pass by UiTPAS number`,
82
+ human: $t(
83
+ `We konden UiTPAS niet bereiken om jouw UiTPAS-nummer te valideren. Probeer het later opnieuw.`,
84
+ ),
85
+ });
86
+ });
87
+ if (!response.ok) {
88
+ const json: unknown = await response.json().catch(() => { /* ignore */ });
89
+ let endUserMessage = '';
90
+
91
+ if (json) {
92
+ console.error(`UiTPAS API returned an error for UiTPAS number ${uitpasNumber}:`, json);
93
+ }
94
+ else {
95
+ console.error(`UiTPAS API returned an error for UiTPAS number ${uitpasNumber}:`, response.statusText);
96
+ }
97
+
98
+ if (isUitpasNumberErrorResponse(json)) {
99
+ endUserMessage = json.endUserMessage ? json.endUserMessage.nl : '';
100
+ }
101
+
102
+ if (endUserMessage) {
103
+ throw new SimpleError({
104
+ code: 'unsuccessful_but_expected_response_retrieving_pass_by_uitpas_number',
105
+ message: `Unsuccesful response with message when retrieving pass by UiTPAS number, message: ${endUserMessage}`,
106
+ human: endUserMessage,
107
+ });
108
+ }
109
+
110
+ throw new SimpleError({
111
+ code: 'unsuccessful_and_unexpected_response_retrieving_pass_by_uitpas_number',
112
+ message: `Unsuccesful response without message when retrieving pass by UiTPAS number`,
113
+ human: $t(`Er is een fout opgetreden bij het ophalen van je UiTPAS. Kijk je het nummer even na?`),
114
+ });
115
+ }
116
+
117
+ const json = await response.json().catch(() => {
118
+ // Handle JSON parsing errors
119
+ throw new SimpleError({
120
+ code: 'invalid_json_retrieving_pass_by_uitpas_number',
121
+ message: `Invalid json when retrieving pass by UiTPAS number`,
122
+ human: $t(
123
+ `Er is een fout opgetreden bij het communiceren met UiTPAS. Probeer het later opnieuw.`,
124
+ ),
125
+ });
126
+ });
127
+ assertIsUitpasNumberSuccessfulResponse(json);
128
+ if (json.messages) {
129
+ const humanMessage = json.messages[0].text; // only display the first message
130
+
131
+ // alternatively, join all messages
132
+ // const text = json.messages.map((message: any) => message.text).join(', ');
133
+
134
+ throw new SimpleError({
135
+ code: 'uitpas_number_issue',
136
+ message: `UiTPAS API returned an error: ${humanMessage}`,
137
+ human: humanMessage,
138
+ });
139
+ }
140
+ if (json.socialTariff.status !== 'ACTIVE') {
141
+ // THIS SHOULD NOT HAPPEN, as in that case json.messages should be present
142
+ throw new SimpleError({
143
+ code: 'non_active_social_tariff',
144
+ message: `UiTPAS social tariff is not ACTIVE but ${json.socialTariff.status}`,
145
+ human: $t(
146
+ `Het opgegeven UiTPAS-nummer heeft geen actief kansentarief. Neem contact op met de UiTPAS-organisatie voor meer informatie.`,
147
+ ),
148
+ });
149
+ }
150
+ // no errors -> the uitpas number is valid and social tariff is applicable
151
+ }
152
+
153
+ /**
154
+ * Checks multiple uitpas numbers
155
+ * If any of the uitpas numbers is invalid, it will throw a SimpleErrors instance with all errors.
156
+ * The field of the error will be the index of the uitpas number in the array.
157
+ * @param uitpasNumbers The uitpas numbers to check
158
+ */
159
+ export async function checkUitpasNumbers(access_token: string, uitpasNumbers: string[]) {
160
+ const simpleErrors = new SimpleErrors();
161
+ for (let i = 0; i < uitpasNumbers.length; i++) {
162
+ const uitpasNumber = uitpasNumbers[i];
163
+ try {
164
+ await checkUitpasNumber(access_token, uitpasNumber); // Throws if invalid
165
+ }
166
+ catch (e) {
167
+ if (isSimpleError(e) || isSimpleErrors(e)) {
168
+ e.addNamespace(i.toString());
169
+ e.addNamespace('uitpasNumbers');
170
+ simpleErrors.addError(e);
171
+ }
172
+ else {
173
+ throw e;
174
+ }
175
+ }
176
+ }
177
+ if (simpleErrors.errors.length > 0) {
178
+ throw simpleErrors;
179
+ }
180
+ }
@@ -0,0 +1,90 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+
3
+ type StaticSocialTariffReponse = {
4
+ available: Array<{
5
+ price: number;
6
+ // other properties ignored
7
+ }>;
8
+ };
9
+
10
+ function assertsIsStaticSocialTariffResponse(json: unknown): asserts json is StaticSocialTariffReponse {
11
+ if (
12
+ typeof json !== 'object'
13
+ || json === null
14
+ || !('available' in json)
15
+ || !Array.isArray(json.available)
16
+ || !json.available.every(
17
+ (item: unknown) => typeof item === 'object' && item !== null && 'price' in item && typeof item.price === 'number',
18
+ )
19
+ ) {
20
+ console.error('Invalid response when getting static UiTPAS social tariff:', json);
21
+ throw new SimpleError({
22
+ code: 'invalid_response_getting_static_uitpas_social_tariff',
23
+ message: `Invalid response when getting static UiTPAS social tariff`,
24
+ human: $t(`Er is een fout opgetreden bij het ophalen van het kansentarief voor dit evenement. Probeer het later opnieuw.`),
25
+ });
26
+ }
27
+ }
28
+
29
+ export async function getSocialTariffForEvent(access_token: string, basePrice: number, uitpasEventUrl: string) {
30
+ const baseUrl = 'https://api-test.uitpas.be/tariffs/static';
31
+ const params = new URLSearchParams();
32
+ params.append('regularPrice', (basePrice / 100).toFixed(2));
33
+ const eventId = uitpasEventUrl.split('/').pop(); // Extract the event ID from the URL
34
+ if (!eventId) {
35
+ throw new SimpleError({
36
+ code: 'invalid_uitpas_event_url',
37
+ message: `Invalid UiTPAS event URL: ${uitpasEventUrl}`,
38
+ human: $t(`De opgegeven UiTPAS-evenement URL is ongeldig.`),
39
+ });
40
+ }
41
+ params.append('eventId', eventId);
42
+ const url = `${baseUrl}?${params.toString()}`;
43
+ const myHeaders = new Headers();
44
+ myHeaders.append('Authorization', 'Bearer ' + access_token);
45
+ myHeaders.append('Accept', 'application/json');
46
+ const requestOptions = {
47
+ method: 'GET',
48
+ headers: myHeaders,
49
+ };
50
+ const response = await fetch(url, requestOptions).catch(() => {
51
+ // Handle network errors
52
+ throw new SimpleError({
53
+ code: 'uitpas_unreachable_getting_static_uitpas_social_tariff',
54
+ message: `Network issue when getting static UiTPAS social tariff`,
55
+ human: $t(
56
+ `We konden UiTPAS niet bereiken om het kansentarief op te zoeken. Probeer het later opnieuw.`,
57
+ ),
58
+ });
59
+ });
60
+ if (!response.ok) {
61
+ throw new SimpleError({
62
+ code: 'unsuccessful_response_getting_static_uitpas_social_tariff',
63
+ message: `Unsuccessful response when getting static UiTPAS social tariff`,
64
+ human: $t(`Er is een fout opgetreden bij het verbinden met UiTPAS. Probeer het later opnieuw.`),
65
+ });
66
+ }
67
+ const json = await response.json().catch(() => {
68
+ // Handle JSON parsing errors
69
+ throw new SimpleError({
70
+ code: 'invalid_json_getting_static_uitpas_social_tariff',
71
+ message: `Invalid json when getting static UiTPAS social tariff`,
72
+ human: $t(
73
+ `Er is een fout opgetreden bij het communiceren met UiTPAS. Probeer het later opnieuw.`,
74
+ ),
75
+ });
76
+ });
77
+
78
+ assertsIsStaticSocialTariffResponse(json);
79
+ if (json.available.length === 0) {
80
+ throw new SimpleError({
81
+ code: 'no_social_tariff_available',
82
+ message: `No social tariff available for event ${eventId}`,
83
+ human: $t(`Er is geen kansentarief beschikbaar voor dit evenement.`),
84
+ });
85
+ }
86
+ if (json.available.length > 1) {
87
+ console.warn('Multiple social tariffs available for event', eventId, '(used ', json.available[0].price, ' as base price. All options:', json.available);
88
+ }
89
+ return Math.round(json.available[0].price * 100);
90
+ }