@stamhoofd/backend 2.110.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 (51) hide show
  1. package/LICENSE.md +32 -0
  2. package/package.json +15 -12
  3. package/src/boot.ts +1 -0
  4. package/src/email-recipient-loaders/documents.ts +66 -0
  5. package/src/endpoints/auth/PatchUserEndpoint.test.ts +56 -0
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +701 -4
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +21 -10
  8. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +661 -4
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +17 -6
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +291 -8
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +22 -0
  12. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.ts +16 -18
  13. package/src/endpoints/organization/dashboard/invoices/GetInvoicesCountEndpoint.ts +43 -0
  14. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +219 -0
  15. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +8 -8
  16. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +2 -2
  17. package/src/endpoints/organization/shared/GetUitpasNumberDetailsEndpoint.ts +72 -0
  18. package/src/endpoints/organization/webshops/RetrieveUitpasSocialTariffPriceEndpoint.ts +3 -2
  19. package/src/excel-loaders/members.ts +27 -27
  20. package/src/helpers/AdminPermissionChecker.ts +30 -10
  21. package/src/helpers/AuthenticatedStructures.ts +24 -5
  22. package/src/helpers/StripeHelper.ts +11 -1
  23. package/src/helpers/StripePayoutChecker.ts +7 -0
  24. package/src/helpers/UitpasTokenRepository.ts +7 -5
  25. package/src/helpers/passthroughFetch.ts +24 -0
  26. package/src/helpers/updateMemberDetailsUitpasNumber.ts +149 -0
  27. package/src/seeds/data/default-email-templates.sql +2 -1
  28. package/src/seeds/wip/1769088653-uitpas-status.ts +129 -0
  29. package/src/services/InvoiceService.ts +114 -0
  30. package/src/services/uitpas/PassholderEndpoints.ts +190 -0
  31. package/src/services/uitpas/UitpasService.ts +37 -12
  32. package/src/services/uitpas/checkUitpasNumbers.ts +16 -140
  33. package/src/services/uitpas/handleUitpasResponse.ts +89 -0
  34. package/src/sql-filters/invoiced-balance-items.ts +20 -0
  35. package/src/sql-filters/invoices.ts +122 -0
  36. package/src/sql-filters/payments.ts +11 -1
  37. package/src/sql-sorters/invoices.ts +83 -0
  38. package/src/sql-sorters/payments.ts +33 -0
  39. package/tests/e2e/bundle-discounts.test.ts +8 -8
  40. package/tests/e2e/tests-disable-net-connect.test.ts +5 -0
  41. package/tests/helpers/StripeMocker.ts +5 -5
  42. package/tests/helpers/UitpasApiMocker.ts +175 -0
  43. package/tests/helpers/index.ts +1 -0
  44. package/tests/helpers/resetNock.ts +7 -0
  45. package/tests/init/index.ts +1 -0
  46. package/tests/init/initPayconiq.ts +2 -2
  47. package/tests/init/initStripe.ts +1 -1
  48. package/tests/init/initUitpasApi.ts +14 -0
  49. package/tests/jest.global.setup.ts +6 -4
  50. package/tests/jest.setup.ts +12 -6
  51. package/LICENSE +0 -665
@@ -1,5 +1,5 @@
1
1
  import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLCast, SQLConcat, SQLFilterDefinitions, SQLJsonUnquote, SQLScalar, SQLValueType } from '@stamhoofd/sql';
2
- import { balanceItemPaymentsCompilers } from './balance-item-payments';
2
+ import { balanceItemPaymentsCompilers } from './balance-item-payments.js';
3
3
 
4
4
  /**
5
5
  * Defines how to filter payments in the database from StamhoofdFilter objects
@@ -16,6 +16,11 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
16
16
  type: SQLValueType.String,
17
17
  nullable: false,
18
18
  }),
19
+ type: createColumnFilter({
20
+ expression: SQL.column('type'),
21
+ type: SQLValueType.String,
22
+ nullable: false,
23
+ }),
19
24
  status: createColumnFilter({
20
25
  expression: SQL.column('status'),
21
26
  type: SQLValueType.String,
@@ -56,6 +61,11 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
56
61
  type: SQLValueType.String,
57
62
  nullable: true,
58
63
  }),
64
+ hasInvoice: createColumnFilter({
65
+ expression: SQL.isNull(SQL.column('invoiceId')),
66
+ type: SQLValueType.Boolean,
67
+ nullable: false,
68
+ }),
59
69
  customer: {
60
70
  ...baseSQLFilterCompilers,
61
71
  email: createColumnFilter({
@@ -0,0 +1,83 @@
1
+ import { Invoice } from '@stamhoofd/models';
2
+ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+
5
+ export const invoiceSorters: SQLSortDefinitions<Invoice> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
11
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+
14
+ id: {
15
+ getValue(a) {
16
+ return a.id;
17
+ },
18
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
19
+ return new SQLOrderBy({
20
+ column: SQL.column('id'),
21
+ direction,
22
+ });
23
+ },
24
+ },
25
+ invoicedAt: {
26
+ getValue(a) {
27
+ return a.invoicedAt !== null ? Formatter.dateTimeIso(a.invoicedAt, 'UTC') : null;
28
+ },
29
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
30
+ return new SQLOrderBy({
31
+ column: SQL.column('invoicedAt'),
32
+ direction,
33
+ });
34
+ },
35
+ },
36
+ createdAt: {
37
+ getValue(a) {
38
+ return Formatter.dateTimeIso(a.createdAt, 'UTC');
39
+ },
40
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
41
+ return new SQLOrderBy({
42
+ column: SQL.column('createdAt'),
43
+ direction,
44
+ });
45
+ },
46
+ },
47
+
48
+ totalWithVAT: {
49
+ getValue(a) {
50
+ return a.totalWithVAT;
51
+ },
52
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
53
+ return new SQLOrderBy({
54
+ column: SQL.column('totalWithVAT'),
55
+ direction,
56
+ });
57
+ },
58
+ },
59
+
60
+ totalWithoutVAT: {
61
+ getValue(a) {
62
+ return a.totalWithoutVAT;
63
+ },
64
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
65
+ return new SQLOrderBy({
66
+ column: SQL.column('totalWithoutVAT'),
67
+ direction,
68
+ });
69
+ },
70
+ },
71
+
72
+ VATTotalAmount: {
73
+ getValue(a) {
74
+ return a.VATTotalAmount;
75
+ },
76
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
77
+ return new SQLOrderBy({
78
+ column: SQL.column('VATTotalAmount'),
79
+ direction,
80
+ });
81
+ },
82
+ },
83
+ };
@@ -55,4 +55,37 @@ export const paymentSorters: SQLSortDefinitions<Payment> = {
55
55
  });
56
56
  },
57
57
  },
58
+ hasInvoice: {
59
+ getValue(a) {
60
+ return a.invoiceId ? 1 : 0;
61
+ },
62
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
63
+ return new SQLOrderBy({
64
+ column: SQL.isNull(SQL.column('invoiceId')),
65
+ direction,
66
+ });
67
+ },
68
+ },
69
+ method: {
70
+ getValue(a) {
71
+ return a.method;
72
+ },
73
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
74
+ return new SQLOrderBy({
75
+ column: SQL.column('method'),
76
+ direction,
77
+ });
78
+ },
79
+ },
80
+ type: {
81
+ getValue(a) {
82
+ return a.type;
83
+ },
84
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
85
+ return new SQLOrderBy({
86
+ column: SQL.column('type'),
87
+ direction,
88
+ });
89
+ },
90
+ },
58
91
  };
@@ -2,14 +2,14 @@ import { Request } from '@simonbackx/simple-endpoints';
2
2
  import { BalanceItem, BalanceItemFactory, GroupFactory, MemberFactory, Organization, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
3
3
  import { AccessRight, AppliedRegistrationDiscount, BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BooleanStatus, GroupPriceDiscount, GroupPriceDiscountType, IDRegisterCart, IDRegisterCheckout, IDRegisterItem, PaymentMethod, PermissionLevel, Permissions, PermissionsResourceType, ReduceablePrice, ResourcePermissions } from '@stamhoofd/structures';
4
4
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
5
- import { RegisterMembersEndpoint } from '../../src/endpoints/global/registration/RegisterMembersEndpoint';
6
- import { assertBalances } from '../assertions/assertBalances';
7
- import { testServer } from '../helpers/TestServer';
8
- import { initBundleDiscount } from '../init/initBundleDiscount';
9
- import { initStripe } from '../init/initStripe';
10
- import { initAdmin } from '../init/initAdmin';
11
- import { BalanceItemService } from '../../src/services/BalanceItemService';
12
- import { initPermissionRole } from '../init';
5
+ import { RegisterMembersEndpoint } from '../../src/endpoints/global/registration/RegisterMembersEndpoint.js';
6
+ import { BalanceItemService } from '../../src/services/BalanceItemService.js';
7
+ import { assertBalances } from '../assertions/assertBalances.js';
8
+ import { testServer } from '../helpers/TestServer.js';
9
+ import { initAdmin } from '../init/initAdmin.js';
10
+ import { initBundleDiscount } from '../init/initBundleDiscount.js';
11
+ import { initPermissionRole } from '../init/initPermissionRole.js';
12
+ import { initStripe } from '../init/initStripe.js';
13
13
 
14
14
  const baseUrl = `/members/register`;
15
15
 
@@ -0,0 +1,5 @@
1
+ test('Should disallow net connect if request is not mocked', async () => {
2
+ const promise = fetch('https://www.google.com');
3
+
4
+ await expect(promise).rejects.toThrow(/Disallowed net connect/);
5
+ });
@@ -4,9 +4,10 @@ import nock from 'nock';
4
4
  import qs from 'qs';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
6
 
7
- import { StripeWebookEndpoint } from '../../src/endpoints/global/payments/StripeWebhookEndpoint';
8
- import { StripeHelper } from '../../src/helpers/StripeHelper';
9
- import { testServer } from './TestServer';
7
+ import { StripeWebookEndpoint } from '../../src/endpoints/global/payments/StripeWebhookEndpoint.js';
8
+ import { StripeHelper } from '../../src/helpers/StripeHelper.js';
9
+ import { testServer } from './TestServer.js';
10
+ import { resetNock } from './resetNock.js';
10
11
 
11
12
  export class StripeMocker {
12
13
  paymentIntents: { id: string }[] = [];
@@ -215,8 +216,7 @@ export class StripeMocker {
215
216
  }
216
217
 
217
218
  stop() {
218
- nock.cleanAll();
219
- nock.disableNetConnect();
219
+ resetNock();
220
220
  }
221
221
  }
222
222
 
@@ -0,0 +1,175 @@
1
+ import { sleep } from '@stamhoofd/utility';
2
+ import nock from 'nock';
3
+ import { resetNock } from './resetNock.js';
4
+
5
+ export type UitpasSocialTariff = {
6
+ status: 'ACTIVE' | 'EXPIRED' | 'NONE';
7
+ endDate?: string;
8
+ };
9
+
10
+ export type UitpasMessage = {
11
+ level: 'INFO' | 'WARN' | 'ERROR';
12
+ text: string;
13
+ };
14
+
15
+ export type UitpasApiResponse = {
16
+ passholderId: string;
17
+ uitpasNumber: string;
18
+ firstName: string;
19
+ points: number;
20
+ postalCode: string;
21
+ socialTariff: UitpasSocialTariff;
22
+ messages?: UitpasMessage[];
23
+ };
24
+
25
+ export class UitpasMocker {
26
+ /**
27
+ * Real test data from uitpas api
28
+ * https://docs.publiq.be/docs/uitpas/test-dataset
29
+ */
30
+ static readonly testData = new Map<string, UitpasApiResponse>([
31
+ [
32
+ '0900000095902',
33
+ {
34
+ passholderId: '6eb040fd-3c60-4049-8170-c61e4e52c009',
35
+ uitpasNumber: '0900000095902',
36
+ firstName: 'Valère',
37
+ points: 8150,
38
+ postalCode: '9000',
39
+ socialTariff: { status: 'NONE' },
40
+ },
41
+ ],
42
+ [
43
+ /**
44
+ * Example of an uitpas that does not end with 1X but is active
45
+ */
46
+ '0900011354829',
47
+ {
48
+ passholderId: '6eb040fd-3c60-4049-8170-c61e4e52c009',
49
+ uitpasNumber: '0900000095902',
50
+ firstName: 'Valère',
51
+ points: 8150,
52
+ postalCode: '9000',
53
+ socialTariff: {
54
+ status: 'ACTIVE',
55
+ endDate: '2026-04-30T21:59:59+00:00',
56
+ },
57
+ },
58
+ ],
59
+ [
60
+ '0900011354819',
61
+ {
62
+ passholderId: 'f24d31b4-ffab-4ded-98b6-3ab4a6308d4d',
63
+ uitpasNumber: '0900011354819',
64
+ firstName: 'Bernadette',
65
+ points: 400,
66
+ postalCode: '9340',
67
+ socialTariff: {
68
+ status: 'ACTIVE',
69
+ endDate: '2026-04-30T21:59:59+00:00',
70
+ },
71
+ },
72
+ ],
73
+ [
74
+ '0900000031618',
75
+ {
76
+ passholderId: 'd146126c-cc42-4b7f-8431-bb71a04131aa',
77
+ uitpasNumber: '0900000031618',
78
+ firstName: 'Vervallen Pas',
79
+ points: 4,
80
+ postalCode: '9000',
81
+ socialTariff: {
82
+ status: 'EXPIRED',
83
+ endDate: '2025-01-28T22:59:59+00:00',
84
+ },
85
+ messages: [
86
+ {
87
+ level: 'WARN',
88
+ text: 'Je sociaal tarief is verlopen. Contacteer je UiTPAS balie',
89
+ },
90
+ ],
91
+ },
92
+ ],
93
+ ]);
94
+
95
+ #forceFailure = false;
96
+ #requestTimeoutInMs = 0;
97
+
98
+ reset() {
99
+ this.#forceFailure = false;
100
+ this.#requestTimeoutInMs = 0;
101
+ }
102
+
103
+ forceFailure() {
104
+ this.#forceFailure = true;
105
+ }
106
+
107
+ setRequestTimeout(ms: number) {
108
+ this.#requestTimeoutInMs = ms;
109
+ }
110
+
111
+ start() {
112
+ if (
113
+ !STAMHOOFD.UITPAS_API_CLIENT_ID
114
+ || !STAMHOOFD.UITPAS_API_CLIENT_SECRET?.startsWith('sk_test_')
115
+ ) {
116
+ throw new Error(
117
+ 'Invalid UITPAS_API_CLIENT_SECRET. Even in test mode it should start with sk_test_',
118
+ );
119
+ }
120
+
121
+ // mock access-token
122
+ nock('https://account-test.uitid.be')
123
+ .persist()
124
+ .post('/realms/uitid/protocol/openid-connect/token')
125
+ .reply(200, {
126
+ access_token: 'fake-access-token',
127
+ token_type: 'Bearer',
128
+ expires_in: 86400,
129
+ });
130
+
131
+ // mock get passes
132
+ nock('https://api-test.uitpas.be')
133
+ .persist()
134
+ .get(/\/passes\/(\d+)/)
135
+ .reply(async (uri: string) => {
136
+ const matchArray = uri.match(/\/passes\/(\d+)/);
137
+
138
+ if (this.#requestTimeoutInMs) {
139
+ await sleep(this.#requestTimeoutInMs);
140
+ }
141
+
142
+ if (!matchArray) {
143
+ return [500];
144
+ }
145
+
146
+ if (this.#forceFailure) {
147
+ return [503];
148
+ }
149
+
150
+ const uitpasNumber = matchArray[1];
151
+ const result = UitpasMocker.testData.get(uitpasNumber);
152
+ if (!result) {
153
+ return [
154
+ 404,
155
+ {
156
+ type: 'https://api.publiq.be/probs/uitpas/pass-not-found',
157
+ title: 'Pass not found',
158
+ status: 404,
159
+ detail: '0900000031617',
160
+ endUserMessage: {
161
+ nl: 'Het UiTPAS-nummer dat je invulde konden we niet terugvinden. Kijk je het nummer even na?',
162
+ },
163
+ },
164
+ ];
165
+ }
166
+
167
+ return [200, result];
168
+ });
169
+ }
170
+
171
+ stop() {
172
+ this.reset();
173
+ resetNock();
174
+ }
175
+ }
@@ -0,0 +1 @@
1
+ export * from './UitpasApiMocker.js';
@@ -0,0 +1,7 @@
1
+ import nock from 'nock';
2
+ import { PayconiqMocker } from './PayconiqMocker.js';
3
+
4
+ export function resetNock() {
5
+ nock.cleanAll();
6
+ PayconiqMocker.setup();
7
+ }
@@ -4,3 +4,4 @@ export * from './initPayconiq';
4
4
  export * from './initBundleDiscount';
5
5
  export * from './initStripe';
6
6
  export * from './initPermissionRole';
7
+ export * from './initUitpasApi';
@@ -1,6 +1,6 @@
1
- import { PaymentMethod } from '@stamhoofd/structures';
2
- import { PayconiqMocker } from '../helpers/PayconiqMocker';
3
1
  import { Organization } from '@stamhoofd/models';
2
+ import { PaymentMethod } from '@stamhoofd/structures';
3
+ import { PayconiqMocker } from '../helpers/PayconiqMocker.js';
4
4
 
5
5
  export async function initPayconiq({ organization }: { organization: Organization }) {
6
6
  organization.meta.registrationPaymentConfiguration.paymentMethods.push(PaymentMethod.Payconiq);
@@ -1,7 +1,7 @@
1
1
  import { Organization } from '@stamhoofd/models';
2
- import { StripeMocker } from '../helpers/StripeMocker';
3
2
  import { PaymentMethod } from '@stamhoofd/structures';
4
3
  import { TestUtils } from '@stamhoofd/test-utils';
4
+ import { StripeMocker } from '../helpers/StripeMocker.js';
5
5
 
6
6
  export async function initStripe({ organization }: { organization: Organization }) {
7
7
  const stripeMocker = new StripeMocker();
@@ -0,0 +1,14 @@
1
+ import { TestUtils } from '@stamhoofd/test-utils';
2
+ import { UitpasMocker } from '../helpers/UitpasApiMocker.js';
3
+
4
+ export function initUitpasApi(): UitpasMocker {
5
+ const mocker = new UitpasMocker();
6
+
7
+ mocker.start();
8
+
9
+ TestUtils.scheduleAfterThisTest(() => {
10
+ mocker.stop();
11
+ });
12
+
13
+ return mocker;
14
+ }
@@ -1,12 +1,14 @@
1
- import { TestUtils } from '@stamhoofd/test-utils';
1
+ // first import nock
2
2
  import nock from 'nock';
3
+
4
+ // prevent nock import from being removed on save
5
+ console.log('Imported nock: ', !!nock);
6
+
7
+ import { TestUtils } from '@stamhoofd/test-utils';
3
8
  import path from 'path';
4
9
  const emailPath = require.resolve('@stamhoofd/email');
5
10
  const modelsPath = require.resolve('@stamhoofd/models');
6
11
 
7
- // Disable network requests
8
- nock.disableNetConnect();
9
-
10
12
  // Set timezone!
11
13
  process.env.TZ = 'UTC';
12
14
 
@@ -1,3 +1,6 @@
1
+ // first import nock
2
+ import nock from 'nock';
3
+
1
4
  import { Column, Database } from '@simonbackx/simple-database';
2
5
  import { Request } from '@simonbackx/simple-endpoints';
3
6
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -7,11 +10,11 @@ import { Version } from '@stamhoofd/structures';
7
10
  import { TestUtils } from '@stamhoofd/test-utils';
8
11
  import { sleep } from '@stamhoofd/utility';
9
12
  import * as jose from 'jose';
10
- import nock from 'nock';
11
- import { GlobalHelper } from '../src/helpers/GlobalHelper';
12
- import { BalanceItemService } from '../src/services/BalanceItemService';
13
- import { PayconiqMocker } from './helpers/PayconiqMocker';
14
- import './toMatchMap';
13
+ import { GlobalHelper } from '../src/helpers/GlobalHelper.js';
14
+ import { BalanceItemService } from '../src/services/BalanceItemService.js';
15
+ import { PayconiqMocker } from './helpers/PayconiqMocker.js';
16
+ import { resetNock } from './helpers/resetNock.js';
17
+ import './toMatchMap.js';
15
18
 
16
19
  // Set version of saved structures
17
20
  Column.setJSONVersion(Version);
@@ -33,7 +36,7 @@ if (new Date().getTimezoneOffset() !== 0) {
33
36
  console.log = jest.fn();
34
37
 
35
38
  beforeAll(async () => {
36
- nock.cleanAll();
39
+ resetNock();
37
40
  nock.disableNetConnect();
38
41
 
39
42
  await Database.delete('DELETE FROM `tokens`');
@@ -111,4 +114,7 @@ afterEach(async () => {
111
114
 
112
115
  TestUtils.setup();
113
116
  EmailMocker.infect();
117
+
118
+ // should be mocked first (before node https imports)
119
+ PayconiqMocker.setup();
114
120
  PayconiqMocker.infect();