@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
|
@@ -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="
|
|
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?.
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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();
|
|
@@ -74,7 +74,7 @@ export async function checkPermissionsFor(access_token: string, organizationId:
|
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
76
|
assertIsPermissionsResponse(json);
|
|
77
|
-
const neededPermissions = organizationId
|
|
77
|
+
const neededPermissions = !organizationId
|
|
78
78
|
? [{
|
|
79
79
|
permission: 'PASSES_READ',
|
|
80
80
|
human: 'Basis UiTPAS informatie ophalen met UiTPAS nummer',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SQLFilterDefinitions } from '@stamhoofd/sql';
|
|
2
|
-
import { baseSQLFilterCompilers, createColumnFilter,
|
|
2
|
+
import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
|
|
3
|
+
import { balanceItemFilterCompilers } from './balance-items.js';
|
|
3
4
|
|
|
4
5
|
export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
|
|
5
6
|
...baseSQLFilterCompilers,
|
|
@@ -13,59 +14,6 @@ export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
|
|
|
13
14
|
type: SQLValueType.Number,
|
|
14
15
|
nullable: false,
|
|
15
16
|
}),
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
id: createColumnFilter({
|
|
19
|
-
expression: SQL.column('balance_items', 'id'),
|
|
20
|
-
type: SQLValueType.String,
|
|
21
|
-
nullable: false,
|
|
22
|
-
}),
|
|
23
|
-
description: createColumnFilter({
|
|
24
|
-
expression: SQL.column('balance_items', 'description'),
|
|
25
|
-
type: SQLValueType.String,
|
|
26
|
-
nullable: false,
|
|
27
|
-
}),
|
|
28
|
-
payingOrganizationId: createColumnFilter({
|
|
29
|
-
expression: SQL.column('balance_items', 'payingOrganizationId'),
|
|
30
|
-
type: SQLValueType.String,
|
|
31
|
-
nullable: true,
|
|
32
|
-
}),
|
|
33
|
-
type: createColumnFilter({
|
|
34
|
-
expression: SQL.column('balance_items', 'type'),
|
|
35
|
-
type: SQLValueType.String,
|
|
36
|
-
nullable: false,
|
|
37
|
-
}),
|
|
38
|
-
registration: createExistsFilter(
|
|
39
|
-
SQL.select()
|
|
40
|
-
.from(SQL.table('registrations'))
|
|
41
|
-
.where(
|
|
42
|
-
SQL.column('registrations', 'id'),
|
|
43
|
-
SQL.column('balance_items', 'registrationId'),
|
|
44
|
-
),
|
|
45
|
-
{
|
|
46
|
-
...baseSQLFilterCompilers,
|
|
47
|
-
groupId: createColumnFilter({
|
|
48
|
-
expression: SQL.column('registrations', 'groupId'),
|
|
49
|
-
type: SQLValueType.String,
|
|
50
|
-
nullable: false,
|
|
51
|
-
}),
|
|
52
|
-
},
|
|
53
|
-
),
|
|
54
|
-
order: createExistsFilter(
|
|
55
|
-
SQL.select()
|
|
56
|
-
.from(SQL.table('webshop_orders'))
|
|
57
|
-
.where(
|
|
58
|
-
SQL.column('webshop_orders', 'id'),
|
|
59
|
-
SQL.column('balance_items', 'orderId'),
|
|
60
|
-
),
|
|
61
|
-
{
|
|
62
|
-
...baseSQLFilterCompilers,
|
|
63
|
-
webshopId: createColumnFilter({
|
|
64
|
-
expression: SQL.column('webshop_orders', 'webshopId'),
|
|
65
|
-
type: SQLValueType.String,
|
|
66
|
-
nullable: false,
|
|
67
|
-
}),
|
|
68
|
-
},
|
|
69
|
-
),
|
|
70
|
-
},
|
|
17
|
+
// Reuse the shared balance item filters (type, webshop, group, membership type, ...)
|
|
18
|
+
balanceItem: balanceItemFilterCompilers,
|
|
71
19
|
};
|
|
@@ -1,56 +1,115 @@
|
|
|
1
1
|
import type { SQLFilterDefinitions } from '@stamhoofd/sql';
|
|
2
|
-
import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
|
|
2
|
+
import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLValueType } from '@stamhoofd/sql';
|
|
3
|
+
import { BalanceItemRelationType } from '@stamhoofd/structures';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Filters on the relations of a balance item (registration group, webshop order and membership type).
|
|
7
|
+
*
|
|
8
|
+
* Grouped separately from the plain column filters because these are JOIN/JSON based rather than simple
|
|
9
|
+
* columns. They are spread into balanceItemFilterCompilers below, which is the single set consumed both
|
|
10
|
+
* by the direct balance items endpoint and by the balance items nested inside payments (see
|
|
11
|
+
* balance-item-payments.ts) — so both automatically expose these relation filters.
|
|
12
|
+
*
|
|
13
|
+
* All expressions are qualified with the balance_items table so they keep working when balance_items
|
|
14
|
+
* is joined into another query (e.g. via balance_item_payments).
|
|
15
|
+
*/
|
|
16
|
+
export const balanceItemRelationFilterCompilers: SQLFilterDefinitions = {
|
|
17
|
+
registration: createExistsFilter(
|
|
18
|
+
SQL.select()
|
|
19
|
+
.from(SQL.table('registrations'))
|
|
20
|
+
.where(
|
|
21
|
+
SQL.column('registrations', 'id'),
|
|
22
|
+
SQL.column('balance_items', 'registrationId'),
|
|
23
|
+
),
|
|
24
|
+
{
|
|
25
|
+
...baseSQLFilterCompilers,
|
|
26
|
+
groupId: createColumnFilter({
|
|
27
|
+
expression: SQL.column('registrations', 'groupId'),
|
|
28
|
+
type: SQLValueType.String,
|
|
29
|
+
nullable: false,
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
),
|
|
33
|
+
order: createExistsFilter(
|
|
34
|
+
SQL.select()
|
|
35
|
+
.from(SQL.table('webshop_orders'))
|
|
36
|
+
.where(
|
|
37
|
+
SQL.column('webshop_orders', 'id'),
|
|
38
|
+
SQL.column('balance_items', 'orderId'),
|
|
39
|
+
),
|
|
40
|
+
{
|
|
41
|
+
...baseSQLFilterCompilers,
|
|
42
|
+
webshopId: createColumnFilter({
|
|
43
|
+
expression: SQL.column('webshop_orders', 'webshopId'),
|
|
44
|
+
type: SQLValueType.String,
|
|
45
|
+
nullable: false,
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
// The membership type is only stored inside the relations JSON, so we read its id from there.
|
|
50
|
+
membershipType: createColumnFilter({
|
|
51
|
+
expression: SQL.jsonExtract(SQL.column('balance_items', 'relations'), `$.value.${BalanceItemRelationType.MembershipType}.id`),
|
|
52
|
+
type: SQLValueType.JSONString,
|
|
53
|
+
nullable: true,
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
3
56
|
|
|
4
57
|
/**
|
|
5
58
|
* Defines how to filter balance items in the database from StamhoofdFilter objects
|
|
6
59
|
*/
|
|
7
60
|
export const balanceItemFilterCompilers: SQLFilterDefinitions = {
|
|
8
61
|
...baseSQLFilterCompilers,
|
|
62
|
+
...balanceItemRelationFilterCompilers,
|
|
9
63
|
id: createColumnFilter({
|
|
10
|
-
expression: SQL.column('id'),
|
|
64
|
+
expression: SQL.column('balance_items', 'id'),
|
|
11
65
|
type: SQLValueType.String,
|
|
12
66
|
nullable: false,
|
|
13
67
|
}),
|
|
14
68
|
organizationId: createColumnFilter({
|
|
15
|
-
expression: SQL.column('organizationId'),
|
|
69
|
+
expression: SQL.column('balance_items', 'organizationId'),
|
|
16
70
|
type: SQLValueType.String,
|
|
17
71
|
nullable: false,
|
|
18
72
|
}),
|
|
73
|
+
payingOrganizationId: createColumnFilter({
|
|
74
|
+
expression: SQL.column('balance_items', 'payingOrganizationId'),
|
|
75
|
+
type: SQLValueType.String,
|
|
76
|
+
nullable: true,
|
|
77
|
+
}),
|
|
19
78
|
type: createColumnFilter({
|
|
20
|
-
expression: SQL.column('type'),
|
|
79
|
+
expression: SQL.column('balance_items', 'type'),
|
|
21
80
|
type: SQLValueType.String,
|
|
22
81
|
nullable: false,
|
|
23
82
|
}),
|
|
24
83
|
status: createColumnFilter({
|
|
25
|
-
expression: SQL.column('status'),
|
|
84
|
+
expression: SQL.column('balance_items', 'status'),
|
|
26
85
|
type: SQLValueType.String,
|
|
27
86
|
nullable: false,
|
|
28
87
|
}),
|
|
29
88
|
createdAt: createColumnFilter({
|
|
30
|
-
expression: SQL.column('createdAt'),
|
|
89
|
+
expression: SQL.column('balance_items', 'createdAt'),
|
|
31
90
|
type: SQLValueType.Datetime,
|
|
32
91
|
nullable: false,
|
|
33
92
|
}),
|
|
34
93
|
updatedAt: createColumnFilter({
|
|
35
|
-
expression: SQL.column('updatedAt'),
|
|
94
|
+
expression: SQL.column('balance_items', 'updatedAt'),
|
|
36
95
|
type: SQLValueType.Datetime,
|
|
37
96
|
nullable: false,
|
|
38
97
|
}),
|
|
39
98
|
|
|
40
99
|
description: createColumnFilter({
|
|
41
|
-
expression: SQL.column('description'),
|
|
100
|
+
expression: SQL.column('balance_items', 'description'),
|
|
42
101
|
type: SQLValueType.String,
|
|
43
102
|
nullable: false,
|
|
44
103
|
}),
|
|
45
104
|
|
|
46
105
|
priceWithVAT: createColumnFilter({
|
|
47
|
-
expression: SQL.column('priceTotal'),
|
|
106
|
+
expression: SQL.column('balance_items', 'priceTotal'),
|
|
48
107
|
type: SQLValueType.Number,
|
|
49
108
|
nullable: false,
|
|
50
109
|
}),
|
|
51
110
|
|
|
52
111
|
priceOpen: createColumnFilter({
|
|
53
|
-
expression: SQL.column('priceOpen'),
|
|
112
|
+
expression: SQL.column('balance_items', 'priceOpen'),
|
|
54
113
|
type: SQLValueType.Number,
|
|
55
114
|
nullable: false,
|
|
56
115
|
}),
|