@stamhoofd/backend 2.132.1 → 2.134.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.
@@ -1,11 +1,13 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import type { Organization, User, Webshop } from '@stamhoofd/models';
3
3
  import { GroupFactory, OrganizationFactory, OrganizationRegistrationPeriod, RegistrationPeriodFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
- import { AccessRight, MemberResponsibility, OrganizationPrivateMetaData, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PermissionLevel, PermissionRoleDetailed, PermissionRoleForResponsibility, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
4
+ import { AccessRight, Address, Company, MemberResponsibility, OrganizationMetaData, OrganizationPrivateMetaData, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PeppolEndointId, PermissionLevel, PermissionRoleDetailed, PermissionRoleForResponsibility, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
5
5
 
6
6
  import type { AutoEncoderPatchType, PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
7
7
  import { PatchableArray, PatchMap } from '@simonbackx/simple-encoding';
8
8
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
9
+ import { Country } from '@stamhoofd/types/Country';
10
+ import nock from 'nock';
9
11
  import { testServer } from '../../../../../tests/helpers/TestServer.js';
10
12
  import { PatchOrganizationEndpoint } from './PatchOrganizationEndpoint.js';
11
13
 
@@ -1282,6 +1284,192 @@ describe('Endpoint.PatchOrganization', () => {
1282
1284
  });
1283
1285
  });
1284
1286
 
1287
+ describe('custom PEPPOL endpoint id on companies', () => {
1288
+ const belgianAddress = () => Address.create({
1289
+ street: 'Demostraat',
1290
+ number: '12',
1291
+ city: 'Gent',
1292
+ postalCode: '9000',
1293
+ country: Country.Belgium,
1294
+ });
1295
+
1296
+ function mockDirectoryFound(schemeID: string, id: string, entityName = 'Directory Name') {
1297
+ return nock('https://directory.peppol.eu')
1298
+ .get('/search/1.0/json')
1299
+ .query({ participant: `iso6523-actorid-upis::${schemeID}:${id}` })
1300
+ .reply(200, {
1301
+ 'total-result-count': 1,
1302
+ matches: [{
1303
+ participantID: { scheme: 'iso6523-actorid-upis', value: `${schemeID}:${id}` },
1304
+ entities: [{ name: [{ name: entityName }] }],
1305
+ }],
1306
+ } as nock.Body);
1307
+ }
1308
+
1309
+ afterEach(() => {
1310
+ nock.cleanAll();
1311
+ });
1312
+
1313
+ const companiesPut = (company: Company): PatchableArrayAutoEncoder<Company> => {
1314
+ const arr: PatchableArrayAutoEncoder<Company> = new PatchableArray();
1315
+ arr.addPut(company);
1316
+ return arr;
1317
+ };
1318
+
1319
+ const companiesPatch = (patch: AutoEncoderPatchType<Company>): PatchableArrayAutoEncoder<Company> => {
1320
+ const arr: PatchableArrayAutoEncoder<Company> = new PatchableArray();
1321
+ arr.addPatch(patch);
1322
+ return arr;
1323
+ };
1324
+
1325
+ test('a normal organization admin cannot add a company with a custom PEPPOL endpoint id', async () => {
1326
+ const scope = mockDirectoryFound('0208', '0123456789');
1327
+ const organization = await new OrganizationFactory({}).create();
1328
+ const user = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
1329
+ const token = await Token.createToken(user);
1330
+
1331
+ const patch = OrganizationStruct.patch({
1332
+ id: organization.id,
1333
+ meta: OrganizationMetaData.patch({
1334
+ companies: companiesPut(Company.create({
1335
+ name: 'Demo Company',
1336
+ address: belgianAddress(),
1337
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0123456789' }),
1338
+ })),
1339
+ }),
1340
+ });
1341
+
1342
+ await expect(patchOrganization({ patch, organization, token })).rejects.toThrow(
1343
+ STExpect.simpleError({ code: 'permission_denied', field: 'customPeppolEndpointId' }),
1344
+ );
1345
+
1346
+ // The directory must not have been contacted: the permission check happens first.
1347
+ expect(scope.isDone()).toBe(false);
1348
+ });
1349
+
1350
+ test('a normal organization admin cannot patch a custom PEPPOL endpoint id onto an existing company', async () => {
1351
+ const organization = await new OrganizationFactory({}).create();
1352
+ const company = Company.create({ name: 'Demo Company', address: belgianAddress() });
1353
+ organization.meta.companies = [company];
1354
+ await organization.save();
1355
+
1356
+ const user = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
1357
+ const token = await Token.createToken(user);
1358
+
1359
+ const patch = OrganizationStruct.patch({
1360
+ id: organization.id,
1361
+ meta: OrganizationMetaData.patch({
1362
+ companies: companiesPatch(Company.patch({
1363
+ id: company.id,
1364
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0123456789' }),
1365
+ })),
1366
+ }),
1367
+ });
1368
+
1369
+ await expect(patchOrganization({ patch, organization, token })).rejects.toThrow(
1370
+ STExpect.simpleError({ code: 'permission_denied', field: 'customPeppolEndpointId' }),
1371
+ );
1372
+ });
1373
+
1374
+ test('a platform admin can set a custom PEPPOL endpoint id that is validated against the directory', async () => {
1375
+ const scope = mockDirectoryFound('0088', '5412345000013');
1376
+ const organization = await new OrganizationFactory({}).create();
1377
+ const user = await new UserFactory({ globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
1378
+ const token = await Token.createToken(user);
1379
+
1380
+ const patch = OrganizationStruct.patch({
1381
+ id: organization.id,
1382
+ meta: OrganizationMetaData.patch({
1383
+ companies: companiesPut(Company.create({
1384
+ name: 'Demo Company',
1385
+ address: belgianAddress(),
1386
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }),
1387
+ })),
1388
+ }),
1389
+ });
1390
+
1391
+ const response = await patchOrganization({ patch, organization, token });
1392
+
1393
+ expect(scope.isDone()).toBe(true);
1394
+ expect(response.body.meta.companies).toHaveLength(1);
1395
+ expect(response.body.meta.companies[0].customPeppolEndpointId).toMatchObject({
1396
+ schemeID: '0088',
1397
+ id: '5412345000013',
1398
+ // The registered entity name from the directory is stored.
1399
+ entityName: 'Directory Name',
1400
+ });
1401
+ });
1402
+
1403
+ test('a client-supplied entityName is ignored and overwritten by the directory value', async () => {
1404
+ mockDirectoryFound('0088', '5412345000013', 'Directory Name');
1405
+ const organization = await new OrganizationFactory({}).create();
1406
+ const user = await new UserFactory({ globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
1407
+ const token = await Token.createToken(user);
1408
+
1409
+ const patch = OrganizationStruct.patch({
1410
+ id: organization.id,
1411
+ meta: OrganizationMetaData.patch({
1412
+ companies: companiesPut(Company.create({
1413
+ name: 'Demo Company',
1414
+ address: belgianAddress(),
1415
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013', entityName: 'Spoofed name' }),
1416
+ })),
1417
+ }),
1418
+ });
1419
+
1420
+ const response = await patchOrganization({ patch, organization, token });
1421
+
1422
+ expect(response.body.meta.companies[0].customPeppolEndpointId?.entityName).toBe('Directory Name');
1423
+ });
1424
+
1425
+ test('a KBO custom PEPPOL endpoint id that does not match the company number is rejected', async () => {
1426
+ const organization = await new OrganizationFactory({}).create();
1427
+ const user = await new UserFactory({ globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
1428
+ const token = await Token.createToken(user);
1429
+
1430
+ const patch = OrganizationStruct.patch({
1431
+ id: organization.id,
1432
+ meta: OrganizationMetaData.patch({
1433
+ companies: companiesPut(Company.create({
1434
+ name: 'Demo Company',
1435
+ address: belgianAddress(),
1436
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '9999999999' }),
1437
+ })),
1438
+ }),
1439
+ });
1440
+
1441
+ await expect(patchOrganization({ patch, organization, token })).rejects.toThrow(
1442
+ STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
1443
+ );
1444
+ });
1445
+
1446
+ test('a platform admin gets an error when the custom PEPPOL endpoint id is unknown to the directory', async () => {
1447
+ nock('https://directory.peppol.eu')
1448
+ .get('/search/1.0/json')
1449
+ .query({ participant: 'iso6523-actorid-upis::0088:5412345000013' })
1450
+ .reply(200, { 'total-result-count': 0, matches: [] } as nock.Body);
1451
+
1452
+ const organization = await new OrganizationFactory({}).create();
1453
+ const user = await new UserFactory({ globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
1454
+ const token = await Token.createToken(user);
1455
+
1456
+ const patch = OrganizationStruct.patch({
1457
+ id: organization.id,
1458
+ meta: OrganizationMetaData.patch({
1459
+ companies: companiesPut(Company.create({
1460
+ name: 'Demo Company',
1461
+ address: belgianAddress(),
1462
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }),
1463
+ })),
1464
+ }),
1465
+ });
1466
+
1467
+ await expect(patchOrganization({ patch, organization, token })).rejects.toThrow(
1468
+ STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
1469
+ );
1470
+ });
1471
+ });
1472
+
1285
1473
  describe('userMode organization', () => {
1286
1474
  beforeEach(async () => {
1287
1475
  TestUtils.setEnvironment('userMode', 'organization');
@@ -660,6 +660,20 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
660
660
  return new Response(struct);
661
661
  }
662
662
 
663
+ /**
664
+ * A custom PEPPOL endpoint id may only be set by full platform admins for now.
665
+ */
666
+ private requirePeppolPermission() {
667
+ if (!Context.auth.hasPlatformFullAccess()) {
668
+ throw new SimpleError({
669
+ code: 'permission_denied',
670
+ message: 'Only platform admins can set a custom PEPPOL endpoint id',
671
+ human: $t('%Zcg'),
672
+ field: 'customPeppolEndpointId',
673
+ });
674
+ }
675
+ }
676
+
663
677
  async validateCompanies(organization: Organization, companies: PatchableArrayAutoEncoder<Company> | Company[]) {
664
678
  if (isPatchableArray(companies)) {
665
679
  for (const patch of companies.getPatches()) {
@@ -674,6 +688,10 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
674
688
  });
675
689
  }
676
690
 
691
+ if (patch.customPeppolEndpointId !== undefined) {
692
+ this.requirePeppolPermission();
693
+ }
694
+
677
695
  // Changed VAT number
678
696
  const prepatched = original.patch(patch);
679
697
  await ViesHelper.checkCompany(prepatched, patch);
@@ -692,6 +710,10 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
692
710
  });
693
711
  }
694
712
 
713
+ if (put.customPeppolEndpointId) {
714
+ this.requirePeppolPermission();
715
+ }
716
+
695
717
  await ViesHelper.checkCompany(put, put);
696
718
  }
697
719
  } else {
@@ -705,6 +727,10 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
705
727
  }
706
728
 
707
729
  for (const company of companies) {
730
+ if (company.customPeppolEndpointId) {
731
+ this.requirePeppolPermission();
732
+ }
733
+
708
734
  await ViesHelper.checkCompany(company, company);
709
735
  }
710
736
  }
@@ -0,0 +1,158 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { BalanceItem, BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
+ import type { PaginatedResponse, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PaymentMethod, PaymentStatus, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
6
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
7
+ import { GetPaymentsEndpoint } from './GetPaymentsEndpoint.js';
8
+
9
+ // These tests exercise the balance-item filters reused inside the payments query (balanceItemPayments ->
10
+ // balanceItem -> ...), which is the path where balance_items is joined into another query.
11
+ describe('Endpoint.GetPaymentsEndpoint', () => {
12
+ const endpoint = new GetPaymentsEndpoint();
13
+
14
+ const getPayments = async ({ filter, organization, user }: { filter: StamhoofdFilter | null; organization: Organization; user: User }) => {
15
+ const token = await Token.createToken(user);
16
+
17
+ const request = Request.get({
18
+ path: '/payments',
19
+ host: organization.getApiHost(),
20
+ query: new LimitedFilteredRequest({
21
+ filter,
22
+ limit: 100,
23
+ }),
24
+ headers: {
25
+ authorization: 'Bearer ' + token.accessToken,
26
+ },
27
+ });
28
+
29
+ return testServer.test<PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>>(endpoint, request);
30
+ };
31
+
32
+ const createFinanceUser = async (organization: Organization) => {
33
+ return await new UserFactory({
34
+ organization,
35
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
36
+ }).create();
37
+ };
38
+
39
+ const createPaymentForBalanceItem = async (organization: Organization, balanceItem: BalanceItem) => {
40
+ const payment = new Payment();
41
+ payment.method = PaymentMethod.Transfer;
42
+ payment.status = PaymentStatus.Succeeded;
43
+ payment.organizationId = organization.id;
44
+ payment.price = 10_00;
45
+ await payment.save();
46
+
47
+ const balanceItemPayment = new BalanceItemPayment();
48
+ balanceItemPayment.balanceItemId = balanceItem.id;
49
+ balanceItemPayment.paymentId = payment.id;
50
+ balanceItemPayment.price = 10_00;
51
+ balanceItemPayment.organizationId = organization.id;
52
+ await balanceItemPayment.save();
53
+
54
+ return payment;
55
+ };
56
+
57
+ describe('Filtering on the balance item of a payment', () => {
58
+ test('only returns payments whose balance item is linked to an order of the given webshop', async () => {
59
+ const organization = await new OrganizationFactory({}).create();
60
+ const user = await createFinanceUser(organization);
61
+
62
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
63
+ const otherWebshop = await new WebshopFactory({ organizationId: organization.id }).create();
64
+
65
+ const order = await new OrderFactory({ webshop }).create();
66
+ const otherOrder = await new OrderFactory({ webshop: otherWebshop }).create();
67
+
68
+ const matchingItem = await new BalanceItemFactory({
69
+ organizationId: organization.id,
70
+ orderId: order.id,
71
+ type: BalanceItemType.Order,
72
+ amount: 1,
73
+ unitPrice: 10_00,
74
+ }).create();
75
+ const matchingPayment = await createPaymentForBalanceItem(organization, matchingItem);
76
+
77
+ // Negative control: a payment for a balance item of another webshop's order
78
+ const otherItem = await new BalanceItemFactory({
79
+ organizationId: organization.id,
80
+ orderId: otherOrder.id,
81
+ type: BalanceItemType.Order,
82
+ amount: 1,
83
+ unitPrice: 10_00,
84
+ }).create();
85
+ await createPaymentForBalanceItem(organization, otherItem);
86
+
87
+ const response = await getPayments({
88
+ filter: {
89
+ balanceItemPayments: {
90
+ $elemMatch: {
91
+ balanceItem: {
92
+ order: {
93
+ webshopId: {
94
+ $in: [webshop.id],
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ },
101
+ organization,
102
+ user,
103
+ });
104
+
105
+ expect(response.status).toBe(200);
106
+ expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
107
+ });
108
+
109
+ test('only returns payments whose balance item matches the given membership type', async () => {
110
+ const organization = await new OrganizationFactory({}).create();
111
+ const user = await createFinanceUser(organization);
112
+
113
+ const createMembershipItem = async (membershipTypeId: string) => {
114
+ return await new BalanceItemFactory({
115
+ organizationId: organization.id,
116
+ type: BalanceItemType.PlatformMembership,
117
+ amount: 1,
118
+ unitPrice: 10_00,
119
+ relations: new Map([
120
+ [
121
+ BalanceItemRelationType.MembershipType,
122
+ BalanceItemRelation.create({
123
+ id: membershipTypeId,
124
+ name: new TranslatedString('Membership type'),
125
+ }),
126
+ ],
127
+ ]),
128
+ }).create();
129
+ };
130
+
131
+ const matchingItem = await createMembershipItem('membership-type-a');
132
+ const matchingPayment = await createPaymentForBalanceItem(organization, matchingItem);
133
+
134
+ // Negative control: a payment for a balance item with a different membership type
135
+ const otherItem = await createMembershipItem('membership-type-b');
136
+ await createPaymentForBalanceItem(organization, otherItem);
137
+
138
+ const response = await getPayments({
139
+ filter: {
140
+ balanceItemPayments: {
141
+ $elemMatch: {
142
+ balanceItem: {
143
+ membershipType: {
144
+ $in: ['membership-type-a'],
145
+ },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ organization,
151
+ user,
152
+ });
153
+
154
+ expect(response.status).toBe(200);
155
+ expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
156
+ });
157
+ });
158
+ });
@@ -235,7 +235,7 @@ export class StripeReportInvoicer {
235
235
  sellingOrganization,
236
236
  type: 'services',
237
237
  });
238
- item.VATIncluded = true;
238
+ item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
239
239
  item.quantity = 1;
240
240
  item.unitPrice = applicationFee.serviceFee;
241
241
  item.createdAt = new Date();
@@ -265,7 +265,7 @@ export class StripeReportInvoicer {
265
265
  sellingOrganization,
266
266
  type: 'services',
267
267
  });
268
- item.VATIncluded = true;
268
+ item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
269
269
  item.quantity = 1;
270
270
  item.unitPrice = applicationFee.transferFee;
271
271
  item.createdAt = new Date();
@@ -276,6 +276,17 @@ export class StripeReportInvoicer {
276
276
  balanceItems.push(item);
277
277
  }
278
278
 
279
+ let total = 0;
280
+ for (const balanceItem of balanceItems) {
281
+ total += balanceItem.priceWithVAT;
282
+ }
283
+ if (total !== applicationFee.amount) {
284
+ throw new SimpleError({
285
+ code: 'price_mismatched',
286
+ message: 'The charged amount does not match the total application fee for the payment',
287
+ });
288
+ }
289
+
279
290
  const systemUser = await User.getSystem();
280
291
 
281
292
  // Done validation
@@ -1,4 +1,4 @@
1
- import { Address, Company } from '@stamhoofd/structures';
1
+ import { Address, Company, PeppolEndointId } from '@stamhoofd/structures';
2
2
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
3
3
  import { Country } from '@stamhoofd/types/Country';
4
4
  import nock from 'nock';
@@ -7,6 +7,9 @@ import { ViesHelper } from './ViesHelper.js';
7
7
  const VIES_HOST = 'https://ec.europa.eu';
8
8
  const VIES_PATH = '/taxation_customs/vies/rest-api/check-vat-number';
9
9
 
10
+ const DIRECTORY_HOST = 'https://directory.peppol.eu';
11
+ const DIRECTORY_PATH = '/search/1.0/json';
12
+
10
13
  describe('ViesHelper', () => {
11
14
  /**
12
15
  * Registers a mock for the (only) external dependency: the VIES check-vat-number endpoint.
@@ -253,5 +256,146 @@ describe('ViesHelper', () => {
253
256
  expect(patch.VATNumber).toBe('BE0411905847');
254
257
  expect(patch.companyNumber).toBe('0411905847');
255
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
+ });
256
400
  });
257
401
  });
@@ -1,9 +1,11 @@
1
1
  import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
3
  import type { Company } from '@stamhoofd/structures';
4
+ import { PeppolScheme } from '@stamhoofd/structures';
4
5
  import { Country } from '@stamhoofd/types/Country';
5
6
  import axios from 'axios';
6
7
  import jsvat from 'jsvat-next';
8
+ import { PeppolDirectoryService } from '../services/PeppolDirectoryService.js';
7
9
 
8
10
  export class ViesHelperStatic {
9
11
  testMode = false;
@@ -27,6 +29,43 @@ export class ViesHelperStatic {
27
29
  }
28
30
 
29
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
+
30
69
  if (!company.address) {
31
70
  // Not allowed to set
32
71
  patch.companyNumber = null;