@open-nav/client 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/src/client.ts ADDED
@@ -0,0 +1,338 @@
1
+ import {
2
+ BASE_URLS,
3
+ HEADER_VERSION,
4
+ MAX_INVOICE_BATCH_SIZE,
5
+ NavValidationError,
6
+ PASSWORD_HASH_CRYPTO_TYPE,
7
+ REQUEST_VERSION,
8
+ SIGNATURE_CRYPTO_TYPE,
9
+ createRequestId,
10
+ decodeExchangeToken,
11
+ encodeInvoiceAnnulment,
12
+ encodeInvoiceData,
13
+ passwordHash,
14
+ requestSignature,
15
+ serializeDocument,
16
+ toHeaderTimestamp,
17
+ type InvoiceAnnulment,
18
+ type InvoiceData,
19
+ type ManageAnnulmentRequest,
20
+ type ManageAnnulmentResponse,
21
+ type ManageInvoiceRequest,
22
+ type ManageInvoiceResponse,
23
+ type NavEnvironment,
24
+ type QueryInvoiceChainDigestRequest,
25
+ type QueryInvoiceChainDigestResponse,
26
+ type QueryInvoiceCheckRequest,
27
+ type QueryInvoiceCheckResponse,
28
+ type QueryInvoiceDataRequest,
29
+ type QueryInvoiceDataResponse,
30
+ type QueryInvoiceDigestRequest,
31
+ type QueryInvoiceDigestResponse,
32
+ type QueryTaxpayerRequest,
33
+ type QueryTaxpayerResponse,
34
+ type QueryTransactionListRequest,
35
+ type QueryTransactionListResponse,
36
+ type QueryTransactionStatusRequest,
37
+ type QueryTransactionStatusResponse,
38
+ type SignedOperation,
39
+ type SoftwareType,
40
+ type TokenExchangeResponse,
41
+ type ValidationIssue,
42
+ } from '@open-nav/core';
43
+ import { assertCredentials, type NavCredentials } from './credentials.js';
44
+ import { postXml, type TransportOptions } from './transport.js';
45
+
46
+ /**
47
+ * A request body with the parts the client fills in removed.
48
+ *
49
+ * Deriving these from the generated request types means there is no
50
+ * hand-maintained parameter list to fall out of step with the schema.
51
+ */
52
+ export type RequestBody<T> = Omit<T, 'header' | 'user' | 'software'>;
53
+
54
+ /** A manage request body; the exchange token is obtained by the client. */
55
+ export type ManageBody<T> = Omit<RequestBody<T>, 'exchangeToken'>;
56
+
57
+ export interface NavClientOptions {
58
+ credentials: NavCredentials;
59
+ /**
60
+ * Identification of the invoicing software, which NAV requires on every
61
+ * request and uses for its own statistics and support.
62
+ */
63
+ software: SoftwareType;
64
+ /** `test` (the default) or `production`. */
65
+ environment?: NavEnvironment;
66
+ /** Overrides `environment`; useful for a mock server. */
67
+ baseUrl?: string;
68
+ /** Prefix for generated request identifiers, for tracing. */
69
+ requestIdPrefix?: string;
70
+ /** Injectable clock, for deterministic tests. */
71
+ now?: () => Date;
72
+ transport?: TransportOptions;
73
+ }
74
+
75
+ export interface InvoiceOperationInput {
76
+ /** `CREATE`, `MODIFY` or `STORNO`. */
77
+ operation: 'CREATE' | 'MODIFY' | 'STORNO';
78
+ /** The invoice, or an already base64 encoded payload. */
79
+ invoice: InvoiceData | { base64: string };
80
+ }
81
+
82
+ export interface SubmitInvoicesOptions {
83
+ /** Gzip each payload. The flag applies to the whole batch. */
84
+ compress?: boolean;
85
+ }
86
+
87
+ /**
88
+ * Client for the NAV Online Számla 3.0 invoice service.
89
+ *
90
+ * Every method corresponds to one service operation and takes the generated
91
+ * request type minus the header, user and software blocks, which the client
92
+ * builds and signs.
93
+ *
94
+ * ```ts
95
+ * const client = new NavClient({ credentials, software, environment: 'test' });
96
+ * const taxpayer = await client.queryTaxpayer({ taxNumber: '12345678' });
97
+ * ```
98
+ */
99
+ export class NavClient {
100
+ private readonly baseUrl: string;
101
+ private readonly credentials: NavCredentials;
102
+ private readonly software: SoftwareType;
103
+ private readonly requestIdPrefix: string | undefined;
104
+ private readonly now: () => Date;
105
+ private readonly transport: TransportOptions;
106
+
107
+ constructor(options: NavClientOptions) {
108
+ assertCredentials(options.credentials);
109
+ this.credentials = options.credentials;
110
+ this.software = options.software;
111
+ this.baseUrl = options.baseUrl ?? BASE_URLS[options.environment ?? 'test'];
112
+ this.requestIdPrefix = options.requestIdPrefix;
113
+ this.now = options.now ?? (() => new Date());
114
+ this.transport = options.transport ?? {};
115
+ }
116
+
117
+ /**
118
+ * Exchange credentials for a single-use token required by the manage
119
+ * operations.
120
+ *
121
+ * The token arrives AES-128-ECB encrypted under the exchange key and is
122
+ * decrypted here. NAV keeps it valid for a few minutes; this client fetches
123
+ * a fresh one per manage call rather than caching, because a token consumed
124
+ * by a submission cannot be reused and a stale one fails opaquely.
125
+ */
126
+ async tokenExchange(): Promise<{
127
+ token: string;
128
+ validityFrom: string;
129
+ validityTo: string;
130
+ response: TokenExchangeResponse;
131
+ }> {
132
+ const response = await this.execute<TokenExchangeResponse>(
133
+ 'tokenExchange',
134
+ 'TokenExchangeRequest',
135
+ {},
136
+ );
137
+ return {
138
+ token: decodeExchangeToken(response.encodedExchangeToken, this.credentials.exchangeKey),
139
+ validityFrom: response.tokenValidityFrom,
140
+ validityTo: response.tokenValidityTo,
141
+ response,
142
+ };
143
+ }
144
+
145
+ /** Submit invoice data. Returns the transaction to poll for the outcome. */
146
+ async manageInvoice(body: ManageBody<ManageInvoiceRequest>): Promise<ManageInvoiceResponse> {
147
+ assertBatchSize(body.invoiceOperations.invoiceOperation.length, 'invoiceOperations');
148
+ const { token } = await this.tokenExchange();
149
+ const signed: SignedOperation[] = body.invoiceOperations.invoiceOperation.map((operation) => ({
150
+ index: operation.index,
151
+ operation: operation.invoiceOperation,
152
+ base64Payload: operation.invoiceData,
153
+ }));
154
+ return this.execute<ManageInvoiceResponse>(
155
+ 'manageInvoice',
156
+ 'ManageInvoiceRequest',
157
+ { ...body, exchangeToken: token },
158
+ { signed, retryable: false },
159
+ );
160
+ }
161
+
162
+ /** Technically annul a previously submitted, erroneous data report. */
163
+ async manageAnnulment(
164
+ body: ManageBody<ManageAnnulmentRequest>,
165
+ ): Promise<ManageAnnulmentResponse> {
166
+ assertBatchSize(body.annulmentOperations.annulmentOperation.length, 'annulmentOperations');
167
+ const { token } = await this.tokenExchange();
168
+ const signed: SignedOperation[] = body.annulmentOperations.annulmentOperation.map(
169
+ (operation) => ({
170
+ index: operation.index,
171
+ operation: operation.annulmentOperation,
172
+ base64Payload: operation.invoiceAnnulment,
173
+ }),
174
+ );
175
+ return this.execute<ManageAnnulmentResponse>(
176
+ 'manageAnnulment',
177
+ 'ManageAnnulmentRequest',
178
+ { ...body, exchangeToken: token },
179
+ { signed, retryable: false },
180
+ );
181
+ }
182
+
183
+ /** Processing state and validation messages of a submitted transaction. */
184
+ queryTransactionStatus(
185
+ body: RequestBody<QueryTransactionStatusRequest>,
186
+ ): Promise<QueryTransactionStatusResponse> {
187
+ return this.execute('queryTransactionStatus', 'QueryTransactionStatusRequest', body);
188
+ }
189
+
190
+ /** Transactions submitted in a time window, paged. */
191
+ queryTransactionList(
192
+ body: RequestBody<QueryTransactionListRequest>,
193
+ ): Promise<QueryTransactionListResponse> {
194
+ return this.execute('queryTransactionList', 'QueryTransactionListRequest', body);
195
+ }
196
+
197
+ /** Full invoice data of one invoice, inbound or outbound. */
198
+ queryInvoiceData(body: RequestBody<QueryInvoiceDataRequest>): Promise<QueryInvoiceDataResponse> {
199
+ return this.execute('queryInvoiceData', 'QueryInvoiceDataRequest', body);
200
+ }
201
+
202
+ /** Paged summary list of invoices matching a query. */
203
+ queryInvoiceDigest(
204
+ body: RequestBody<QueryInvoiceDigestRequest>,
205
+ ): Promise<QueryInvoiceDigestResponse> {
206
+ return this.execute('queryInvoiceDigest', 'QueryInvoiceDigestRequest', body);
207
+ }
208
+
209
+ /** Modification chain of an invoice. */
210
+ queryInvoiceChainDigest(
211
+ body: RequestBody<QueryInvoiceChainDigestRequest>,
212
+ ): Promise<QueryInvoiceChainDigestResponse> {
213
+ return this.execute('queryInvoiceChainDigest', 'QueryInvoiceChainDigestRequest', body);
214
+ }
215
+
216
+ /** Whether an invoice number exists in NAV's records. */
217
+ queryInvoiceCheck(
218
+ body: RequestBody<QueryInvoiceCheckRequest>,
219
+ ): Promise<QueryInvoiceCheckResponse> {
220
+ return this.execute('queryInvoiceCheck', 'QueryInvoiceCheckRequest', body);
221
+ }
222
+
223
+ /** Validity and registered data of a Hungarian taxpayer. */
224
+ queryTaxpayer(body: RequestBody<QueryTaxpayerRequest>): Promise<QueryTaxpayerResponse> {
225
+ return this.execute('queryTaxpayer', 'QueryTaxpayerRequest', body);
226
+ }
227
+
228
+ /**
229
+ * Encode invoices and submit them as one batch.
230
+ *
231
+ * Encoding happens once and the same base64 is both sent and hashed, which
232
+ * is the only safe way to do it: hashing a separately serialised copy of the
233
+ * same invoice is a signature failure waiting to happen.
234
+ */
235
+ async submitInvoices(
236
+ invoices: InvoiceOperationInput[],
237
+ options: SubmitInvoicesOptions = {},
238
+ ): Promise<ManageInvoiceResponse> {
239
+ assertBatchSize(invoices.length, 'invoices');
240
+ return this.manageInvoice({
241
+ invoiceOperations: {
242
+ compressedContent: options.compress ?? false,
243
+ invoiceOperation: invoices.map((entry, position) => ({
244
+ index: position + 1,
245
+ invoiceOperation: entry.operation,
246
+ invoiceData:
247
+ 'base64' in entry.invoice
248
+ ? entry.invoice.base64
249
+ : encodeInvoiceData(entry.invoice, { compress: options.compress }),
250
+ })),
251
+ },
252
+ });
253
+ }
254
+
255
+ /** Annul one or more previously reported invoices. */
256
+ async submitAnnulments(
257
+ annulments: Array<InvoiceAnnulment | { base64: string }>,
258
+ options: SubmitInvoicesOptions = {},
259
+ ): Promise<ManageAnnulmentResponse> {
260
+ assertBatchSize(annulments.length, 'annulments');
261
+ return this.manageAnnulment({
262
+ annulmentOperations: {
263
+ annulmentOperation: annulments.map((entry, position) => ({
264
+ index: position + 1,
265
+ annulmentOperation: 'ANNUL',
266
+ invoiceAnnulment:
267
+ 'base64' in entry
268
+ ? entry.base64
269
+ : encodeInvoiceAnnulment(entry, { compress: options.compress }),
270
+ })),
271
+ },
272
+ });
273
+ }
274
+
275
+ /** Build, sign and send one request. */
276
+ private async execute<TResponse>(
277
+ operation: string,
278
+ rootName: string,
279
+ body: object,
280
+ options: { signed?: SignedOperation[]; retryable?: boolean } = {},
281
+ ): Promise<TResponse> {
282
+ const requestId = createRequestId(this.requestIdPrefix);
283
+ const timestamp = toHeaderTimestamp(this.now());
284
+
285
+ const request = {
286
+ header: {
287
+ requestId,
288
+ timestamp,
289
+ requestVersion: REQUEST_VERSION,
290
+ headerVersion: HEADER_VERSION,
291
+ },
292
+ user: {
293
+ login: this.credentials.login,
294
+ passwordHash: {
295
+ value: passwordHash(this.credentials.password),
296
+ cryptoType: PASSWORD_HASH_CRYPTO_TYPE,
297
+ },
298
+ taxNumber: this.credentials.taxNumber,
299
+ requestSignature: {
300
+ value: requestSignature(
301
+ requestId,
302
+ timestamp,
303
+ this.credentials.signKey,
304
+ options.signed ?? [],
305
+ ),
306
+ cryptoType: SIGNATURE_CRYPTO_TYPE,
307
+ },
308
+ },
309
+ software: this.software,
310
+ ...body,
311
+ };
312
+
313
+ const xml = serializeDocument(rootName, request);
314
+ const response = await postXml(
315
+ this.baseUrl,
316
+ operation,
317
+ xml,
318
+ this.transport,
319
+ options.retryable ?? true,
320
+ );
321
+ return response.value as TResponse;
322
+ }
323
+ }
324
+
325
+ function assertBatchSize(size: number, path: string): void {
326
+ const issues: ValidationIssue[] = [];
327
+ if (size === 0) {
328
+ issues.push({ path, code: 'EMPTY_BATCH', message: 'must contain at least one operation' });
329
+ }
330
+ if (size > MAX_INVOICE_BATCH_SIZE) {
331
+ issues.push({
332
+ path,
333
+ code: 'BATCH_TOO_LARGE',
334
+ message: `NAV accepts at most ${MAX_INVOICE_BATCH_SIZE} operations per request, got ${size}`,
335
+ });
336
+ }
337
+ if (issues.length > 0) throw new NavValidationError('Invalid batch', issues);
338
+ }
@@ -0,0 +1,57 @@
1
+ import { NavValidationError, type ValidationIssue } from '@open-nav/core';
2
+
3
+ /**
4
+ * Credentials of a technical user (technikai felhasználó), created in the
5
+ * Online Számla portal under the taxpayer whose data is being reported.
6
+ *
7
+ * These are secrets. Load them from the environment or a secret manager —
8
+ * never from source control — and note that the test and production systems
9
+ * issue separate, non-interchangeable users.
10
+ */
11
+ export interface NavCredentials {
12
+ /** Technical user login, 6–15 alphanumeric characters. */
13
+ login: string;
14
+ /** Technical user password, in clear; it is hashed before transmission. */
15
+ password: string;
16
+ /** Signature key (aláírókulcs) used to sign requests. */
17
+ signKey: string;
18
+ /** Exchange key (cserekulcs) used to decrypt the exchange token. */
19
+ exchangeKey: string;
20
+ /** Tax number of the taxpayer being reported for, 8 digits, no VAT suffix. */
21
+ taxNumber: string;
22
+ }
23
+
24
+ /**
25
+ * Check credentials locally before the first request.
26
+ *
27
+ * NAV answers every credential problem with the same opaque
28
+ * `INVALID_SECURITY_USER`, so catching shape errors here saves real debugging
29
+ * time. The most common cause is pasting the 11-digit tax number instead of
30
+ * the 8-digit core.
31
+ */
32
+ export function assertCredentials(credentials: NavCredentials): void {
33
+ const issues: ValidationIssue[] = [];
34
+ const require = (
35
+ field: keyof NavCredentials,
36
+ pattern: RegExp,
37
+ message: string,
38
+ code: string,
39
+ ): void => {
40
+ const value = credentials[field];
41
+ if (typeof value !== 'string' || value.length === 0) {
42
+ issues.push({ path: `credentials.${field}`, code: 'REQUIRED', message: 'is required' });
43
+ } else if (!pattern.test(value)) {
44
+ issues.push({ path: `credentials.${field}`, code, message });
45
+ }
46
+ };
47
+
48
+ require('login', /^[a-zA-Z0-9]{6,15}$/, 'must be 6-15 alphanumeric characters', 'INVALID_LOGIN');
49
+ require('password', /^.+$/, 'must not be empty', 'INVALID_PASSWORD');
50
+ require('signKey', /^.+$/, 'must not be empty', 'INVALID_SIGN_KEY');
51
+ require('exchangeKey', /^.{16}$/, 'must be exactly 16 characters', 'INVALID_EXCHANGE_KEY');
52
+ require('taxNumber', /^\d{8}$/, 'must be the 8 digit core tax number, without the VAT and county digits', 'INVALID_TAX_NUMBER');
53
+
54
+ if (issues.length > 0) {
55
+ throw new NavValidationError('Invalid NAV credentials', issues);
56
+ }
57
+ }
@@ -0,0 +1,238 @@
1
+ import {
2
+ QUERY_PAGE_SIZE,
3
+ decodeInvoiceData,
4
+ serializeDocument,
5
+ type InvoiceDataResultType,
6
+ type InvoiceData,
7
+ type InvoiceDigestType,
8
+ type InvoiceDirectionType,
9
+ } from '@open-nav/core';
10
+ import type { NavClient } from './client.js';
11
+
12
+ /**
13
+ * Bulk retrieval of invoices from NAV.
14
+ *
15
+ * Two things make this more than a loop. NAV caps a digest query at **35
16
+ * days** — `BAD_QUERY_PARAM_RANGE_EXCEEDED`, "Date interval defined by the
17
+ * query parameters must not exceed 35 days" — so any useful range has to be
18
+ * split. And an inbound invoice must be fetched with the supplier's tax
19
+ * number, which is only knowable from the digest entry that named it.
20
+ */
21
+
22
+ /** Longest interval NAV accepts in one digest query. */
23
+ export const MAX_QUERY_DAYS = 35;
24
+
25
+ export interface DownloadOptions {
26
+ /** `INBOUND` for invoices issued to you, `OUTBOUND` for your own. */
27
+ direction?: InvoiceDirectionType;
28
+ /** First issue date, inclusive, as `yyyy-mm-dd`. */
29
+ dateFrom: string;
30
+ /** Last issue date, inclusive, as `yyyy-mm-dd`. */
31
+ dateTo: string;
32
+ /**
33
+ * Pause between requests, in milliseconds. Defaults to 250.
34
+ *
35
+ * NAV rate limits per taxpayer, and a download makes one request per
36
+ * invoice. Pacing is cheaper than being throttled.
37
+ */
38
+ delayMs?: number;
39
+ /** Called before each invoice is fetched, for progress reporting. */
40
+ onProgress?: (progress: DownloadProgress) => void;
41
+ /** Injectable sleep, for tests. */
42
+ sleep?: (ms: number) => Promise<void>;
43
+ }
44
+
45
+ export interface DownloadProgress {
46
+ /** The window currently being queried. */
47
+ window: DateWindow;
48
+ windowIndex: number;
49
+ windowCount: number;
50
+ /** Digest entries seen so far, across all windows. */
51
+ seen: number;
52
+ /** Invoices fetched so far. */
53
+ fetched: number;
54
+ }
55
+
56
+ export interface DateWindow {
57
+ dateFrom: string;
58
+ dateTo: string;
59
+ }
60
+
61
+ export interface DownloadedInvoice {
62
+ /** The digest entry that led to this invoice. */
63
+ digest: InvoiceDigestType;
64
+ /** The decoded invoice. */
65
+ invoice: InvoiceData;
66
+ /** The invoice re-serialised, for writing to disk. */
67
+ xml: string;
68
+ /** NAV's own record of when and how the data arrived. */
69
+ auditData: InvoiceDataResultType['auditData'];
70
+ }
71
+
72
+ /**
73
+ * Split a date range into windows NAV will accept.
74
+ *
75
+ * Inclusive at both ends, so a 35 day window spans `dateFrom` to
76
+ * `dateFrom + 34 days`: NAV compares the two dates given, and 35 days apart
77
+ * is already over the line.
78
+ */
79
+ export function chunkDateRange(
80
+ dateFrom: string,
81
+ dateTo: string,
82
+ maxDays = MAX_QUERY_DAYS,
83
+ ): DateWindow[] {
84
+ const start = parseDate(dateFrom, 'dateFrom');
85
+ const end = parseDate(dateTo, 'dateTo');
86
+ if (start > end) {
87
+ throw new RangeError(`dateFrom ${dateFrom} is after dateTo ${dateTo}`);
88
+ }
89
+
90
+ const windows: DateWindow[] = [];
91
+ const dayMs = 86_400_000;
92
+ for (let cursor = start; cursor <= end; cursor += maxDays * dayMs) {
93
+ const windowEnd = Math.min(cursor + (maxDays - 1) * dayMs, end);
94
+ windows.push({ dateFrom: formatDate(cursor), dateTo: formatDate(windowEnd) });
95
+ }
96
+ return windows;
97
+ }
98
+
99
+ function parseDate(value: string, field: string): number {
100
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
101
+ throw new RangeError(`${field} must be yyyy-mm-dd, got ${JSON.stringify(value)}`);
102
+ }
103
+ const timestamp = Date.parse(`${value}T00:00:00Z`);
104
+ if (Number.isNaN(timestamp)) throw new RangeError(`${field} is not a real date: ${value}`);
105
+ return timestamp;
106
+ }
107
+
108
+ function formatDate(timestamp: number): string {
109
+ return new Date(timestamp).toISOString().slice(0, 10);
110
+ }
111
+
112
+ /**
113
+ * Walk every digest entry in a date range, across windows and pages.
114
+ *
115
+ * A digest is a summary. It is enough to decide what to fetch, and cheap:
116
+ * one request per hundred invoices.
117
+ */
118
+ export async function* iterateInvoiceDigests(
119
+ client: NavClient,
120
+ options: DownloadOptions,
121
+ ): AsyncGenerator<{ digest: InvoiceDigestType; window: DateWindow }> {
122
+ const direction = options.direction ?? 'INBOUND';
123
+ const windows = chunkDateRange(options.dateFrom, options.dateTo);
124
+ const pause = pauser(options);
125
+ let seen = 0;
126
+
127
+ for (const [windowIndex, window] of windows.entries()) {
128
+ let page = 1;
129
+ let availablePage = 1;
130
+
131
+ do {
132
+ if (seen > 0 || page > 1) await pause();
133
+ const response = await client.queryInvoiceDigest({
134
+ page,
135
+ invoiceDirection: direction,
136
+ invoiceQueryParams: {
137
+ mandatoryQueryParams: {
138
+ invoiceIssueDate: { dateFrom: window.dateFrom, dateTo: window.dateTo },
139
+ },
140
+ },
141
+ });
142
+
143
+ const result = response.invoiceDigestResult;
144
+ availablePage = result.availablePage;
145
+ const entries = result.invoiceDigest ?? [];
146
+
147
+ for (const digest of entries) {
148
+ seen += 1;
149
+ options.onProgress?.({
150
+ window,
151
+ windowIndex,
152
+ windowCount: windows.length,
153
+ seen,
154
+ fetched: 0,
155
+ });
156
+ yield { digest, window };
157
+ }
158
+
159
+ // NAV reports 0 available pages for an empty result.
160
+ if (entries.length < QUERY_PAGE_SIZE) break;
161
+ page += 1;
162
+ } while (page <= availablePage);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Walk every invoice in a date range, fetching each in full.
168
+ *
169
+ * One request per invoice, so this is the expensive one; pace it with
170
+ * `delayMs` and consider whether the digest alone answers your question.
171
+ */
172
+ export async function* iterateInvoices(
173
+ client: NavClient,
174
+ options: DownloadOptions,
175
+ ): AsyncGenerator<DownloadedInvoice> {
176
+ const direction = options.direction ?? 'INBOUND';
177
+ const pause = pauser(options);
178
+ let fetched = 0;
179
+ let seen = 0;
180
+
181
+ for await (const { digest, window } of iterateInvoiceDigests(client, {
182
+ ...options,
183
+ // Progress is reported here instead, where the counts are complete.
184
+ ...(options.onProgress ? { onProgress: undefined } : {}),
185
+ })) {
186
+ seen += 1;
187
+ await pause();
188
+
189
+ const response = await client.queryInvoiceData({
190
+ invoiceNumberQuery: {
191
+ invoiceNumber: digest.invoiceNumber,
192
+ invoiceDirection: direction,
193
+ // Only meaningful when querying as the customer: NAV answers
194
+ // BAD_QUERY_PARAM_SUPPLIER_NOT_EXPECTED if it is sent otherwise.
195
+ ...(direction === 'INBOUND' ? { supplierTaxNumber: digest.supplierTaxNumber } : {}),
196
+ ...(digest.batchIndex !== undefined ? { batchIndex: digest.batchIndex } : {}),
197
+ },
198
+ });
199
+
200
+ const result = response.invoiceDataResult;
201
+ if (!result) continue; // Listed in the digest but no longer retrievable.
202
+
203
+ const invoice = decodeInvoiceData(result.invoiceData, {
204
+ compressed: result.compressedContentIndicator,
205
+ });
206
+ fetched += 1;
207
+ options.onProgress?.({
208
+ window,
209
+ windowIndex: 0,
210
+ windowCount: 0,
211
+ seen,
212
+ fetched,
213
+ });
214
+
215
+ yield {
216
+ digest,
217
+ invoice,
218
+ xml: serializeDocument('InvoiceData', invoice, { indent: ' ' }),
219
+ auditData: result.auditData,
220
+ };
221
+ }
222
+ }
223
+
224
+ /** Collect a whole range into memory. Convenient, but unbounded. */
225
+ export async function downloadInvoices(
226
+ client: NavClient,
227
+ options: DownloadOptions,
228
+ ): Promise<DownloadedInvoice[]> {
229
+ const collected: DownloadedInvoice[] = [];
230
+ for await (const entry of iterateInvoices(client, options)) collected.push(entry);
231
+ return collected;
232
+ }
233
+
234
+ function pauser(options: DownloadOptions): () => Promise<void> {
235
+ const delayMs = options.delayMs ?? 250;
236
+ const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
237
+ return () => (delayMs > 0 ? sleep(delayMs) : Promise.resolve());
238
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './client.js';
2
+ export * from './credentials.js';
3
+ export * from './transport.js';
4
+ export * from './transaction.js';
5
+ export * from './download.js';