@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.
- package/package.json +18 -18
- package/src/crons/invoices.ts +5 -3
- package/src/endpoints/global/groups/GetGroupsEndpoint.ts +1 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +190 -2
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +72 -14
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +183 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +189 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +26 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +158 -0
- package/src/helpers/StripeInvoicer.ts +13 -2
- package/src/helpers/ViesHelper.test.ts +145 -1
- package/src/helpers/ViesHelper.ts +39 -0
- package/src/services/InvoiceXMLService.test.ts +161 -0
- package/src/services/InvoiceXMLService.ts +11 -9
- package/src/services/PeppolDirectoryService.test.ts +139 -0
- package/src/services/PeppolDirectoryService.ts +111 -0
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/sql-filters/balance-item-payments.ts +4 -56
- package/src/sql-filters/balance-items.ts +69 -10
|
@@ -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 =
|
|
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 =
|
|
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;
|