@stamhoofd/backend 2.132.1 → 2.133.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.132.1",
3
+ "version": "2.133.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -57,20 +57,20 @@
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.132.1",
61
- "@stamhoofd/backend-i18n": "2.132.1",
62
- "@stamhoofd/backend-middleware": "2.132.1",
63
- "@stamhoofd/crons": "2.132.1",
64
- "@stamhoofd/email": "2.132.1",
65
- "@stamhoofd/excel-writer": "2.132.1",
66
- "@stamhoofd/logging": "2.132.1",
67
- "@stamhoofd/models": "2.132.1",
68
- "@stamhoofd/object-differ": "2.132.1",
69
- "@stamhoofd/queues": "2.132.1",
70
- "@stamhoofd/sql": "2.132.1",
71
- "@stamhoofd/structures": "2.132.1",
72
- "@stamhoofd/types": "2.132.1",
73
- "@stamhoofd/utility": "2.132.1",
60
+ "@stamhoofd/backend-env": "2.133.0",
61
+ "@stamhoofd/backend-i18n": "2.133.0",
62
+ "@stamhoofd/backend-middleware": "2.133.0",
63
+ "@stamhoofd/crons": "2.133.0",
64
+ "@stamhoofd/email": "2.133.0",
65
+ "@stamhoofd/excel-writer": "2.133.0",
66
+ "@stamhoofd/logging": "2.133.0",
67
+ "@stamhoofd/models": "2.133.0",
68
+ "@stamhoofd/object-differ": "2.133.0",
69
+ "@stamhoofd/queues": "2.133.0",
70
+ "@stamhoofd/sql": "2.133.0",
71
+ "@stamhoofd/structures": "2.133.0",
72
+ "@stamhoofd/types": "2.133.0",
73
+ "@stamhoofd/utility": "2.133.0",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.132.1",
94
+ "@stamhoofd/test-utils": "2.133.0",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "99e4195c2638521cf24f1e5ebef5ae9bf5009d1d"
110
+ "gitHead": "65d5f0a31f87b8e3b7a23349f08cf7943ef15bda"
111
111
  }
@@ -133,7 +133,7 @@ export class GetGroupsEndpoint extends Endpoint<Params, Query, Body, ResponseBod
133
133
  }
134
134
  }
135
135
 
136
- const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
136
+ const maxLimit = Context.optionalAuth?.hasSomePlatformAccess() ? 1000 : 100;
137
137
 
138
138
  if (request.query.limit > maxLimit) {
139
139
  throw new SimpleError({
@@ -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
  }
@@ -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;
@@ -0,0 +1,161 @@
1
+ import { BalanceItemFactory, Invoice, InvoicedBalanceItem, OrganizationFactory } from '@stamhoofd/models';
2
+ import { Address, Company, File, PaymentCustomer, PeppolEndointId, VATSubtotal } from '@stamhoofd/structures';
3
+ import { Country } from '@stamhoofd/types/Country';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { vi } from 'vitest';
6
+ import { InvoicePdfService } from './InvoicePdfService.js';
7
+ import { InvoiceXMlService } from './InvoiceXMLService.js';
8
+
9
+ describe('InvoiceXMlService', () => {
10
+ beforeEach(() => {
11
+ // The pdf is embedded as base64 in the UBL; avoid the real network download.
12
+ vi.spyOn(InvoicePdfService, 'downloadPdf').mockResolvedValue(Buffer.from('%PDF-1.4 test'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ const belgianAddress = () => Address.create({
20
+ street: 'Teststraat',
21
+ number: '1',
22
+ city: 'Gent',
23
+ postalCode: '9000',
24
+ country: Country.Belgium,
25
+ });
26
+
27
+ const seller = () => Company.create({
28
+ name: 'Seller BV',
29
+ VATNumber: 'BE0411905847',
30
+ companyNumber: '0411905847',
31
+ address: belgianAddress(),
32
+ administrationEmail: 'billing@seller.be',
33
+ });
34
+
35
+ /**
36
+ * Builds and persists a minimal, valid invoice for the given customer company, with one
37
+ * invoiced line, so that InvoiceXMlService.buildXml can generate the UBL.
38
+ */
39
+ const buildInvoiceFor = async (company: Company) => {
40
+ const organization = await new OrganizationFactory({}).create();
41
+
42
+ const invoice = new Invoice();
43
+ invoice.organizationId = organization.id;
44
+ invoice.number = 'F2026-001';
45
+ invoice.invoicedAt = new Date(2026, 0, 15);
46
+ invoice.pdf = new File({ id: uuidv4(), server: 'https://files.example.com', path: 'test.pdf', size: 100, name: 'invoice', contentType: 'application/pdf' });
47
+ invoice.customer = PaymentCustomer.create({ company });
48
+ invoice.seller = seller();
49
+ invoice.totalWithVAT = 12_10;
50
+ invoice.totalWithoutVAT = 10_00;
51
+ invoice.VATTotalAmount = 2_10;
52
+ invoice.payableRoundingAmount = 0;
53
+ invoice.VATTotal = [VATSubtotal.create({ VATPercentage: 21, taxablePrice: 10_00, VAT: 2_10 })];
54
+ await invoice.save();
55
+
56
+ const balanceItem = await new BalanceItemFactory({ organizationId: organization.id, amount: 1, unitPrice: 10_00 }).create();
57
+
58
+ const item = new InvoicedBalanceItem();
59
+ item.organizationId = organization.id;
60
+ item.invoiceId = invoice.id;
61
+ item.balanceItemId = balanceItem.id;
62
+ item.name = 'Test item';
63
+ item.unitPrice = 10_00;
64
+ item.totalWithoutVAT = 10_00;
65
+ item.quantity = 1_00_00;
66
+ item.VATPercentage = 21;
67
+ item.balanceInvoicedAmount = 12_10;
68
+ await item.save();
69
+
70
+ return invoice;
71
+ };
72
+
73
+ test('generates XML for a Belgian company with a VAT number', async () => {
74
+ const company = Company.create({
75
+ name: 'Customer BV',
76
+ VATNumber: 'BE0123456749',
77
+ companyNumber: '0123456749',
78
+ address: belgianAddress(),
79
+ });
80
+ const invoice = await buildInvoiceFor(company);
81
+
82
+ const xml = await InvoiceXMlService.buildXml(invoice);
83
+
84
+ expect(xml).toContain('<cbc:Name>Customer BV</cbc:Name>');
85
+ // No custom PEPPOL id: the endpoint id is the derived KBO number.
86
+ expect(xml).toContain('<cbc:EndpointID schemeID="0208">0123456749</cbc:EndpointID>');
87
+ // The legal entity company id is the derived KBO number.
88
+ expect(xml).toContain('<cbc:CompanyID schemeID="0208">0123456749</cbc:CompanyID>');
89
+ // The VAT number is registered as a tax scheme.
90
+ expect(xml).toContain('<cbc:CompanyID>BE0123456749</cbc:CompanyID>');
91
+ });
92
+
93
+ test('generates XML for a Belgian company with only a company number', async () => {
94
+ const company = Company.create({
95
+ name: 'Customer VZW',
96
+ VATNumber: null,
97
+ companyNumber: '0123456749',
98
+ address: belgianAddress(),
99
+ });
100
+ const invoice = await buildInvoiceFor(company);
101
+
102
+ const xml = await InvoiceXMlService.buildXml(invoice);
103
+
104
+ expect(xml).toContain('<cbc:Name>Customer VZW</cbc:Name>');
105
+ expect(xml).toContain('<cbc:EndpointID schemeID="0208">0123456749</cbc:EndpointID>');
106
+ expect(xml).toContain('<cbc:CompanyID schemeID="0208">0123456749</cbc:CompanyID>');
107
+ // No VAT number for this company: no customer VAT tax scheme.
108
+ expect(xml).not.toContain('BE0123456749');
109
+ });
110
+
111
+ test('generates XML for a Belgian company with a VAT number and a custom PEPPOL id', async () => {
112
+ const company = Company.create({
113
+ name: 'Customer BV',
114
+ VATNumber: 'BE0123456749',
115
+ companyNumber: '0123456749',
116
+ address: belgianAddress(),
117
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }),
118
+ });
119
+ const invoice = await buildInvoiceFor(company);
120
+
121
+ const xml = await InvoiceXMlService.buildXml(invoice);
122
+
123
+ // The custom PEPPOL id is used as the delivery endpoint id.
124
+ expect(xml).toContain('<cbc:EndpointID schemeID="0088">5412345000013</cbc:EndpointID>');
125
+ // But the legal entity company id remains the derived KBO number.
126
+ expect(xml).toContain('<cbc:CompanyID schemeID="0208">0123456749</cbc:CompanyID>');
127
+ expect(xml).toContain('<cbc:CompanyID>BE0123456749</cbc:CompanyID>');
128
+ });
129
+
130
+ test('generates XML for a Belgian company with only a company number and a custom PEPPOL id', async () => {
131
+ const company = Company.create({
132
+ name: 'Customer VZW',
133
+ VATNumber: null,
134
+ companyNumber: '0123456749',
135
+ address: belgianAddress(),
136
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0060', id: '123456789' }),
137
+ });
138
+ const invoice = await buildInvoiceFor(company);
139
+
140
+ const xml = await InvoiceXMlService.buildXml(invoice);
141
+
142
+ expect(xml).toContain('<cbc:EndpointID schemeID="0060">123456789</cbc:EndpointID>');
143
+ expect(xml).toContain('<cbc:CompanyID schemeID="0208">0123456749</cbc:CompanyID>');
144
+ expect(xml).not.toContain('BE0123456749');
145
+ });
146
+
147
+ test('cannot generate XML for a company without a company number, even with a custom PEPPOL id', async () => {
148
+ const company = Company.create({
149
+ name: 'No Number',
150
+ VATNumber: null,
151
+ companyNumber: null,
152
+ address: belgianAddress(),
153
+ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }),
154
+ });
155
+ const invoice = await buildInvoiceFor(company);
156
+
157
+ // The legal entity company id (peppolCompanyId) requires a VAT or company number,
158
+ // so a custom endpoint id alone is not enough.
159
+ await expect(InvoiceXMlService.buildXml(invoice)).rejects.toThrow('Missing customer peppol id');
160
+ });
161
+ });
@@ -202,7 +202,7 @@ export class InvoiceXMlService {
202
202
  }
203
203
  <cac:PartyLegalEntity>
204
204
  <cbc:RegistrationName>${esc(company.name)}</cbc:RegistrationName>
205
- <cbc:CompanyID schemeID="0208">${esc(companyNumber)}</cbc:CompanyID>
205
+ <cbc:CompanyID schemeID="${esc(customerPeppolCompanyId.schemeID)}">${esc(customerPeppolCompanyId.id)}</cbc:CompanyID>
206
206
  </cac:PartyLegalEntity>
207
207
  ${
208
208
  customerEmail
@@ -398,15 +398,17 @@ export class InvoiceXMlService {
398
398
  return;
399
399
  }
400
400
 
401
- if (invoice.customer.company?.VATNumber === null) {
402
- console.log('Skipping PEPPOL for invoice ' + invoice.id + ', recipient not subject to VAT');
403
- return;
404
- }
401
+ if (!invoice.customer.company?.customPeppolEndpointId) {
402
+ if (invoice.customer.company?.VATNumber === null) {
403
+ console.log('Skipping PEPPOL for invoice ' + invoice.id + ', recipient not subject to VAT');
404
+ return;
405
+ }
405
406
 
406
- // Check VAT number is belgian
407
- if (!invoice.customer.company?.VATNumber.startsWith('BE')) {
408
- console.log('Skipping PEPPOL for invoice ' + invoice.id + ', recipient outside Belgium');
409
- return;
407
+ // Check VAT number is belgian
408
+ if (!invoice.customer.company?.VATNumber.startsWith('BE')) {
409
+ console.log('Skipping PEPPOL for invoice ' + invoice.id + ', recipient outside Belgium');
410
+ return;
411
+ }
410
412
  }
411
413
 
412
414
  try {
@@ -0,0 +1,139 @@
1
+ import { PeppolEndointId } from '@stamhoofd/structures';
2
+ import { STExpect } from '@stamhoofd/test-utils';
3
+ import nock from 'nock';
4
+ import { PeppolDirectoryService } from './PeppolDirectoryService.js';
5
+
6
+ const DIRECTORY_HOST = 'https://directory.peppol.eu';
7
+ const DIRECTORY_PATH = '/search/1.0/json';
8
+
9
+ describe('PeppolDirectoryService', () => {
10
+ /**
11
+ * Mocks the PEPPOL directory search endpoint for a given participant query.
12
+ * Returns the nock scope so tests can assert whether it was actually called.
13
+ */
14
+ function mockDirectory(participant: string, response: unknown, statusCode = 200) {
15
+ return nock(DIRECTORY_HOST)
16
+ .get(DIRECTORY_PATH)
17
+ .query({ participant })
18
+ .reply(statusCode, response as nock.Body);
19
+ }
20
+
21
+ /**
22
+ * A directory response that contains a single match for the given participant value.
23
+ */
24
+ function matchResponse(value: string) {
25
+ return {
26
+ version: '1.0',
27
+ 'total-result-count': 1,
28
+ matches: [
29
+ {
30
+ participantID: { scheme: 'iso6523-actorid-upis', value },
31
+ entities: [{ name: [{ name: 'Demo Company' }], countryCode: 'BE' }],
32
+ },
33
+ ],
34
+ };
35
+ }
36
+
37
+ afterEach(() => {
38
+ nock.cleanAll();
39
+ nock.disableNetConnect();
40
+ });
41
+
42
+ test('resolves when the participant is registered in the directory', async () => {
43
+ const scope = mockDirectory('iso6523-actorid-upis::0208:0123456789', matchResponse('0208:0123456789'));
44
+
45
+ const endpointId = PeppolEndointId.create({ schemeID: '0208', id: '0123456789' });
46
+ await expect(PeppolDirectoryService.validate(endpointId)).resolves.toBeUndefined();
47
+
48
+ expect(scope.isDone()).toBe(true);
49
+ // The registered entity name from the directory is stored on the endpoint id.
50
+ expect(endpointId.entityName).toBe('Demo Company');
51
+ });
52
+
53
+ test('overwrites a pre-existing entityName with the value from the directory', async () => {
54
+ mockDirectory('iso6523-actorid-upis::0208:0123456789', matchResponse('0208:0123456789'));
55
+
56
+ const endpointId = PeppolEndointId.create({ schemeID: '0208', id: '0123456789', entityName: 'Client supplied name' });
57
+ await PeppolDirectoryService.validate(endpointId);
58
+
59
+ expect(endpointId.entityName).toBe('Demo Company');
60
+ });
61
+
62
+ test('sets entityName to null when the directory match has no entity name', async () => {
63
+ mockDirectory('iso6523-actorid-upis::0208:0123456789', {
64
+ 'total-result-count': 1,
65
+ matches: [{ participantID: { scheme: 'iso6523-actorid-upis', value: '0208:0123456789' }, entities: [] }],
66
+ });
67
+
68
+ const endpointId = PeppolEndointId.create({ schemeID: '0208', id: '0123456789', entityName: 'stale' });
69
+ await PeppolDirectoryService.validate(endpointId);
70
+
71
+ expect(endpointId.entityName).toBeNull();
72
+ });
73
+
74
+ test('trims whitespace around the scheme and id before querying', async () => {
75
+ const scope = mockDirectory('iso6523-actorid-upis::0088:5412345000013', matchResponse('0088:5412345000013'));
76
+
77
+ await expect(
78
+ PeppolDirectoryService.validate(PeppolEndointId.create({ schemeID: ' 0088 ', id: ' 5412345000013 ' })),
79
+ ).resolves.toBeUndefined();
80
+
81
+ expect(scope.isDone()).toBe(true);
82
+ });
83
+
84
+ test('rejects an unsupported scheme without contacting the directory', async () => {
85
+ const scope = mockDirectory('iso6523-actorid-upis::9999:123', matchResponse('9999:123'));
86
+
87
+ await expect(
88
+ PeppolDirectoryService.validate(PeppolEndointId.create({ schemeID: '9999', id: '123' })),
89
+ ).rejects.toThrow(STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }));
90
+
91
+ expect(scope.isDone()).toBe(false);
92
+ });
93
+
94
+ test('rejects an empty id without contacting the directory', async () => {
95
+ const scope = mockDirectory('iso6523-actorid-upis::0208:', matchResponse('0208:'));
96
+
97
+ await expect(
98
+ PeppolDirectoryService.validate(PeppolEndointId.create({ schemeID: '0208', id: ' ' })),
99
+ ).rejects.toThrow(STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }));
100
+
101
+ expect(scope.isDone()).toBe(false);
102
+ });
103
+
104
+ test('rejects when the participant is not found in the directory', async () => {
105
+ mockDirectory('iso6523-actorid-upis::0060:123456789', {
106
+ version: '1.0',
107
+ 'total-result-count': 0,
108
+ matches: [],
109
+ });
110
+
111
+ await expect(
112
+ PeppolDirectoryService.validate(PeppolEndointId.create({ schemeID: '0060', id: '123456789' })),
113
+ ).rejects.toThrow(STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }));
114
+ });
115
+
116
+ test('rejects when the directory only returns a different participant', async () => {
117
+ mockDirectory('iso6523-actorid-upis::0208:0123456789', matchResponse('0208:9999999999'));
118
+
119
+ await expect(
120
+ PeppolDirectoryService.validate(PeppolEndointId.create({ schemeID: '0208', id: '0123456789' })),
121
+ ).rejects.toThrow(STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }));
122
+ });
123
+
124
+ test('throws service_unavailable when the directory errors out', async () => {
125
+ mockDirectory('iso6523-actorid-upis::0208:0123456789', { message: 'Internal Server Error' }, 500);
126
+
127
+ await expect(
128
+ PeppolDirectoryService.validate(PeppolEndointId.create({ schemeID: '0208', id: '0123456789' })),
129
+ ).rejects.toThrow(STExpect.simpleError({ code: 'service_unavailable', field: 'customPeppolEndpointId' }));
130
+ });
131
+
132
+ test('throws service_unavailable when the directory returns an unexpected response', async () => {
133
+ mockDirectory('iso6523-actorid-upis::0208:0123456789', { unexpected: true });
134
+
135
+ await expect(
136
+ PeppolDirectoryService.validate(PeppolEndointId.create({ schemeID: '0208', id: '0123456789' })),
137
+ ).rejects.toThrow(STExpect.simpleError({ code: 'service_unavailable', field: 'customPeppolEndpointId' }));
138
+ });
139
+ });
@@ -0,0 +1,111 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import type { PeppolEndointId } from '@stamhoofd/structures';
3
+ import { PeppolScheme } from '@stamhoofd/structures';
4
+ import axios from 'axios';
5
+
6
+ /**
7
+ * The PEPPOL schemes (ISO 6523 ICD codes) we currently support for a custom
8
+ * PEPPOL endpoint id. The scheme identifier is the numeric code used in the
9
+ * PEPPOL participant identifier (e.g. `iso6523-actorid-upis::0208:...`).
10
+ */
11
+ export const supportedPeppolSchemes: string[] = Object.values(PeppolScheme);
12
+
13
+ export class PeppolDirectoryServiceStatic {
14
+ /**
15
+ * Validates a custom PEPPOL endpoint id: the scheme must be supported and the
16
+ * participant must be registered in the PEPPOL directory. Throws a SimpleError
17
+ * (with field `customPeppolEndpointId`) when the id is invalid or unknown.
18
+ */
19
+ async validate(endpointId: PeppolEndointId): Promise<void> {
20
+ const schemeID = endpointId.schemeID.trim();
21
+ const id = endpointId.id.trim();
22
+
23
+ if (!supportedPeppolSchemes.includes(schemeID)) {
24
+ throw new SimpleError({
25
+ code: 'invalid_field',
26
+ message: 'Unsupported PEPPOL scheme ' + schemeID,
27
+ human: $t('%ZcX'),
28
+ field: 'customPeppolEndpointId',
29
+ });
30
+ }
31
+
32
+ if (id.length === 0) {
33
+ throw new SimpleError({
34
+ code: 'invalid_field',
35
+ message: 'Missing PEPPOL endpoint id',
36
+ human: $t('%ZcY'),
37
+ field: 'customPeppolEndpointId',
38
+ });
39
+ }
40
+
41
+ const participant = `iso6523-actorid-upis::${schemeID}:${id}`;
42
+
43
+ let data: any;
44
+ try {
45
+ const response = await axios.request({
46
+ method: 'GET',
47
+ url: 'https://directory.peppol.eu/search/1.0/json',
48
+ params: { participant },
49
+ });
50
+ data = response.data;
51
+ } catch (e) {
52
+ // The directory is unavailable: we can't validate the id right now.
53
+ console.error('PEPPOL directory error', e);
54
+ throw new SimpleError({
55
+ code: 'service_unavailable',
56
+ message: 'PEPPOL directory unavailable',
57
+ human: $t('%Zcf'),
58
+ field: 'customPeppolEndpointId',
59
+ });
60
+ }
61
+
62
+ if (typeof data !== 'object' || data === null || !Array.isArray(data.matches)) {
63
+ throw new SimpleError({
64
+ code: 'service_unavailable',
65
+ message: 'Invalid response from PEPPOL directory',
66
+ human: $t('%Zcf'),
67
+ field: 'customPeppolEndpointId',
68
+ });
69
+ }
70
+
71
+ console.log('PEPPOL lookup', participant, data);
72
+
73
+ const expected = `${schemeID}:${id}`.toLowerCase();
74
+ const match = data.matches.find((m: any) => {
75
+ return typeof m?.participantID?.value === 'string'
76
+ && m.participantID.value.toLowerCase() === expected;
77
+ });
78
+
79
+ if (!match) {
80
+ throw new SimpleError({
81
+ code: 'invalid_field',
82
+ message: 'PEPPOL participant not found in directory: ' + participant,
83
+ human: $t('%Zcr', { id: `${schemeID}:${id}` }),
84
+ field: 'customPeppolEndpointId',
85
+ });
86
+ }
87
+
88
+ // Store the registered entity name from the directory. This is server-controlled
89
+ // and overwrites any value the client may have sent (see ViesHelper.checkCompany).
90
+ endpointId.entityName = this.extractEntityName(match);
91
+ }
92
+
93
+ /**
94
+ * Best-effort extraction of the registered entity name from a PEPPOL directory match.
95
+ * The directory returns one or more entities, each with a list of localized names.
96
+ */
97
+ private extractEntityName(match: any): string | null {
98
+ const entities = Array.isArray(match?.entities) ? match.entities : [];
99
+ for (const entity of entities) {
100
+ const names = Array.isArray(entity?.name) ? entity.name : [];
101
+ for (const n of names) {
102
+ if (typeof n?.name === 'string' && n.name.trim().length > 0) {
103
+ return n.name.trim();
104
+ }
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ }
110
+
111
+ export const PeppolDirectoryService = new PeppolDirectoryServiceStatic();