@stamhoofd/backend 2.143.1 → 2.145.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 (111) hide show
  1. package/package.json +19 -19
  2. package/src/crons/fake-settlements.test.ts +13 -11
  3. package/src/crons/fake-settlements.ts +22 -38
  4. package/src/crons/mollie-chargebacks.ts +31 -29
  5. package/src/crons/mollie-refunds.ts +61 -59
  6. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +16 -5
  7. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +210 -4
  8. package/src/endpoints/auth/CreateTokenEndpoint.ts +9 -20
  9. package/src/endpoints/auth/DeleteTokenEndpoint.test.ts +29 -0
  10. package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
  11. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -11
  12. package/src/endpoints/auth/MFA.security.test.ts +9 -7
  13. package/src/endpoints/auth/MFA.test.ts +42 -15
  14. package/src/endpoints/auth/PatchUserEndpoint.ts +3 -2
  15. package/src/endpoints/auth/VerifyEmailEndpoint.ts +5 -4
  16. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +4 -3
  17. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +6 -5
  18. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +5 -4
  19. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +5 -4
  20. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +2 -1
  21. package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +2 -1
  22. package/src/endpoints/global/files/UploadFile.test.ts +2 -1
  23. package/src/endpoints/global/files/UploadFile.ts +53 -25
  24. package/src/endpoints/global/files/UploadImage.test.ts +2 -1
  25. package/src/endpoints/global/files/UploadImage.ts +32 -6
  26. package/src/endpoints/global/files/upload-security.test.ts +3 -2
  27. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -3
  28. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +26 -25
  29. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +57 -56
  30. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +4 -3
  31. package/src/endpoints/global/platform/GetPlatformEndpoint.test.ts +6 -4
  32. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +6 -6
  33. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +9 -8
  34. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +14 -13
  35. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +24 -23
  36. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +4 -3
  37. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +2 -1
  38. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +2 -1
  39. package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +20 -19
  40. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +2 -1
  41. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +2 -1
  42. package/src/endpoints/organization/dashboard/billing/ApplyRegisterCodeEndpoint.test.ts +3 -2
  43. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -3
  44. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +5 -4
  45. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +5 -4
  46. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +2 -1
  47. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +2 -1
  48. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +0 -1
  49. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +23 -22
  50. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +4 -4
  51. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +2 -1
  52. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +4 -3
  53. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -2
  54. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +2 -1
  55. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +5 -4
  56. package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +7 -6
  57. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +9 -8
  58. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +3 -2
  59. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +2 -1
  60. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +5 -4
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +2 -1
  62. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +3 -2
  63. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +6 -5
  64. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +16 -15
  65. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +2 -1
  66. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +2 -1
  67. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +2 -1
  68. package/src/endpoints/organization/dashboard/webshops/WebshopReservedPathSegments.test.ts +2 -1
  69. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +4 -3
  70. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +6 -5
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +94 -8
  72. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +18 -18
  73. package/src/helpers/ApplicationFeeInvoicer.ts +1 -1
  74. package/src/helpers/Context.ts +14 -21
  75. package/src/helpers/ContextStorage.ts +5 -0
  76. package/src/helpers/MollieSettlementSync.test.ts +94 -41
  77. package/src/helpers/MollieSettlementSync.ts +174 -78
  78. package/src/helpers/StripeSettlementSync.test.ts +0 -85
  79. package/src/helpers/StripeSettlementSync.ts +47 -70
  80. package/src/helpers/TwoFactorHelper.ts +21 -22
  81. package/src/helpers/streamForBreakdown.ts +1 -1
  82. package/src/services/AdminSessionService.ts +5 -3
  83. package/src/services/InvoiceService.ts +2 -2
  84. package/src/services/MollieService.ts +168 -126
  85. package/src/services/PaymentService.ts +2 -2
  86. package/src/services/SSOService.test.ts +25 -1
  87. package/src/services/SSOService.ts +17 -6
  88. package/src/services/SessionService.test.ts +319 -0
  89. package/src/services/SessionService.ts +357 -0
  90. package/src/services/SettlementService.test.ts +13 -85
  91. package/src/services/SettlementService.ts +5 -113
  92. package/src/services/WebshopCrowdfundingService.test.ts +4 -3
  93. package/src/sql-filters/balance-items.ts +10 -0
  94. package/src/sql-filters/registrations.ts +59 -1
  95. package/tests/actions/patchUserMember.ts +2 -1
  96. package/tests/e2e/api-rate-limits.test.ts +2 -1
  97. package/tests/e2e/bundle-discounts.test.ts +4 -3
  98. package/tests/e2e/charge-members.test.ts +6 -5
  99. package/tests/e2e/documents.test.ts +4 -3
  100. package/tests/e2e/private-files.test.ts +9 -8
  101. package/tests/e2e/register.test.ts +4 -3
  102. package/tests/e2e/stock.test.ts +4 -3
  103. package/tests/e2e/tickets.test.ts +4 -3
  104. package/tests/helpers/ExportSlice.ts +2 -1
  105. package/tests/helpers/MollieMocker.ts +173 -38
  106. package/tests/init/initAdmin.ts +2 -1
  107. package/tests/init/initPlatformAdmin.ts +2 -1
  108. package/src/helpers/ViesHelper.test.ts +0 -401
  109. package/src/helpers/ViesHelper.ts +0 -215
  110. package/src/services/PeppolDirectoryService.test.ts +0 -139
  111. package/src/services/PeppolDirectoryService.ts +0 -111
@@ -1,401 +0,0 @@
1
- import { Address, Company, PeppolEndointId } from '@stamhoofd/structures';
2
- import { STExpect, TestUtils } from '@stamhoofd/test-utils';
3
- import { Country } from '@stamhoofd/types/Country';
4
- import nock from 'nock';
5
- import { ViesHelper } from './ViesHelper.js';
6
-
7
- const VIES_HOST = 'https://ec.europa.eu';
8
- const VIES_PATH = '/taxation_customs/vies/rest-api/check-vat-number';
9
-
10
- const DIRECTORY_HOST = 'https://directory.peppol.eu';
11
- const DIRECTORY_PATH = '/search/1.0/json';
12
-
13
- describe('ViesHelper', () => {
14
- /**
15
- * Registers a mock for the (only) external dependency: the VIES check-vat-number endpoint.
16
- * Returns the nock scope so tests can assert whether it was actually called.
17
- */
18
- function mockVies(response: unknown, statusCode = 200) {
19
- return nock(VIES_HOST).post(VIES_PATH).reply(statusCode, response as nock.Body);
20
- }
21
-
22
- /**
23
- * Captures the JSON body that was sent to the VIES endpoint, so we can assert
24
- * what we send without depending on the internals of the helper.
25
- */
26
- function mockViesCapturingBody(response: unknown, statusCode = 200): { getBody: () => any } {
27
- let body: any;
28
- nock(VIES_HOST)
29
- .post(VIES_PATH, (requestBody) => {
30
- body = requestBody;
31
- return true;
32
- })
33
- .reply(statusCode, response as nock.Body);
34
- return { getBody: () => body };
35
- }
36
-
37
- afterEach(() => {
38
- nock.cleanAll();
39
- nock.disableNetConnect();
40
- });
41
-
42
- describe('checkVATNumber', () => {
43
- describe('rejects invalid numbers before contacting the API', () => {
44
- test('an invalid Belgian VAT number is rejected without a request', async () => {
45
- const scope = mockVies({ valid: true });
46
-
47
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0123')).rejects.toThrow(
48
- STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber' }),
49
- );
50
-
51
- // The VIES endpoint must not have been called.
52
- expect(scope.isDone()).toBe(false);
53
- });
54
-
55
- test('an invalid Dutch VAT number is rejected without a request', async () => {
56
- const scope = mockVies({ valid: true });
57
-
58
- await expect(ViesHelper.checkVATNumber(Country.Netherlands, 'NL123456789B01')).rejects.toThrow(
59
- STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber' }),
60
- );
61
-
62
- expect(scope.isDone()).toBe(false);
63
- });
64
-
65
- test('garbage input is rejected without a request', async () => {
66
- const scope = mockVies({ valid: true });
67
-
68
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'not-a-vat-number')).rejects.toThrow(
69
- STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber' }),
70
- );
71
-
72
- expect(scope.isDone()).toBe(false);
73
- });
74
-
75
- test('the error message contains the rejected number', async () => {
76
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0123')).rejects.toThrow(
77
- STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber', message: /Ongeldig BTW-nummer.*BE0123/ }),
78
- );
79
- });
80
- });
81
-
82
- describe('parses the API response', () => {
83
- test('returns the formatted number when VIES confirms it is valid', async () => {
84
- const scope = mockVies({ valid: true });
85
-
86
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).resolves.toBe('BE0411905847');
87
-
88
- // The number was actually validated against VIES.
89
- expect(scope.isDone()).toBe(true);
90
- });
91
-
92
- test('sends the country code and the cleaned number (without country prefix) to VIES', async () => {
93
- const mock = mockViesCapturingBody({ valid: true });
94
-
95
- await ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847');
96
-
97
- expect(mock.getBody()).toEqual({
98
- countryCode: 'BE',
99
- vatNumber: '0411905847',
100
- });
101
- });
102
-
103
- test('validates Dutch numbers against VIES as well', async () => {
104
- const mock = mockViesCapturingBody({ valid: true });
105
-
106
- await expect(ViesHelper.checkVATNumber(Country.Netherlands, 'NL301828519B01')).resolves.toBe('NL301828519B01');
107
-
108
- expect(mock.getBody()).toEqual({
109
- countryCode: 'NL',
110
- vatNumber: '301828519B01',
111
- });
112
- });
113
-
114
- test('throws an invalid_field error when VIES reports the number as not valid', async () => {
115
- mockVies({ valid: false });
116
-
117
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
118
- STExpect.simpleError({
119
- code: 'invalid_field',
120
- field: 'VATNumber',
121
- message: /BTW-plichtig.*BE0411905847/,
122
- }),
123
- );
124
- });
125
-
126
- test('throws service_unavailable when VIES returns a response without a valid boolean', async () => {
127
- mockVies({ somethingElse: true });
128
-
129
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
130
- STExpect.simpleError({ code: 'service_unavailable', field: 'VATNumber' }),
131
- );
132
- });
133
-
134
- test('throws service_unavailable when VIES returns a non-object response', async () => {
135
- mockVies('"plain string"');
136
-
137
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
138
- STExpect.simpleError({ code: 'service_unavailable', field: 'VATNumber' }),
139
- );
140
- });
141
-
142
- test('throws service_unavailable when the VIES endpoint errors out', async () => {
143
- mockVies({ message: 'Internal Server Error' }, 500);
144
-
145
- await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
146
- STExpect.simpleError({ code: 'service_unavailable', field: 'VATNumber' }),
147
- );
148
- });
149
- });
150
-
151
- test('skips the API in development for the known development number', async () => {
152
- TestUtils.setEnvironment('environment', 'development');
153
- const scope = mockVies({ valid: false });
154
-
155
- await expect(ViesHelper.checkVATNumber(Country.Netherlands, 'NL301828519B01')).resolves.toBe('NL301828519B01');
156
-
157
- // No request was made, even though VIES would have reported it invalid.
158
- expect(scope.isDone()).toBe(false);
159
- });
160
- });
161
-
162
- describe('checkCompanyNumber', () => {
163
- test('returns the number unchanged for non-Belgian countries without contacting the API', async () => {
164
- const scope = mockVies({ valid: true });
165
-
166
- const result = await ViesHelper.checkCompanyNumber(Country.Netherlands, '12345678');
167
-
168
- expect(result).toEqual({ companyNumber: '12345678' });
169
- expect(scope.isDone()).toBe(false);
170
- });
171
-
172
- test('rejects an invalid Belgian company number without contacting the API', async () => {
173
- const scope = mockVies({ valid: true });
174
-
175
- await expect(ViesHelper.checkCompanyNumber(Country.Belgium, '123')).rejects.toThrow(
176
- STExpect.simpleError({ code: 'invalid_field', field: 'companyNumber', message: /Ongeldig ondernemingsnummer: 123/ }),
177
- );
178
-
179
- expect(scope.isDone()).toBe(false);
180
- });
181
-
182
- test('promotes a valid Belgian company number to a VAT number when VIES confirms it', async () => {
183
- mockVies({ valid: true });
184
-
185
- const result = await ViesHelper.checkCompanyNumber(Country.Belgium, '0411905847');
186
-
187
- expect(result).toEqual({
188
- companyNumber: '0411905847',
189
- VATNumber: 'BE0411905847',
190
- });
191
- });
192
-
193
- test('keeps the company number but clears the VAT number when VIES does not recognise it', async () => {
194
- mockVies({ valid: false });
195
-
196
- const result = await ViesHelper.checkCompanyNumber(Country.Belgium, '0411905847');
197
-
198
- expect(result).toEqual({
199
- companyNumber: '0411905847',
200
- VATNumber: null,
201
- });
202
- });
203
- });
204
-
205
- describe('checkCompany', () => {
206
- const belgianAddress = () => Address.create({
207
- street: 'Demostraat',
208
- number: '12',
209
- city: 'Gent',
210
- postalCode: '9000',
211
- country: Country.Belgium,
212
- });
213
-
214
- test('clears both numbers when the company has no address, without contacting the API', async () => {
215
- const scope = mockVies({ valid: true });
216
-
217
- const company = Company.create({
218
- name: 'Demo Company',
219
- VATNumber: 'BE0411905847',
220
- companyNumber: '0411905847',
221
- address: null,
222
- });
223
- const patch = Company.create({});
224
-
225
- await ViesHelper.checkCompany(company, patch);
226
-
227
- expect(patch.VATNumber).toBeNull();
228
- expect(patch.companyNumber).toBeNull();
229
- expect(scope.isDone()).toBe(false);
230
- });
231
-
232
- test('rejects a company name that is too short', async () => {
233
- const company = Company.create({
234
- name: 'ab',
235
- address: belgianAddress(),
236
- });
237
-
238
- await expect(ViesHelper.checkCompany(company, Company.create({}))).rejects.toThrow(
239
- STExpect.simpleError({ code: 'invalid_company_name', field: 'companyName' }),
240
- );
241
- });
242
-
243
- test('validates the VAT number and derives the company number for a Belgian company', async () => {
244
- mockVies({ valid: true });
245
-
246
- const company = Company.create({
247
- name: 'Demo Company',
248
- VATNumber: 'BE0411905847',
249
- companyNumber: null,
250
- address: belgianAddress(),
251
- });
252
- const patch = Company.create({});
253
-
254
- await ViesHelper.checkCompany(company, patch);
255
-
256
- expect(patch.VATNumber).toBe('BE0411905847');
257
- expect(patch.companyNumber).toBe('0411905847');
258
- });
259
-
260
- describe('custom PEPPOL endpoint id', () => {
261
- function mockDirectoryFound(participant: string, entityName = 'Directory Name') {
262
- return nock(DIRECTORY_HOST)
263
- .get(DIRECTORY_PATH)
264
- .query({ participant })
265
- .reply(200, {
266
- 'total-result-count': 1,
267
- matches: [{
268
- participantID: { scheme: 'iso6523-actorid-upis', value: participant.split('::')[1] },
269
- entities: [{ name: [{ name: entityName }] }],
270
- }],
271
- } as nock.Body);
272
- }
273
-
274
- function mockDirectoryNotFound(participant: string) {
275
- return nock(DIRECTORY_HOST)
276
- .get(DIRECTORY_PATH)
277
- .query({ participant })
278
- .reply(200, { 'total-result-count': 0, matches: [] } as nock.Body);
279
- }
280
-
281
- // Use a GLN endpoint id for the generic directory/entityName tests: it has no
282
- // extra constraints, unlike a KBO (0208) id which must match the company number.
283
- const companyWithPeppol = () => Company.create({
284
- name: 'Demo Company',
285
- address: belgianAddress(),
286
- customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }),
287
- });
288
-
289
- test('validates the id against the directory when it was changed', async () => {
290
- const scope = mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
291
-
292
- const company = companyWithPeppol();
293
- const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }) });
294
-
295
- await expect(ViesHelper.checkCompany(company, patch)).resolves.toBeUndefined();
296
- expect(scope.isDone()).toBe(true);
297
- // The registered entity name is stored from the directory.
298
- expect(company.customPeppolEndpointId?.entityName).toBe('Directory Name');
299
- });
300
-
301
- test('overwrites a client-supplied entityName with the value from the directory', async () => {
302
- mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
303
-
304
- const company = Company.create({
305
- name: 'Demo Company',
306
- address: belgianAddress(),
307
- customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013', entityName: 'Client supplied name' }),
308
- });
309
- const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013', entityName: 'Client supplied name' }) });
310
-
311
- await ViesHelper.checkCompany(company, patch);
312
-
313
- // entityName is server-controlled: the client value is discarded.
314
- expect(company.customPeppolEndpointId?.entityName).toBe('Directory Name');
315
- expect(patch.customPeppolEndpointId).not.toBeNull();
316
- expect((patch.customPeppolEndpointId as PeppolEndointId | undefined)?.entityName).toBe('Directory Name');
317
- });
318
-
319
- test('does not contact the directory when the id did not change', async () => {
320
- const scope = mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
321
-
322
- const company = companyWithPeppol();
323
- const patch = Company.patch({ name: 'Renamed Company' });
324
-
325
- await ViesHelper.checkCompany(company, patch);
326
- expect(scope.isDone()).toBe(false);
327
- });
328
-
329
- test('rejects an id that is unknown to the directory', async () => {
330
- mockDirectoryNotFound('iso6523-actorid-upis::0088:5412345000013');
331
-
332
- const company = companyWithPeppol();
333
- const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }) });
334
-
335
- await expect(ViesHelper.checkCompany(company, patch)).rejects.toThrow(
336
- STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
337
- );
338
- });
339
-
340
- test('does not contact the directory when the id is cleared', async () => {
341
- const scope = mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
342
-
343
- const company = Company.create({ name: 'Demo Company', address: belgianAddress() });
344
- const patch = Company.patch({ customPeppolEndpointId: null });
345
-
346
- await ViesHelper.checkCompany(company, patch);
347
- expect(scope.isDone()).toBe(false);
348
- });
349
-
350
- test('rejects a KBO endpoint id for a non-Belgian company before contacting the directory', async () => {
351
- const scope = mockDirectoryFound('iso6523-actorid-upis::0208:0411905847');
352
-
353
- const company = Company.create({
354
- name: 'Demo Company',
355
- address: Address.create({ street: 'Damstraat', number: '1', city: 'Amsterdam', postalCode: '1012', country: Country.Netherlands }),
356
- customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }),
357
- });
358
- const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }) });
359
-
360
- await expect(ViesHelper.checkCompany(company, patch)).rejects.toThrow(
361
- STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
362
- );
363
- expect(scope.isDone()).toBe(false);
364
- });
365
-
366
- test('rejects a KBO endpoint id that does not match the company number before contacting the directory', async () => {
367
- const scope = mockDirectoryFound('iso6523-actorid-upis::0208:9999999999');
368
-
369
- const company = Company.create({
370
- name: 'Demo Company',
371
- address: belgianAddress(),
372
- companyNumber: '0411905847',
373
- customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '9999999999' }),
374
- });
375
- const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '9999999999' }) });
376
-
377
- await expect(ViesHelper.checkCompany(company, patch)).rejects.toThrow(
378
- STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
379
- );
380
- expect(scope.isDone()).toBe(false);
381
- });
382
-
383
- test('accepts a KBO endpoint id that matches the company number', async () => {
384
- mockVies({ valid: true });
385
- const scope = mockDirectoryFound('iso6523-actorid-upis::0208:0411905847');
386
-
387
- const company = Company.create({
388
- name: 'Demo Company',
389
- address: belgianAddress(),
390
- companyNumber: '0411905847',
391
- customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }),
392
- });
393
- const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }) });
394
-
395
- await expect(ViesHelper.checkCompany(company, patch)).resolves.toBeUndefined();
396
- expect(scope.isDone()).toBe(true);
397
- expect(company.customPeppolEndpointId?.entityName).toBe('Directory Name');
398
- });
399
- });
400
- });
401
- });
@@ -1,215 +0,0 @@
1
- import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
- import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
- import type { Company } from '@stamhoofd/structures';
4
- import { PeppolScheme } from '@stamhoofd/structures';
5
- import { Country } from '@stamhoofd/types/Country';
6
- import axios from 'axios';
7
- import jsvat from 'jsvat-next';
8
- import { PeppolDirectoryService } from '../services/PeppolDirectoryService.js';
9
-
10
- export class ViesHelperStatic {
11
- testMode = false;
12
-
13
- async request(method: 'GET' | 'POST', url: string, content: any) {
14
- const json = content ? JSON.stringify(content) : '';
15
-
16
- console.log('[VIES REQUEST]', method, url, content ? '\n [VIES REQUEST] ' : undefined, json);
17
-
18
- const response = await axios.request({
19
- method,
20
- url,
21
- headers: {
22
- 'Content-Type': json.length > 0 ? 'application/json' : 'text/plain',
23
- },
24
- data: json,
25
-
26
- });
27
- console.log('[VIES RESPONSE]', method, url, '\n[VIES RESPONSE]', JSON.stringify(response.data));
28
- return response.data;
29
- }
30
-
31
- async checkCompany(company: Company, patch: AutoEncoderPatchType<Company> | Company) {
32
- // Validate the custom PEPPOL endpoint id, but only when it actually changed.
33
- // In a patch, `customPeppolEndpointId` is undefined unless the client changed it;
34
- // for a full company it is always set (null or a value), so we validate a set value.
35
- if (patch.customPeppolEndpointId !== undefined && company.customPeppolEndpointId) {
36
- const endpointId = company.customPeppolEndpointId;
37
-
38
- // A KBO (0208) endpoint id is the Belgian enterprise number, so it must belong to a
39
- // Belgian company and be identical to this company's own company number.
40
- if (endpointId.schemeID === (PeppolScheme.KBO as string)) {
41
- if (company.address?.country !== Country.Belgium) {
42
- throw new SimpleError({
43
- code: 'invalid_field',
44
- message: 'A KBO PEPPOL id can only be used for a Belgian company',
45
- human: $t('%Zck'),
46
- field: 'customPeppolEndpointId',
47
- });
48
- }
49
-
50
- const companyPeppolId = company.peppolCompanyId;
51
- if (!companyPeppolId || endpointId.id.replace(/\D+/g, '') !== companyPeppolId.id.replace(/\D+/g, '')) {
52
- throw new SimpleError({
53
- code: 'invalid_field',
54
- message: 'A KBO PEPPOL id must match the company number',
55
- human: $t('%ZcZ'),
56
- field: 'customPeppolEndpointId',
57
- });
58
- }
59
- }
60
-
61
- // validate() fills in the server-controlled entityName from the PEPPOL directory.
62
- await PeppolDirectoryService.validate(endpointId);
63
-
64
- // Write the validated endpoint id back as a full replacement so any client-supplied
65
- // entityName is discarded: entityName can only ever be set by the server.
66
- patch.customPeppolEndpointId = endpointId;
67
- }
68
-
69
- if (!company.address) {
70
- // Not allowed to set
71
- patch.companyNumber = null;
72
- patch.VATNumber = null;
73
- return;
74
- }
75
-
76
- if (company.name.length < 3 || company.name.toLowerCase() === 'vzw') {
77
- throw new SimpleError({
78
- code: 'invalid_company_name',
79
- message: 'Company name is too short',
80
- human: $t('%1LF'),
81
- field: 'companyName',
82
- });
83
- }
84
-
85
- if (company.VATNumber !== null) {
86
- // Changed VAT number
87
- patch.VATNumber = await ViesHelper.checkVATNumber(company.address.country, company.VATNumber);
88
-
89
- if (company.address.country === Country.Belgium) {
90
- patch.companyNumber = company.VATNumber.substring(2);
91
- }
92
- }
93
-
94
- if (company.companyNumber) {
95
- if (company.VATNumber !== null && company.address.country === Country.Belgium) {
96
- // Already validated
97
- } else {
98
- // Need to validate
99
- const result = await ViesHelper.checkCompanyNumber(company.address.country, company.companyNumber);
100
- patch.companyNumber = result.companyNumber;
101
- if (result.VATNumber !== undefined) {
102
- patch.VATNumber = result.VATNumber;
103
- }
104
- }
105
- }
106
- }
107
-
108
- async checkCompanyNumber(country: Country, companyNumber: string): Promise<{ companyNumber: string | null; VATNumber?: string | null }> {
109
- if (country !== Country.Belgium) {
110
- // Not supported
111
- return {
112
- companyNumber,
113
- };
114
- }
115
-
116
- // In Belgium, the company number syntax is the same as VAT number
117
- let correctedVATNumber = companyNumber;
118
-
119
- // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
120
- if (companyNumber.length > 2 && companyNumber.substr(0, 2).toUpperCase() !== country) {
121
- // Add required country in VAT number
122
- correctedVATNumber = country + companyNumber;
123
- }
124
-
125
- const result = jsvat.checkVAT(correctedVATNumber, [jsvat.belgium]);
126
-
127
- if (!result.isValid) {
128
- throw new SimpleError({
129
- code: 'invalid_field',
130
- message: 'Ongeldig ondernemingsnummer: ' + companyNumber,
131
- field: 'companyNumber',
132
- });
133
- }
134
-
135
- // If this is a valid VAT number, we can assume it's a valid company number
136
- try {
137
- const corrected = await this.checkVATNumber(Country.Belgium, correctedVATNumber);
138
-
139
- // this is a VAT number, not a company number
140
- return {
141
- companyNumber: corrected.substring(2),
142
- VATNumber: corrected,
143
- };
144
- } catch (e) {
145
- if (isSimpleError(e) || isSimpleErrors(e)) {
146
- // Ignore: normal that it is not a valid VAT number
147
- } else {
148
- // Other errors should be thrown
149
- throw e;
150
- }
151
- }
152
-
153
- return {
154
- companyNumber: result.value?.substring(2) ?? companyNumber,
155
-
156
- // VATNumber should always be set to null if it is not a valid VAT number
157
- VATNumber: null,
158
- };
159
- }
160
-
161
- async checkVATNumber(country: Country, vatNumber: string): Promise<string> {
162
- const result = jsvat.checkVAT(vatNumber, country === Country.Belgium ? [jsvat.belgium] : [jsvat.netherlands]);
163
-
164
- if (!result.isValid) {
165
- throw new SimpleError({
166
- code: 'invalid_field',
167
- message: $t('%1To', { number: vatNumber }),
168
- field: 'VATNumber',
169
- });
170
- }
171
-
172
- const formatted = result.value ?? vatNumber;
173
-
174
- if (STAMHOOFD.environment === 'development' && formatted === 'NL301828519B01') {
175
- return formatted;
176
- }
177
-
178
- try {
179
- const cleaned = formatted.substring(2).replace(/(?:\.-\s)+/g, '');
180
- const response = await this.request('POST', 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number', {
181
- countryCode: country,
182
- vatNumber: cleaned,
183
- });
184
-
185
- if (typeof response !== 'object' || response === null || typeof response.valid !== 'boolean') {
186
- // APi error
187
- throw new Error('Invalid response from VIES');
188
- }
189
-
190
- if (!response.valid) {
191
- throw new SimpleError({
192
- code: 'invalid_field',
193
- message: $t('%1TG', { 'vat-number': formatted }),
194
- field: 'VATNumber',
195
- });
196
- }
197
- } catch (e) {
198
- if (isSimpleError(e) || isSimpleErrors(e)) {
199
- throw e;
200
- }
201
- // Unavailable: ignore for now
202
- console.error('VIES error', e);
203
-
204
- throw new SimpleError({
205
- code: 'service_unavailable',
206
- message: $t('%1Sf'),
207
- field: 'VATNumber',
208
- });
209
- }
210
-
211
- return formatted;
212
- }
213
- }
214
-
215
- export const ViesHelper = new ViesHelperStatic();