@getpeppr/sdk 0.1.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/README.md +463 -0
- package/dist/core/client.d.ts +523 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +1308 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/code-lists.d.ts +120 -0
- package/dist/core/code-lists.d.ts.map +1 -0
- package/dist/core/code-lists.js +247 -0
- package/dist/core/code-lists.js.map +1 -0
- package/dist/core/country-rules.d.ts +30 -0
- package/dist/core/country-rules.d.ts.map +1 -0
- package/dist/core/country-rules.js +128 -0
- package/dist/core/country-rules.js.map +1 -0
- package/dist/core/schematron.d.ts +59 -0
- package/dist/core/schematron.d.ts.map +1 -0
- package/dist/core/schematron.js +480 -0
- package/dist/core/schematron.js.map +1 -0
- package/dist/core/ubl-builder.d.ts +20 -0
- package/dist/core/ubl-builder.d.ts.map +1 -0
- package/dist/core/ubl-builder.js +530 -0
- package/dist/core/ubl-builder.js.map +1 -0
- package/dist/core/validator.d.ts +16 -0
- package/dist/core/validator.d.ts.map +1 -0
- package/dist/core/validator.js +171 -0
- package/dist/core/validator.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/types/invoice.d.ts +635 -0
- package/dist/types/invoice.d.ts.map +1 -0
- package/dist/types/invoice.js +8 -0
- package/dist/types/invoice.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* getpeppr SDK Client
|
|
3
|
+
*
|
|
4
|
+
* The main entry point. Designed to feel like Stripe's SDK:
|
|
5
|
+
*
|
|
6
|
+
* const peppol = new Peppol({ apiKey: "sk_live_..." });
|
|
7
|
+
* const result = await peppol.invoices.send({ from, to, lines });
|
|
8
|
+
*
|
|
9
|
+
* All requests go through the getpeppr API gateway (api.getpeppr.dev),
|
|
10
|
+
* which handles Peppol delivery, billing, and usage tracking.
|
|
11
|
+
* Use `baseUrl` to point to a custom instance or localhost.
|
|
12
|
+
*/
|
|
13
|
+
import { buildInvoiceXml } from "./ubl-builder.js";
|
|
14
|
+
import { validateInvoice } from "./validator.js";
|
|
15
|
+
// ─── Retry Utilities ────────────────────────────────────────
|
|
16
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
function calculateRetryDelay(attempt, initialDelayMs, maxDelayMs, retryAfterMs) {
|
|
21
|
+
if (retryAfterMs !== undefined)
|
|
22
|
+
return Math.min(retryAfterMs, maxDelayMs);
|
|
23
|
+
const exponentialDelay = initialDelayMs * Math.pow(2, attempt);
|
|
24
|
+
const jitter = Math.random() * initialDelayMs;
|
|
25
|
+
return Math.min(exponentialDelay + jitter, maxDelayMs);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse the Retry-After header value into milliseconds.
|
|
29
|
+
* Supports integer seconds (e.g., "1", "60") and HTTP-date format.
|
|
30
|
+
* Returns undefined if the header is missing or unparseable.
|
|
31
|
+
*/
|
|
32
|
+
function parseRetryAfter(headerValue) {
|
|
33
|
+
if (!headerValue)
|
|
34
|
+
return undefined;
|
|
35
|
+
// Try integer seconds first (most common for rate limiters)
|
|
36
|
+
const seconds = Number(headerValue);
|
|
37
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
38
|
+
return seconds * 1000;
|
|
39
|
+
}
|
|
40
|
+
// Try HTTP-date format (e.g., "Wed, 21 Oct 2025 07:28:00 GMT")
|
|
41
|
+
const dateMs = Date.parse(headerValue);
|
|
42
|
+
if (!Number.isNaN(dateMs)) {
|
|
43
|
+
const delayMs = dateMs - Date.now();
|
|
44
|
+
return delayMs > 0 ? delayMs : 0;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function isRetryableError(error) {
|
|
49
|
+
if (error instanceof PeppolApiError) {
|
|
50
|
+
return RETRYABLE_STATUS_CODES.has(error.statusCode);
|
|
51
|
+
}
|
|
52
|
+
// Retry on timeout/abort errors
|
|
53
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// ─── getpeppr API Adapter ───────────────────────────────────
|
|
59
|
+
const DEFAULT_BASE_URL = "https://api.getpeppr.dev/v1";
|
|
60
|
+
class GetpepprAdapter {
|
|
61
|
+
name = "getpeppr";
|
|
62
|
+
baseUrl;
|
|
63
|
+
apiKey;
|
|
64
|
+
timeout;
|
|
65
|
+
retryConfig;
|
|
66
|
+
onRequest;
|
|
67
|
+
onResponse;
|
|
68
|
+
constructor(config) {
|
|
69
|
+
this.apiKey = config.apiKey;
|
|
70
|
+
this.timeout = config.timeout ?? 30_000;
|
|
71
|
+
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
72
|
+
this.retryConfig = {
|
|
73
|
+
maxRetries: config.retry?.maxRetries ?? 3,
|
|
74
|
+
initialDelayMs: config.retry?.initialDelayMs ?? 500,
|
|
75
|
+
maxDelayMs: config.retry?.maxDelayMs ?? 30_000,
|
|
76
|
+
};
|
|
77
|
+
this.onRequest = config.onRequest;
|
|
78
|
+
this.onResponse = config.onResponse;
|
|
79
|
+
}
|
|
80
|
+
async request(method, path, body, extraHeaders) {
|
|
81
|
+
const { maxRetries, initialDelayMs, maxDelayMs } = this.retryConfig;
|
|
82
|
+
let lastError;
|
|
83
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
return await this.doRequest(method, path, body, extraHeaders);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
lastError = err;
|
|
89
|
+
if (attempt < maxRetries && isRetryableError(err)) {
|
|
90
|
+
const retryAfterMs = err instanceof PeppolApiError ? err.retryAfterMs : undefined;
|
|
91
|
+
await sleep(calculateRetryDelay(attempt, initialDelayMs, maxDelayMs, retryAfterMs));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw lastError;
|
|
98
|
+
}
|
|
99
|
+
async doRequest(method, path, body, extraHeaders) {
|
|
100
|
+
const url = `${this.baseUrl}${path}`;
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
103
|
+
const requestHeaders = {
|
|
104
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
Accept: "application/json",
|
|
107
|
+
...extraHeaders,
|
|
108
|
+
};
|
|
109
|
+
const startTime = Date.now();
|
|
110
|
+
if (this.onRequest) {
|
|
111
|
+
try {
|
|
112
|
+
this.onRequest({
|
|
113
|
+
method,
|
|
114
|
+
url,
|
|
115
|
+
headers: { ...requestHeaders },
|
|
116
|
+
body,
|
|
117
|
+
timestamp: startTime,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Hook errors must never break the request
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(url, {
|
|
126
|
+
method,
|
|
127
|
+
headers: requestHeaders,
|
|
128
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
129
|
+
signal: controller.signal,
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const errorBody = await response.text().catch(() => "Unknown error");
|
|
133
|
+
const retryAfterMs = response.status === 429
|
|
134
|
+
? parseRetryAfter(response.headers.get("Retry-After"))
|
|
135
|
+
: undefined;
|
|
136
|
+
if (this.onResponse) {
|
|
137
|
+
try {
|
|
138
|
+
this.onResponse({
|
|
139
|
+
status: response.status,
|
|
140
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
141
|
+
body: errorBody,
|
|
142
|
+
durationMs: Date.now() - startTime,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Hook errors must never break the request
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
throw new PeppolApiError(`getpeppr API error (${response.status}): ${errorBody}`, response.status, errorBody, retryAfterMs);
|
|
151
|
+
}
|
|
152
|
+
// 204 No Content — no body to parse (e.g., sendInvoiceById)
|
|
153
|
+
if (response.status === 204) {
|
|
154
|
+
if (this.onResponse) {
|
|
155
|
+
try {
|
|
156
|
+
this.onResponse({
|
|
157
|
+
status: response.status,
|
|
158
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
159
|
+
body: undefined,
|
|
160
|
+
durationMs: Date.now() - startTime,
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Hook errors must never break the request
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const responseBody = (await response.json());
|
|
171
|
+
if (this.onResponse) {
|
|
172
|
+
try {
|
|
173
|
+
this.onResponse({
|
|
174
|
+
status: response.status,
|
|
175
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
176
|
+
body: responseBody,
|
|
177
|
+
durationMs: Date.now() - startTime,
|
|
178
|
+
timestamp: Date.now(),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Hook errors must never break the request
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return responseBody;
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
clearTimeout(timeoutId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// NOTE: sendInvoice and createInvoice hit the same SDK endpoint (POST /invoices).
|
|
192
|
+
// The gateway (Tasks 9-10) differentiates them: sendInvoice wraps with
|
|
193
|
+
// { invoice: {...}, send_after_import: true }, createInvoice omits the flag.
|
|
194
|
+
async sendInvoice(input, options) {
|
|
195
|
+
const headers = {};
|
|
196
|
+
if (options?.idempotencyKey) {
|
|
197
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
198
|
+
}
|
|
199
|
+
const result = await this.request("POST", "/invoices", input, headers);
|
|
200
|
+
return parseSendResult(result);
|
|
201
|
+
}
|
|
202
|
+
async createInvoice(input, options) {
|
|
203
|
+
const headers = {};
|
|
204
|
+
if (options?.idempotencyKey) {
|
|
205
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
206
|
+
}
|
|
207
|
+
// Signal to the gateway that this is a draft (no auto-send).
|
|
208
|
+
// The gateway reads `_draft` and strips it before forwarding to B2BRouter.
|
|
209
|
+
const result = await this.request("POST", "/invoices", { ...input, _draft: true }, headers);
|
|
210
|
+
return parseSendResult(result);
|
|
211
|
+
}
|
|
212
|
+
async sendInvoiceById(id) {
|
|
213
|
+
await this.request("POST", `/invoices/send/${id}`);
|
|
214
|
+
}
|
|
215
|
+
async sendCreditNote(input) {
|
|
216
|
+
const result = await this.request("POST", "/credit-notes", input);
|
|
217
|
+
return parseSendResult(result);
|
|
218
|
+
}
|
|
219
|
+
async validateDocument(input) {
|
|
220
|
+
return this.request("POST", "/validate", input);
|
|
221
|
+
}
|
|
222
|
+
async listReceived(options) {
|
|
223
|
+
const limit = options?.limit ?? 25;
|
|
224
|
+
const offset = options?.offset ?? 0;
|
|
225
|
+
const result = await this.request("GET", `/invoices?limit=${limit}&offset=${offset}`);
|
|
226
|
+
return (result.data ?? []).map((inv) => ({
|
|
227
|
+
id: String(inv.id),
|
|
228
|
+
invoice: inv,
|
|
229
|
+
rawXml: String(inv.ubl_xml ?? ""),
|
|
230
|
+
senderId: String(inv.sender_peppol_id ?? ""),
|
|
231
|
+
receivedAt: String(inv.received_at ?? new Date().toISOString()),
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
async listInvoices(options) {
|
|
235
|
+
const params = new URLSearchParams();
|
|
236
|
+
if (options?.limit)
|
|
237
|
+
params.set("limit", String(options.limit));
|
|
238
|
+
if (options?.offset)
|
|
239
|
+
params.set("offset", String(options.offset));
|
|
240
|
+
if (options?.type)
|
|
241
|
+
params.set("type", options.type);
|
|
242
|
+
if (options?.includeLines)
|
|
243
|
+
params.set("include", "lines");
|
|
244
|
+
const query = params.toString() ? `?${params.toString()}` : "";
|
|
245
|
+
const result = await this.request("GET", `/invoices${query}`);
|
|
246
|
+
// B2BRouter wraps list in "invoices" key (plural), not "data"
|
|
247
|
+
const invoices = (result.invoices ?? result.data ?? []);
|
|
248
|
+
const meta = result.meta;
|
|
249
|
+
return {
|
|
250
|
+
data: invoices.map((inv) => ({
|
|
251
|
+
id: String(inv.id ?? ""),
|
|
252
|
+
number: String(inv.number ?? ""),
|
|
253
|
+
status: mapStatus(String(inv.state ?? inv.status ?? "new")),
|
|
254
|
+
createdAt: inv.created_at ? String(inv.created_at) : undefined,
|
|
255
|
+
})),
|
|
256
|
+
meta: {
|
|
257
|
+
totalCount: Number(meta?.total_count ?? invoices.length),
|
|
258
|
+
offset: Number(meta?.offset ?? options?.offset ?? 0),
|
|
259
|
+
limit: Number(meta?.limit ?? options?.limit ?? 25),
|
|
260
|
+
hasMore: meta ? Number(meta.total_count) > Number(meta.offset) + Number(meta.limit) : false,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async getStatus(documentId) {
|
|
265
|
+
const result = await this.request("GET", `/invoices/${documentId}`);
|
|
266
|
+
return parseSendResult(result);
|
|
267
|
+
}
|
|
268
|
+
async lookupDirectory(scheme, id) {
|
|
269
|
+
return this.request("GET", `/directory/${scheme}/${id}`);
|
|
270
|
+
}
|
|
271
|
+
async getInvoiceAs(id, format) {
|
|
272
|
+
const { maxRetries, initialDelayMs, maxDelayMs } = this.retryConfig;
|
|
273
|
+
let lastError;
|
|
274
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
275
|
+
try {
|
|
276
|
+
return await this.doRequestBinary(`/invoices/${id}/as/${format}`);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
lastError = err;
|
|
280
|
+
if (attempt < maxRetries && isRetryableError(err)) {
|
|
281
|
+
const retryAfterMs = err instanceof PeppolApiError ? err.retryAfterMs : undefined;
|
|
282
|
+
await sleep(calculateRetryDelay(attempt, initialDelayMs, maxDelayMs, retryAfterMs));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
throw lastError;
|
|
289
|
+
}
|
|
290
|
+
async doRequestBinary(path) {
|
|
291
|
+
const url = `${this.baseUrl}${path}`;
|
|
292
|
+
const controller = new AbortController();
|
|
293
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
294
|
+
const requestHeaders = {
|
|
295
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
296
|
+
};
|
|
297
|
+
const startTime = Date.now();
|
|
298
|
+
if (this.onRequest) {
|
|
299
|
+
try {
|
|
300
|
+
this.onRequest({
|
|
301
|
+
method: "GET",
|
|
302
|
+
url,
|
|
303
|
+
headers: { ...requestHeaders },
|
|
304
|
+
timestamp: startTime,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Hook errors must never break the request
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const response = await fetch(url, {
|
|
313
|
+
method: "GET",
|
|
314
|
+
headers: requestHeaders,
|
|
315
|
+
signal: controller.signal,
|
|
316
|
+
});
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
const errorBody = await response.text().catch(() => "Unknown error");
|
|
319
|
+
const retryAfterMs = response.status === 429
|
|
320
|
+
? parseRetryAfter(response.headers.get("Retry-After"))
|
|
321
|
+
: undefined;
|
|
322
|
+
if (this.onResponse) {
|
|
323
|
+
try {
|
|
324
|
+
this.onResponse({
|
|
325
|
+
status: response.status,
|
|
326
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
327
|
+
body: errorBody,
|
|
328
|
+
durationMs: Date.now() - startTime,
|
|
329
|
+
timestamp: Date.now(),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// Hook errors must never break the request
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
throw new PeppolApiError(`getpeppr API error (${response.status}): ${errorBody}`, response.status, errorBody, retryAfterMs);
|
|
337
|
+
}
|
|
338
|
+
const responseBody = await response.arrayBuffer();
|
|
339
|
+
if (this.onResponse) {
|
|
340
|
+
try {
|
|
341
|
+
this.onResponse({
|
|
342
|
+
status: response.status,
|
|
343
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
344
|
+
body: `[ArrayBuffer: ${responseBody.byteLength} bytes]`,
|
|
345
|
+
durationMs: Date.now() - startTime,
|
|
346
|
+
timestamp: Date.now(),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// Hook errors must never break the request
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return responseBody;
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
clearTimeout(timeoutId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async validateDocumentServer(input) {
|
|
360
|
+
return this.request("POST", "/validate/server", input);
|
|
361
|
+
}
|
|
362
|
+
async listEvents(options) {
|
|
363
|
+
const params = new URLSearchParams();
|
|
364
|
+
if (options?.limit)
|
|
365
|
+
params.set("limit", String(options.limit));
|
|
366
|
+
if (options?.offset)
|
|
367
|
+
params.set("offset", String(options.offset));
|
|
368
|
+
if (options?.invoiceId)
|
|
369
|
+
params.set("invoiceId", options.invoiceId);
|
|
370
|
+
if (options?.dateFrom)
|
|
371
|
+
params.set("dateFrom", options.dateFrom);
|
|
372
|
+
if (options?.dateTo)
|
|
373
|
+
params.set("dateTo", options.dateTo);
|
|
374
|
+
const query = params.toString() ? `?${params.toString()}` : "";
|
|
375
|
+
const result = await this.request("GET", `/events${query}`);
|
|
376
|
+
const events = (result.events ?? result.data ?? []);
|
|
377
|
+
const meta = result.meta;
|
|
378
|
+
return {
|
|
379
|
+
data: events.map((evt) => ({
|
|
380
|
+
name: String(evt.name ?? ""),
|
|
381
|
+
text: String(evt.text ?? ""),
|
|
382
|
+
notes: evt.notes ? String(evt.notes) : undefined,
|
|
383
|
+
createdAt: String(evt.createdAt ?? evt.created_at ?? new Date().toISOString()),
|
|
384
|
+
invoice: evt.invoice,
|
|
385
|
+
contact: evt.contact,
|
|
386
|
+
})),
|
|
387
|
+
meta: {
|
|
388
|
+
totalCount: Number(meta?.total_count ?? meta?.totalCount ?? events.length),
|
|
389
|
+
offset: Number(meta?.offset ?? options?.offset ?? 0),
|
|
390
|
+
limit: Number(meta?.limit ?? options?.limit ?? 25),
|
|
391
|
+
hasMore: meta
|
|
392
|
+
? Number(meta.total_count ?? meta.totalCount) > Number(meta.offset) + Number(meta.limit)
|
|
393
|
+
: false,
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
async acknowledgeInvoice(id) {
|
|
398
|
+
const result = await this.request("POST", `/invoices/${id}/ack`);
|
|
399
|
+
return parseSendResult(result);
|
|
400
|
+
}
|
|
401
|
+
async listContacts(options) {
|
|
402
|
+
const params = new URLSearchParams();
|
|
403
|
+
if (options?.limit != null)
|
|
404
|
+
params.set("limit", String(options.limit));
|
|
405
|
+
if (options?.offset != null)
|
|
406
|
+
params.set("offset", String(options.offset));
|
|
407
|
+
if (options?.name)
|
|
408
|
+
params.set("name", options.name);
|
|
409
|
+
if (options?.isClient != null)
|
|
410
|
+
params.set("isClient", String(options.isClient));
|
|
411
|
+
if (options?.isProvider != null)
|
|
412
|
+
params.set("isProvider", String(options.isProvider));
|
|
413
|
+
const query = params.toString() ? `?${params.toString()}` : "";
|
|
414
|
+
const result = await this.request("GET", `/contacts${query}`);
|
|
415
|
+
const contacts = (result.contacts ?? result.data ?? []);
|
|
416
|
+
const meta = result.meta;
|
|
417
|
+
return {
|
|
418
|
+
data: contacts.map(parseContact),
|
|
419
|
+
meta: {
|
|
420
|
+
totalCount: Number(meta?.total_count ?? meta?.totalCount ?? contacts.length),
|
|
421
|
+
offset: Number(meta?.offset ?? options?.offset ?? 0),
|
|
422
|
+
limit: Number(meta?.limit ?? options?.limit ?? 25),
|
|
423
|
+
hasMore: meta
|
|
424
|
+
? Number(meta.total_count ?? meta.totalCount) > Number(meta.offset) + Number(meta.limit)
|
|
425
|
+
: false,
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async getContact(id) {
|
|
430
|
+
const result = await this.request("GET", `/contacts/${id}`);
|
|
431
|
+
return parseContact(result);
|
|
432
|
+
}
|
|
433
|
+
async createContact(input) {
|
|
434
|
+
const result = await this.request("POST", "/contacts", input);
|
|
435
|
+
return parseContact(result);
|
|
436
|
+
}
|
|
437
|
+
async updateContact(id, input) {
|
|
438
|
+
const result = await this.request("PUT", `/contacts/${id}`, input);
|
|
439
|
+
return parseContact(result);
|
|
440
|
+
}
|
|
441
|
+
async deleteContact(id) {
|
|
442
|
+
await this.request("DELETE", `/contacts/${id}`);
|
|
443
|
+
}
|
|
444
|
+
async listBankAccounts(options) {
|
|
445
|
+
const params = new URLSearchParams();
|
|
446
|
+
if (options?.limit != null)
|
|
447
|
+
params.set("limit", String(options.limit));
|
|
448
|
+
if (options?.offset != null)
|
|
449
|
+
params.set("offset", String(options.offset));
|
|
450
|
+
const query = params.toString() ? `?${params.toString()}` : "";
|
|
451
|
+
const result = await this.request("GET", `/bank-accounts${query}`);
|
|
452
|
+
const bankAccounts = (result.bankAccounts ?? result.data ?? []);
|
|
453
|
+
const meta = result.meta;
|
|
454
|
+
return {
|
|
455
|
+
data: bankAccounts.map(parseBankAccount),
|
|
456
|
+
meta: {
|
|
457
|
+
totalCount: Number(meta?.total_count ?? meta?.totalCount ?? bankAccounts.length),
|
|
458
|
+
offset: Number(meta?.offset ?? options?.offset ?? 0),
|
|
459
|
+
limit: Number(meta?.limit ?? options?.limit ?? 25),
|
|
460
|
+
hasMore: meta
|
|
461
|
+
? Number(meta.total_count ?? meta.totalCount) > Number(meta.offset) + Number(meta.limit)
|
|
462
|
+
: false,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
async getBankAccount(id) {
|
|
467
|
+
const result = await this.request("GET", `/bank-accounts/${id}`);
|
|
468
|
+
return parseBankAccount(result);
|
|
469
|
+
}
|
|
470
|
+
async createBankAccount(input) {
|
|
471
|
+
const result = await this.request("POST", "/bank-accounts", input);
|
|
472
|
+
return parseBankAccount(result);
|
|
473
|
+
}
|
|
474
|
+
async updateBankAccount(id, input) {
|
|
475
|
+
const result = await this.request("PUT", `/bank-accounts/${id}`, input);
|
|
476
|
+
return parseBankAccount(result);
|
|
477
|
+
}
|
|
478
|
+
async deleteBankAccount(id) {
|
|
479
|
+
await this.request("DELETE", `/bank-accounts/${id}`);
|
|
480
|
+
}
|
|
481
|
+
async importInvoice(options) {
|
|
482
|
+
const body = {
|
|
483
|
+
file: arrayBufferToBase64(options.file),
|
|
484
|
+
filename: options.filename,
|
|
485
|
+
mimeType: options.mimeType ?? detectMimeType(options.filename),
|
|
486
|
+
};
|
|
487
|
+
const result = await this.request("POST", "/invoices/import", body);
|
|
488
|
+
return parseSendResult(result);
|
|
489
|
+
}
|
|
490
|
+
async listTransportTypes() {
|
|
491
|
+
const result = await this.request("GET", "/transports/types");
|
|
492
|
+
const types = (result.transportTypes ?? result.data ?? []);
|
|
493
|
+
return types.map((t) => ({
|
|
494
|
+
code: String(t.code ?? ""),
|
|
495
|
+
name: String(t.name ?? ""),
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
async listTransports() {
|
|
499
|
+
const result = await this.request("GET", "/transports");
|
|
500
|
+
const transports = (result.transports ?? result.data ?? []);
|
|
501
|
+
return transports.map((t) => ({
|
|
502
|
+
id: String(t.id ?? ""),
|
|
503
|
+
transportTypeCode: String(t.transportTypeCode ?? ""),
|
|
504
|
+
name: String(t.name ?? ""),
|
|
505
|
+
status: t.status ? String(t.status) : undefined,
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// ─── Import Helpers ──────────────────────────────────────────
|
|
510
|
+
/** Convert ArrayBuffer or Uint8Array to base64 string (works in all runtimes). */
|
|
511
|
+
export function arrayBufferToBase64(buffer) {
|
|
512
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
513
|
+
let binary = "";
|
|
514
|
+
for (const byte of bytes) {
|
|
515
|
+
binary += String.fromCharCode(byte);
|
|
516
|
+
}
|
|
517
|
+
return btoa(binary);
|
|
518
|
+
}
|
|
519
|
+
/** Detect MIME type from a filename's extension. */
|
|
520
|
+
export function detectMimeType(filename) {
|
|
521
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
522
|
+
switch (ext) {
|
|
523
|
+
case "xml": return "application/xml";
|
|
524
|
+
case "pdf": return "application/pdf";
|
|
525
|
+
case "json": return "application/json";
|
|
526
|
+
default: return "application/octet-stream";
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function parseSendResult(result) {
|
|
530
|
+
return {
|
|
531
|
+
id: String(result.id ?? ""),
|
|
532
|
+
status: mapStatus(String(result.status ?? "new")),
|
|
533
|
+
peppolMessageId: (result.peppolMessageId ?? result.peppol_message_id),
|
|
534
|
+
createdAt: String(result.createdAt ?? result.updatedAt ?? result.created_at ?? new Date().toISOString()),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function parseContact(raw) {
|
|
538
|
+
const contact = {
|
|
539
|
+
id: String(raw.id ?? ""),
|
|
540
|
+
name: String(raw.name ?? ""),
|
|
541
|
+
};
|
|
542
|
+
if (raw.peppolId != null)
|
|
543
|
+
contact.peppolId = String(raw.peppolId);
|
|
544
|
+
if (raw.vatNumber != null)
|
|
545
|
+
contact.vatNumber = String(raw.vatNumber);
|
|
546
|
+
if (raw.companyId != null)
|
|
547
|
+
contact.companyId = String(raw.companyId);
|
|
548
|
+
if (raw.street != null)
|
|
549
|
+
contact.street = String(raw.street);
|
|
550
|
+
if (raw.city != null)
|
|
551
|
+
contact.city = String(raw.city);
|
|
552
|
+
if (raw.postalCode != null)
|
|
553
|
+
contact.postalCode = String(raw.postalCode);
|
|
554
|
+
if (raw.country != null)
|
|
555
|
+
contact.country = String(raw.country);
|
|
556
|
+
if (raw.email != null)
|
|
557
|
+
contact.email = String(raw.email);
|
|
558
|
+
if (raw.isClient != null)
|
|
559
|
+
contact.isClient = Boolean(raw.isClient);
|
|
560
|
+
if (raw.isProvider != null)
|
|
561
|
+
contact.isProvider = Boolean(raw.isProvider);
|
|
562
|
+
if (raw.createdAt != null)
|
|
563
|
+
contact.createdAt = String(raw.createdAt);
|
|
564
|
+
if (raw.updatedAt != null)
|
|
565
|
+
contact.updatedAt = String(raw.updatedAt);
|
|
566
|
+
return contact;
|
|
567
|
+
}
|
|
568
|
+
function parseBankAccount(raw) {
|
|
569
|
+
const account = {
|
|
570
|
+
id: String(raw.id ?? ""),
|
|
571
|
+
name: String(raw.name ?? ""),
|
|
572
|
+
type: (raw.type === "number" ? "number" : "iban"),
|
|
573
|
+
};
|
|
574
|
+
if (raw.iban != null)
|
|
575
|
+
account.iban = String(raw.iban);
|
|
576
|
+
if (raw.number != null)
|
|
577
|
+
account.number = String(raw.number);
|
|
578
|
+
if (raw.bic != null)
|
|
579
|
+
account.bic = String(raw.bic);
|
|
580
|
+
if (raw.country != null)
|
|
581
|
+
account.country = String(raw.country);
|
|
582
|
+
if (raw.createdAt != null)
|
|
583
|
+
account.createdAt = String(raw.createdAt);
|
|
584
|
+
if (raw.updatedAt != null)
|
|
585
|
+
account.updatedAt = String(raw.updatedAt);
|
|
586
|
+
return account;
|
|
587
|
+
}
|
|
588
|
+
// B2BRouter StateOutput enum — the canonical set of document lifecycle states.
|
|
589
|
+
const VALID_STATUSES = new Set([
|
|
590
|
+
"new", "sent", "downloaded", "accepted", "registered",
|
|
591
|
+
"refused", "allegedly_paid", "paid", "closed", "annotated", "error", "invalid",
|
|
592
|
+
]);
|
|
593
|
+
function mapStatus(raw) {
|
|
594
|
+
// B2BRouter uses "state" field; gateway maps to "status" for SDK DX.
|
|
595
|
+
// We validate against the known enum and fall back to "new" for unknowns.
|
|
596
|
+
const s = raw.toLowerCase();
|
|
597
|
+
return VALID_STATUSES.has(s) ? s : "new";
|
|
598
|
+
}
|
|
599
|
+
// ─── Error Classes ──────────────────────────────────────────
|
|
600
|
+
export class PeppolError extends Error {
|
|
601
|
+
constructor(message) {
|
|
602
|
+
super(message);
|
|
603
|
+
this.name = "PeppolError";
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
export class PeppolValidationError extends PeppolError {
|
|
607
|
+
validation;
|
|
608
|
+
constructor(message, validation) {
|
|
609
|
+
super(message);
|
|
610
|
+
this.validation = validation;
|
|
611
|
+
this.name = "PeppolValidationError";
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
export class PeppolApiError extends PeppolError {
|
|
615
|
+
statusCode;
|
|
616
|
+
responseBody;
|
|
617
|
+
/** Parsed Retry-After delay in milliseconds (present on 429 responses) */
|
|
618
|
+
retryAfterMs;
|
|
619
|
+
constructor(message, statusCode, responseBody, retryAfterMs) {
|
|
620
|
+
super(message);
|
|
621
|
+
this.statusCode = statusCode;
|
|
622
|
+
this.responseBody = responseBody;
|
|
623
|
+
this.name = "PeppolApiError";
|
|
624
|
+
this.retryAfterMs = retryAfterMs;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// ─── Main SDK Client ────────────────────────────────────────
|
|
628
|
+
export class Peppol {
|
|
629
|
+
adapter;
|
|
630
|
+
invoices;
|
|
631
|
+
creditNotes;
|
|
632
|
+
directory;
|
|
633
|
+
events;
|
|
634
|
+
contacts;
|
|
635
|
+
bankAccounts;
|
|
636
|
+
transports;
|
|
637
|
+
constructor(config) {
|
|
638
|
+
if (!config.apiKey) {
|
|
639
|
+
throw new PeppolError('API key is required. Sign up at https://console.getpeppr.dev to get your sandbox key.');
|
|
640
|
+
}
|
|
641
|
+
this.adapter = new GetpepprAdapter(config);
|
|
642
|
+
this.invoices = new InvoiceOperations(this.adapter);
|
|
643
|
+
this.creditNotes = new CreditNoteOperations(this.adapter);
|
|
644
|
+
this.directory = new DirectoryOperations(this.adapter);
|
|
645
|
+
this.events = new EventOperations(this.adapter);
|
|
646
|
+
this.contacts = new ContactOperations(this.adapter);
|
|
647
|
+
this.bankAccounts = new BankAccountOperations(this.adapter);
|
|
648
|
+
this.transports = new TransportOperations(this.adapter);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Validate an invoice without sending it.
|
|
652
|
+
* Useful for pre-flight checks in your UI.
|
|
653
|
+
*/
|
|
654
|
+
validate(input) {
|
|
655
|
+
return validateInvoice(input);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Generate UBL XML without sending.
|
|
659
|
+
* Useful for debugging or manual submission.
|
|
660
|
+
*/
|
|
661
|
+
toXml(input) {
|
|
662
|
+
const validation = validateInvoice(input);
|
|
663
|
+
if (!validation.valid) {
|
|
664
|
+
throw new PeppolValidationError(`Invoice validation failed: ${validation.errors.map((e) => e.message).join("; ")}`, validation);
|
|
665
|
+
}
|
|
666
|
+
return buildInvoiceXml(input);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async function* paginate(fetchPage, options) {
|
|
670
|
+
const pageSize = options?.limit ?? 25;
|
|
671
|
+
let offset = 0;
|
|
672
|
+
while (true) {
|
|
673
|
+
const page = await fetchPage(offset, pageSize);
|
|
674
|
+
for (const item of page.data) {
|
|
675
|
+
yield item;
|
|
676
|
+
}
|
|
677
|
+
if (!page.meta.hasMore)
|
|
678
|
+
break;
|
|
679
|
+
offset += pageSize;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
class InvoiceOperations {
|
|
683
|
+
adapter;
|
|
684
|
+
constructor(adapter) {
|
|
685
|
+
this.adapter = adapter;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Create a draft invoice without sending it.
|
|
689
|
+
* Validates input client-side, then creates the invoice on B2BRouter.
|
|
690
|
+
* Use `sendById()` to send the draft when ready.
|
|
691
|
+
*
|
|
692
|
+
* @example
|
|
693
|
+
* ```ts
|
|
694
|
+
* const draft = await peppol.invoices.create({ number: "INV-001", to, lines });
|
|
695
|
+
* // Later, when ready:
|
|
696
|
+
* await peppol.invoices.sendById(draft.id);
|
|
697
|
+
* ```
|
|
698
|
+
*/
|
|
699
|
+
async create(input, options) {
|
|
700
|
+
const validation = validateInvoice(input);
|
|
701
|
+
if (!validation.valid) {
|
|
702
|
+
throw new PeppolValidationError(`Invoice validation failed:\n${validation.errors.map((e) => ` - ${e.field}: ${e.message}${e.suggestion ? ` (${e.suggestion})` : ""}`).join("\n")}`, validation);
|
|
703
|
+
}
|
|
704
|
+
const result = await this.adapter.createInvoice(input, options);
|
|
705
|
+
if (validation.warnings.length > 0) {
|
|
706
|
+
result.warnings = validation.warnings;
|
|
707
|
+
}
|
|
708
|
+
return result;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Send an existing draft invoice by ID.
|
|
712
|
+
* The invoice must have been previously created with `create()`.
|
|
713
|
+
*
|
|
714
|
+
* @example
|
|
715
|
+
* ```ts
|
|
716
|
+
* await peppol.invoices.sendById("inv-1");
|
|
717
|
+
* ```
|
|
718
|
+
*/
|
|
719
|
+
async sendById(id) {
|
|
720
|
+
return this.adapter.sendInvoiceById(id);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Send an invoice via Peppol.
|
|
724
|
+
*
|
|
725
|
+
* @example
|
|
726
|
+
* ```ts
|
|
727
|
+
* const result = await peppol.invoices.send({
|
|
728
|
+
* number: "INV-001",
|
|
729
|
+
* from: { name: "My Company", peppolId: "0208:BE0123456789", country: "BE" },
|
|
730
|
+
* to: { name: "Client Co", peppolId: "0208:BE9876543210", country: "BE" },
|
|
731
|
+
* lines: [
|
|
732
|
+
* { description: "Consulting", quantity: 10, unitPrice: 150, vatRate: 21 }
|
|
733
|
+
* ]
|
|
734
|
+
* });
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
async send(input, options) {
|
|
738
|
+
// Client-side validation for fast feedback
|
|
739
|
+
const validation = validateInvoice(input);
|
|
740
|
+
if (!validation.valid) {
|
|
741
|
+
throw new PeppolValidationError(`Invoice validation failed:\n${validation.errors.map((e) => ` - ${e.field}: ${e.message}${e.suggestion ? ` (${e.suggestion})` : ""}`).join("\n")}`, validation);
|
|
742
|
+
}
|
|
743
|
+
// Send structured JSON — gateway handles UBL generation
|
|
744
|
+
const result = await this.adapter.sendInvoice(input, options);
|
|
745
|
+
if (validation.warnings.length > 0) {
|
|
746
|
+
result.warnings = validation.warnings;
|
|
747
|
+
}
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
/** List invoices with pagination, filtering, and proper metadata */
|
|
751
|
+
async list(options) {
|
|
752
|
+
return this.adapter.listInvoices(options);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Async iterator over all invoices, automatically handling pagination.
|
|
756
|
+
*
|
|
757
|
+
* @example
|
|
758
|
+
* ```ts
|
|
759
|
+
* for await (const invoice of peppol.invoices.listAll({ type: "issued" })) {
|
|
760
|
+
* console.log(invoice.id, invoice.status);
|
|
761
|
+
* }
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
listAll(options) {
|
|
765
|
+
return paginate((offset, limit) => this.adapter.listInvoices({ ...options, offset, limit }), options);
|
|
766
|
+
}
|
|
767
|
+
/** List received invoices */
|
|
768
|
+
async listReceived(options) {
|
|
769
|
+
return this.adapter.listReceived(options);
|
|
770
|
+
}
|
|
771
|
+
/** Get the status of a sent invoice */
|
|
772
|
+
async getStatus(documentId) {
|
|
773
|
+
return this.adapter.getStatus(documentId);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Export an invoice in a specific format (e.g., PDF, UBL XML).
|
|
777
|
+
* Returns raw binary data as an ArrayBuffer.
|
|
778
|
+
*
|
|
779
|
+
* @example
|
|
780
|
+
* ```ts
|
|
781
|
+
* const pdf = await peppol.invoices.getAs("inv-123", "pdf");
|
|
782
|
+
* fs.writeFileSync("invoice.pdf", Buffer.from(pdf));
|
|
783
|
+
* ```
|
|
784
|
+
*/
|
|
785
|
+
async getAs(id, format) {
|
|
786
|
+
return this.adapter.getInvoiceAs(id, format);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Validate an invoice server-side using XSD and Schematron rules.
|
|
790
|
+
* Builds UBL XML from the input and sends it to the server for validation.
|
|
791
|
+
* More thorough than client-side validation — catches XML-level issues.
|
|
792
|
+
*
|
|
793
|
+
* @example
|
|
794
|
+
* ```ts
|
|
795
|
+
* const result = await peppol.invoices.validateServer({
|
|
796
|
+
* number: "INV-001",
|
|
797
|
+
* to: { name: "Acme", peppolId: "0208:BE0123456789", country: "BE" },
|
|
798
|
+
* lines: [{ description: "Item", quantity: 1, unitPrice: 100, vatRate: 21 }]
|
|
799
|
+
* });
|
|
800
|
+
* console.log(result.valid, result.schematron.errors);
|
|
801
|
+
* ```
|
|
802
|
+
*/
|
|
803
|
+
async validateServer(input) {
|
|
804
|
+
return this.adapter.validateDocumentServer(input);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Import an invoice from a file (XML, PDF, JSON).
|
|
808
|
+
* The file is base64-encoded and sent to the gateway, which forwards it
|
|
809
|
+
* as multipart/form-data to B2BRouter's import endpoint.
|
|
810
|
+
*
|
|
811
|
+
* @example
|
|
812
|
+
* ```ts
|
|
813
|
+
* const xmlBytes = fs.readFileSync("invoice.xml");
|
|
814
|
+
* const result = await peppol.invoices.importFile({
|
|
815
|
+
* file: xmlBytes,
|
|
816
|
+
* filename: "invoice.xml",
|
|
817
|
+
* });
|
|
818
|
+
* console.log(result.id, result.status);
|
|
819
|
+
* ```
|
|
820
|
+
*/
|
|
821
|
+
async importFile(options) {
|
|
822
|
+
return this.adapter.importInvoice(options);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Acknowledge a received invoice.
|
|
826
|
+
*
|
|
827
|
+
* @example
|
|
828
|
+
* ```ts
|
|
829
|
+
* const result = await peppol.invoices.acknowledge("inv-123");
|
|
830
|
+
* console.log(result.status); // "accepted"
|
|
831
|
+
* ```
|
|
832
|
+
*/
|
|
833
|
+
async acknowledge(id) {
|
|
834
|
+
return this.adapter.acknowledgeInvoice(id);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Send multiple invoices in parallel with controlled concurrency.
|
|
838
|
+
* Each invoice is validated and sent individually — failures don't affect other invoices
|
|
839
|
+
* unless `stopOnError: true` is set.
|
|
840
|
+
*
|
|
841
|
+
* The SDK's built-in retry logic (including 429 Retry-After) provides automatic
|
|
842
|
+
* rate-limit handling at the request level.
|
|
843
|
+
*
|
|
844
|
+
* @example
|
|
845
|
+
* ```ts
|
|
846
|
+
* const result = await peppol.invoices.sendBatch([invoice1, invoice2, invoice3], {
|
|
847
|
+
* concurrency: 3,
|
|
848
|
+
* });
|
|
849
|
+
* console.log(`${result.succeeded.length} sent, ${result.failed.length} failed`);
|
|
850
|
+
* ```
|
|
851
|
+
*/
|
|
852
|
+
async sendBatch(inputs, options) {
|
|
853
|
+
const concurrency = options?.concurrency ?? 5;
|
|
854
|
+
const stopOnError = options?.stopOnError ?? false;
|
|
855
|
+
const succeeded = [];
|
|
856
|
+
const failed = [];
|
|
857
|
+
let stopped = false;
|
|
858
|
+
// Process in chunks of `concurrency` size
|
|
859
|
+
for (let i = 0; i < inputs.length; i += concurrency) {
|
|
860
|
+
if (stopped)
|
|
861
|
+
break;
|
|
862
|
+
const chunk = inputs.slice(i, i + concurrency);
|
|
863
|
+
const promises = chunk.map(async (input, j) => {
|
|
864
|
+
const index = i + j;
|
|
865
|
+
if (stopped)
|
|
866
|
+
return;
|
|
867
|
+
try {
|
|
868
|
+
const result = await this.send(input);
|
|
869
|
+
succeeded.push({ index, result });
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
failed.push({ index, input, error: error });
|
|
873
|
+
if (stopOnError) {
|
|
874
|
+
stopped = true;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
await Promise.all(promises);
|
|
879
|
+
}
|
|
880
|
+
return { succeeded, failed, total: inputs.length };
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Poll until an invoice reaches a target status.
|
|
884
|
+
*
|
|
885
|
+
* @example
|
|
886
|
+
* ```ts
|
|
887
|
+
* const result = await peppol.invoices.waitFor(id, "accepted", { timeout: 60000 });
|
|
888
|
+
* ```
|
|
889
|
+
*/
|
|
890
|
+
async waitFor(documentId, targetStatus, options) {
|
|
891
|
+
const timeout = options?.timeout ?? 120_000;
|
|
892
|
+
const interval = options?.interval ?? 5_000;
|
|
893
|
+
const targets = Array.isArray(targetStatus) ? targetStatus : [targetStatus];
|
|
894
|
+
const terminalFailures = ["error", "refused", "invalid"];
|
|
895
|
+
const startTime = Date.now();
|
|
896
|
+
while (true) {
|
|
897
|
+
const result = await this.getStatus(documentId);
|
|
898
|
+
if (targets.includes(result.status)) {
|
|
899
|
+
return result;
|
|
900
|
+
}
|
|
901
|
+
if (terminalFailures.includes(result.status)) {
|
|
902
|
+
throw new PeppolError(`Document ${documentId} reached terminal status "${result.status}" while waiting for "${targets.join('" or "')}"`);
|
|
903
|
+
}
|
|
904
|
+
if (Date.now() - startTime + interval > timeout) {
|
|
905
|
+
throw new PeppolError(`Timed out waiting for document ${documentId} to reach status "${targets.join('" or "')}" (last: "${result.status}")`);
|
|
906
|
+
}
|
|
907
|
+
await sleep(interval);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/** @deprecated Use peppol.invoices.send() with isCreditNote: true instead */
|
|
912
|
+
class CreditNoteOperations {
|
|
913
|
+
adapter;
|
|
914
|
+
constructor(adapter) {
|
|
915
|
+
this.adapter = adapter;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Send a credit note via Peppol.
|
|
919
|
+
* @deprecated Use peppol.invoices.send({ ...input, isCreditNote: true }) instead.
|
|
920
|
+
*/
|
|
921
|
+
async send(input) {
|
|
922
|
+
// Convert to InvoiceInput with isCreditNote flag and delegate to sendInvoice
|
|
923
|
+
const invoiceInput = {
|
|
924
|
+
...input,
|
|
925
|
+
isCreditNote: true,
|
|
926
|
+
invoiceReference: input.invoiceReference,
|
|
927
|
+
};
|
|
928
|
+
const validation = validateInvoice(invoiceInput);
|
|
929
|
+
if (!validation.valid) {
|
|
930
|
+
throw new PeppolValidationError(`Credit note validation failed:\n${validation.errors.map((e) => ` - ${e.field}: ${e.message}`).join("\n")}`, validation);
|
|
931
|
+
}
|
|
932
|
+
// Route through sendInvoice — B2BRouter has no separate credit-notes endpoint
|
|
933
|
+
return this.adapter.sendInvoice(invoiceInput);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// ─── Directory Operations ───────────────────────────────────
|
|
937
|
+
class DirectoryOperations {
|
|
938
|
+
adapter;
|
|
939
|
+
constructor(adapter) {
|
|
940
|
+
this.adapter = adapter;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Look up a Peppol participant in the directory.
|
|
944
|
+
*
|
|
945
|
+
* @example
|
|
946
|
+
* ```ts
|
|
947
|
+
* const entry = await peppol.directory.lookup("0208:BE0123456789");
|
|
948
|
+
* console.log(entry.name, entry.capabilities);
|
|
949
|
+
* ```
|
|
950
|
+
*/
|
|
951
|
+
async lookup(peppolId) {
|
|
952
|
+
const colonIndex = peppolId.indexOf(":");
|
|
953
|
+
if (colonIndex === -1) {
|
|
954
|
+
throw new PeppolError('Invalid Peppol ID format. Expected "scheme:id" (e.g., "0208:BE0123456789")');
|
|
955
|
+
}
|
|
956
|
+
const scheme = peppolId.slice(0, colonIndex);
|
|
957
|
+
const id = peppolId.slice(colonIndex + 1);
|
|
958
|
+
return this.adapter.lookupDirectory(scheme, id);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// ─── Event Operations ────────────────────────────────────────
|
|
962
|
+
class EventOperations {
|
|
963
|
+
adapter;
|
|
964
|
+
constructor(adapter) {
|
|
965
|
+
this.adapter = adapter;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* List events with optional filtering and pagination.
|
|
969
|
+
*
|
|
970
|
+
* @example
|
|
971
|
+
* ```ts
|
|
972
|
+
* const result = await peppol.events.list({ limit: 10 });
|
|
973
|
+
* console.log(result.data, result.meta);
|
|
974
|
+
*
|
|
975
|
+
* // Filter by invoice
|
|
976
|
+
* const invoiceEvents = await peppol.events.list({ invoiceId: "inv-123" });
|
|
977
|
+
* ```
|
|
978
|
+
*/
|
|
979
|
+
async list(options) {
|
|
980
|
+
return this.adapter.listEvents(options);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Async iterator over all events, automatically handling pagination.
|
|
984
|
+
*
|
|
985
|
+
* @example
|
|
986
|
+
* ```ts
|
|
987
|
+
* for await (const event of peppol.events.listAll({ invoiceId: "inv-123" })) {
|
|
988
|
+
* console.log(event.name, event.createdAt);
|
|
989
|
+
* }
|
|
990
|
+
* ```
|
|
991
|
+
*/
|
|
992
|
+
listAll(options) {
|
|
993
|
+
return paginate((offset, limit) => this.adapter.listEvents({ ...options, offset, limit }), options);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// ─── Contact Operations ─────────────────────────────────────
|
|
997
|
+
class ContactOperations {
|
|
998
|
+
adapter;
|
|
999
|
+
constructor(adapter) {
|
|
1000
|
+
this.adapter = adapter;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* List contacts with optional filtering and pagination.
|
|
1004
|
+
*
|
|
1005
|
+
* @example
|
|
1006
|
+
* ```ts
|
|
1007
|
+
* const result = await peppol.contacts.list({ limit: 10, isClient: true });
|
|
1008
|
+
* console.log(result.data, result.meta);
|
|
1009
|
+
* ```
|
|
1010
|
+
*/
|
|
1011
|
+
async list(options) {
|
|
1012
|
+
return this.adapter.listContacts(options);
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Get a single contact by ID.
|
|
1016
|
+
*
|
|
1017
|
+
* @example
|
|
1018
|
+
* ```ts
|
|
1019
|
+
* const contact = await peppol.contacts.get("123");
|
|
1020
|
+
* console.log(contact.name, contact.peppolId);
|
|
1021
|
+
* ```
|
|
1022
|
+
*/
|
|
1023
|
+
async get(id) {
|
|
1024
|
+
return this.adapter.getContact(id);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Create a new contact.
|
|
1028
|
+
*
|
|
1029
|
+
* @example
|
|
1030
|
+
* ```ts
|
|
1031
|
+
* const contact = await peppol.contacts.create({
|
|
1032
|
+
* name: "Acme Corp",
|
|
1033
|
+
* peppolId: "0208:BE0123456789",
|
|
1034
|
+
* country: "BE",
|
|
1035
|
+
* isClient: true,
|
|
1036
|
+
* });
|
|
1037
|
+
* ```
|
|
1038
|
+
*/
|
|
1039
|
+
async create(input) {
|
|
1040
|
+
return this.adapter.createContact(input);
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Update an existing contact.
|
|
1044
|
+
*
|
|
1045
|
+
* @example
|
|
1046
|
+
* ```ts
|
|
1047
|
+
* const updated = await peppol.contacts.update("123", { email: "new@acme.com" });
|
|
1048
|
+
* ```
|
|
1049
|
+
*/
|
|
1050
|
+
async update(id, input) {
|
|
1051
|
+
return this.adapter.updateContact(id, input);
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Delete a contact.
|
|
1055
|
+
*
|
|
1056
|
+
* @example
|
|
1057
|
+
* ```ts
|
|
1058
|
+
* await peppol.contacts.delete("123");
|
|
1059
|
+
* ```
|
|
1060
|
+
*/
|
|
1061
|
+
async delete(id) {
|
|
1062
|
+
return this.adapter.deleteContact(id);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Async iterator over all contacts, automatically handling pagination.
|
|
1066
|
+
*
|
|
1067
|
+
* @example
|
|
1068
|
+
* ```ts
|
|
1069
|
+
* for await (const contact of peppol.contacts.listAll({ isClient: true })) {
|
|
1070
|
+
* console.log(contact.name, contact.peppolId);
|
|
1071
|
+
* }
|
|
1072
|
+
* ```
|
|
1073
|
+
*/
|
|
1074
|
+
listAll(options) {
|
|
1075
|
+
return paginate((offset, limit) => this.adapter.listContacts({ ...options, offset, limit }));
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// ─── Bank Account Operations ─────────────────────────────────
|
|
1079
|
+
class BankAccountOperations {
|
|
1080
|
+
adapter;
|
|
1081
|
+
constructor(adapter) {
|
|
1082
|
+
this.adapter = adapter;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* List bank accounts with optional pagination.
|
|
1086
|
+
*
|
|
1087
|
+
* @example
|
|
1088
|
+
* ```ts
|
|
1089
|
+
* const result = await peppol.bankAccounts.list({ limit: 10 });
|
|
1090
|
+
* console.log(result.data, result.meta);
|
|
1091
|
+
* ```
|
|
1092
|
+
*/
|
|
1093
|
+
async list(options) {
|
|
1094
|
+
return this.adapter.listBankAccounts(options);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Get a single bank account by ID.
|
|
1098
|
+
*
|
|
1099
|
+
* @example
|
|
1100
|
+
* ```ts
|
|
1101
|
+
* const account = await peppol.bankAccounts.get("123");
|
|
1102
|
+
* console.log(account.name, account.iban);
|
|
1103
|
+
* ```
|
|
1104
|
+
*/
|
|
1105
|
+
async get(id) {
|
|
1106
|
+
return this.adapter.getBankAccount(id);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Create a new bank account.
|
|
1110
|
+
*
|
|
1111
|
+
* @example
|
|
1112
|
+
* ```ts
|
|
1113
|
+
* const account = await peppol.bankAccounts.create({
|
|
1114
|
+
* name: "Main Account",
|
|
1115
|
+
* iban: "BE68539007547034",
|
|
1116
|
+
* bic: "BBRUBEBB",
|
|
1117
|
+
* country: "BE",
|
|
1118
|
+
* });
|
|
1119
|
+
* ```
|
|
1120
|
+
*/
|
|
1121
|
+
async create(input) {
|
|
1122
|
+
return this.adapter.createBankAccount(input);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Update an existing bank account.
|
|
1126
|
+
*
|
|
1127
|
+
* @example
|
|
1128
|
+
* ```ts
|
|
1129
|
+
* const updated = await peppol.bankAccounts.update("123", { name: "Updated Name" });
|
|
1130
|
+
* ```
|
|
1131
|
+
*/
|
|
1132
|
+
async update(id, input) {
|
|
1133
|
+
return this.adapter.updateBankAccount(id, input);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Delete a bank account.
|
|
1137
|
+
*
|
|
1138
|
+
* @example
|
|
1139
|
+
* ```ts
|
|
1140
|
+
* await peppol.bankAccounts.delete("123");
|
|
1141
|
+
* ```
|
|
1142
|
+
*/
|
|
1143
|
+
async delete(id) {
|
|
1144
|
+
return this.adapter.deleteBankAccount(id);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Async iterator over all bank accounts, automatically handling pagination.
|
|
1148
|
+
*
|
|
1149
|
+
* @example
|
|
1150
|
+
* ```ts
|
|
1151
|
+
* for await (const account of peppol.bankAccounts.listAll()) {
|
|
1152
|
+
* console.log(account.name, account.iban);
|
|
1153
|
+
* }
|
|
1154
|
+
* ```
|
|
1155
|
+
*/
|
|
1156
|
+
listAll(options) {
|
|
1157
|
+
return paginate((offset, limit) => this.adapter.listBankAccounts({ ...options, offset, limit }));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
// ─── Transport Operations ────────────────────────────────────
|
|
1161
|
+
class TransportOperations {
|
|
1162
|
+
adapter;
|
|
1163
|
+
constructor(adapter) {
|
|
1164
|
+
this.adapter = adapter;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* List all available transport types in the network.
|
|
1168
|
+
* Returns global transport types (not account-scoped).
|
|
1169
|
+
*
|
|
1170
|
+
* @example
|
|
1171
|
+
* ```ts
|
|
1172
|
+
* const types = await peppol.transports.listTypes();
|
|
1173
|
+
* console.log(types); // [{ code: "peppol", name: "Peppol BIS 3.0" }, ...]
|
|
1174
|
+
* ```
|
|
1175
|
+
*/
|
|
1176
|
+
async listTypes() {
|
|
1177
|
+
return this.adapter.listTransportTypes();
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* List configured transports for this account.
|
|
1181
|
+
*
|
|
1182
|
+
* @example
|
|
1183
|
+
* ```ts
|
|
1184
|
+
* const transports = await peppol.transports.list();
|
|
1185
|
+
* console.log(transports); // [{ id: "t-1", transportTypeCode: "peppol", name: "..." }, ...]
|
|
1186
|
+
* ```
|
|
1187
|
+
*/
|
|
1188
|
+
async list() {
|
|
1189
|
+
return this.adapter.listTransports();
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// ─── Webhook Helper ─────────────────────────────────────────
|
|
1193
|
+
/** Default tolerance for webhook timestamp verification (5 minutes) */
|
|
1194
|
+
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
1195
|
+
/**
|
|
1196
|
+
* Parse and verify a webhook payload from B2BRouter.
|
|
1197
|
+
*
|
|
1198
|
+
* B2BRouter signs webhooks with HMAC-SHA256. The signature header format is:
|
|
1199
|
+
* `X-B2Brouter-Signature: t={timestamp},s={hmac_sha256_hex}`
|
|
1200
|
+
*
|
|
1201
|
+
* The signed payload is: `{timestamp}.{raw_json_body}`
|
|
1202
|
+
*
|
|
1203
|
+
* @example
|
|
1204
|
+
* ```ts
|
|
1205
|
+
* app.post("/webhooks/peppol", (req, res) => {
|
|
1206
|
+
* try {
|
|
1207
|
+
* const event = Peppol.webhooks.parse(
|
|
1208
|
+
* req.body, // raw body string
|
|
1209
|
+
* req.headers["x-b2brouter-signature"], // signature header
|
|
1210
|
+
* "whsec_your_webhook_secret", // your webhook secret
|
|
1211
|
+
* );
|
|
1212
|
+
* switch (event.type) {
|
|
1213
|
+
* case "invoice.received":
|
|
1214
|
+
* console.log("New invoice from:", event.data.senderId);
|
|
1215
|
+
* break;
|
|
1216
|
+
* }
|
|
1217
|
+
* res.sendStatus(200);
|
|
1218
|
+
* } catch (err) {
|
|
1219
|
+
* res.status(400).send("Webhook verification failed");
|
|
1220
|
+
* }
|
|
1221
|
+
* });
|
|
1222
|
+
* ```
|
|
1223
|
+
*/
|
|
1224
|
+
export const webhooks = {
|
|
1225
|
+
/**
|
|
1226
|
+
* Parse a webhook payload without signature verification.
|
|
1227
|
+
* Use `constructEvent()` for verified parsing in production.
|
|
1228
|
+
*/
|
|
1229
|
+
parse(payload) {
|
|
1230
|
+
return payload;
|
|
1231
|
+
},
|
|
1232
|
+
/**
|
|
1233
|
+
* Verify and parse a webhook payload using HMAC-SHA256 signature.
|
|
1234
|
+
* Throws `PeppolError` if verification fails.
|
|
1235
|
+
*
|
|
1236
|
+
* @param rawBody — The raw request body string (NOT parsed JSON)
|
|
1237
|
+
* @param signatureHeader — The `X-B2Brouter-Signature` header value
|
|
1238
|
+
* @param secret — Your webhook secret from B2BRouter
|
|
1239
|
+
* @param toleranceSeconds — Max age of the webhook in seconds (default: 300 = 5 min)
|
|
1240
|
+
*/
|
|
1241
|
+
async constructEvent(rawBody, signatureHeader, secret, toleranceSeconds) {
|
|
1242
|
+
if (!rawBody) {
|
|
1243
|
+
throw new PeppolError("Webhook error: missing request body");
|
|
1244
|
+
}
|
|
1245
|
+
if (!signatureHeader) {
|
|
1246
|
+
throw new PeppolError("Webhook error: missing X-B2Brouter-Signature header");
|
|
1247
|
+
}
|
|
1248
|
+
if (!secret) {
|
|
1249
|
+
throw new PeppolError("Webhook error: missing webhook secret");
|
|
1250
|
+
}
|
|
1251
|
+
// Parse header: t={timestamp},s={signature}
|
|
1252
|
+
const match = signatureHeader.match(/t=([^,]+),s=(.+)/);
|
|
1253
|
+
if (!match) {
|
|
1254
|
+
throw new PeppolError("Webhook error: invalid signature header format. Expected 't={timestamp},s={signature}'");
|
|
1255
|
+
}
|
|
1256
|
+
const [, timestampStr, receivedSignature] = match;
|
|
1257
|
+
const timestamp = Number(timestampStr);
|
|
1258
|
+
if (!Number.isFinite(timestamp)) {
|
|
1259
|
+
throw new PeppolError("Webhook error: invalid timestamp in signature header");
|
|
1260
|
+
}
|
|
1261
|
+
// Check timestamp tolerance (prevent replay attacks)
|
|
1262
|
+
const tolerance = toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
1263
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1264
|
+
if (Math.abs(now - timestamp) > tolerance) {
|
|
1265
|
+
throw new PeppolError(`Webhook error: timestamp too old or too new (received: ${timestamp}, now: ${now}, tolerance: ${tolerance}s)`);
|
|
1266
|
+
}
|
|
1267
|
+
// Compute expected signature: HMAC-SHA256(secret, "{timestamp}.{rawBody}")
|
|
1268
|
+
const signedPayload = `${timestampStr}.${rawBody}`;
|
|
1269
|
+
const expectedSignature = await computeHmacSha256(secret, signedPayload);
|
|
1270
|
+
// Constant-time comparison to prevent timing attacks
|
|
1271
|
+
if (!timingSafeEqual(expectedSignature, receivedSignature)) {
|
|
1272
|
+
throw new PeppolError("Webhook error: signature verification failed");
|
|
1273
|
+
}
|
|
1274
|
+
// Parse and return the event
|
|
1275
|
+
try {
|
|
1276
|
+
const parsed = typeof rawBody === "string" ? JSON.parse(rawBody) : rawBody;
|
|
1277
|
+
return parsed;
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
throw new PeppolError("Webhook error: invalid JSON payload");
|
|
1281
|
+
}
|
|
1282
|
+
},
|
|
1283
|
+
};
|
|
1284
|
+
/**
|
|
1285
|
+
* Compute HMAC-SHA256 hex digest.
|
|
1286
|
+
* Uses Web Crypto API (works in Node.js 18+, Deno, Bun, browsers).
|
|
1287
|
+
*/
|
|
1288
|
+
async function computeHmacSha256(secret, message) {
|
|
1289
|
+
const encoder = new TextEncoder();
|
|
1290
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1291
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
|
1292
|
+
return Array.from(new Uint8Array(signature))
|
|
1293
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1294
|
+
.join("");
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
1298
|
+
*/
|
|
1299
|
+
function timingSafeEqual(a, b) {
|
|
1300
|
+
if (a.length !== b.length)
|
|
1301
|
+
return false;
|
|
1302
|
+
let result = 0;
|
|
1303
|
+
for (let i = 0; i < a.length; i++) {
|
|
1304
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1305
|
+
}
|
|
1306
|
+
return result === 0;
|
|
1307
|
+
}
|
|
1308
|
+
//# sourceMappingURL=client.js.map
|