@deuriib/dgii-ecf 0.4.1 → 0.5.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/dist/index.d.cts CHANGED
@@ -259,6 +259,10 @@ interface HttpClientOptions {
259
259
  policies?: ResiliencePolicies;
260
260
  timeoutMs?: number;
261
261
  fetchImpl?: typeof fetch;
262
+ /** Runs before every authenticated request; may refresh credentials. */
263
+ beforeRequest?: () => Promise<void>;
264
+ /** Runs on a 401; return `true` to replay the request once. */
265
+ onUnauthorized?: () => Promise<boolean>;
262
266
  }
263
267
  interface RequestOptions {
264
268
  method?: 'GET' | 'POST';
@@ -269,6 +273,8 @@ interface RequestOptions {
269
273
  body?: BodyInit;
270
274
  query?: Record<string, string | number | undefined>;
271
275
  resource?: string;
276
+ /** Skip the auth hooks (used by the authentication round-trip itself). */
277
+ skipAuthHooks?: boolean;
272
278
  }
273
279
  /** Thin HTTP client over the native `fetch`, wrapped in resilience policies. */
274
280
  declare class HttpClient {
@@ -276,17 +282,22 @@ declare class HttpClient {
276
282
  private readonly policies;
277
283
  private readonly fetchImpl;
278
284
  private readonly baseUrl;
285
+ private readonly beforeRequest;
286
+ private readonly onUnauthorized;
279
287
  constructor(options: HttpClientOptions);
280
288
  setToken(token: string): void;
289
+ getToken(): string | undefined;
281
290
  clearToken(): void;
282
291
  private buildUrl;
283
292
  private authHeaders;
284
293
  private readBody;
285
294
  private parseResponse;
286
295
  request<T>(options: RequestOptions): Promise<Result<T, DgiiError>>;
296
+ private execute;
287
297
  get<T>(path: string, query?: RequestOptions['query'], opts?: {
288
298
  absoluteUrl?: boolean;
289
299
  resource?: string;
300
+ skipAuthHooks?: boolean;
290
301
  }): Promise<Result<T, DgiiError>>;
291
302
  /**
292
303
  * POST a signed XML as a multipart `xml` file part. The runtime computes
@@ -295,6 +306,7 @@ declare class HttpClient {
295
306
  postMultipart<T>(path: string, fileName: string, xml: string, opts?: {
296
307
  absoluteUrl?: boolean;
297
308
  resource?: string;
309
+ skipAuthHooks?: boolean;
298
310
  }): Promise<Result<T, DgiiError>>;
299
311
  }
300
312
 
@@ -364,6 +376,45 @@ declare class Transformer {
364
376
  xml2json<T>(xml: string): Result<T, DgiiError>;
365
377
  }
366
378
 
379
+ /** Every e-CF type code defined by DGII. */
380
+ declare const ECF_TYPE_CODES: readonly ["31", "32", "33", "34", "41", "43", "44", "45", "46", "47"];
381
+ type EcfTypeCode = (typeof ECF_TYPE_CODES)[number];
382
+ /**
383
+ * Consumption invoices (E32) at or above this amount go to the general
384
+ * reception endpoint; below it they must be sent as an RFCE summary.
385
+ */
386
+ declare const CONSUMPTION_SUMMARY_THRESHOLD = "250000";
387
+ interface GeneralReceptionRoute {
388
+ readonly kind: 'general';
389
+ readonly endpoint: Endpoint.SendInvoice;
390
+ }
391
+ interface SummaryReceptionRoute {
392
+ readonly kind: 'summary';
393
+ readonly endpoint: Endpoint.SendSummary;
394
+ }
395
+ type ReceptionRoute = GeneralReceptionRoute | SummaryReceptionRoute;
396
+ declare const isEcfTypeCode: (value: string) => value is EcfTypeCode;
397
+ /**
398
+ * Compare two non-negative decimal strings without going through `number`, so
399
+ * a money amount never picks up binary floating-point error.
400
+ *
401
+ * Returns a negative number when `a < b`, zero when equal, positive otherwise.
402
+ */
403
+ declare const compareDecimal: (a: string, b: string) => number;
404
+ /**
405
+ * Decide which DGII reception endpoint a signed document belongs to.
406
+ *
407
+ * - `<RFCE>` → the consumption-summary reception (`recepcionfc`).
408
+ * - `<ECF>` with any type other than E32 → the general reception.
409
+ * - `<ECF>` type E32 at or above RD$250,000 → the general reception.
410
+ * - `<ECF>` type E32 below RD$250,000 → rejected: DGII only accepts the
411
+ * summary form for those, so the caller must run `convertECF32ToRFCE` first.
412
+ *
413
+ * The decision reads `TipoeCF` and `MontoTotal` from the signed document, so it
414
+ * cannot drift from what is actually being transmitted.
415
+ */
416
+ declare const resolveReceptionRoute: (signedXml: string) => Result<ReceptionRoute, DgiiError>;
417
+
367
418
  /** First 6 chars of the `<SignatureValue>` (DGII security code). */
368
419
  declare const getCodeSixDigitfromSignature: (signedXml: string) => Result<string, DgiiError>;
369
420
  /** QR URL for a factura de consumo (FC). */
@@ -394,6 +445,13 @@ interface EcfOptions {
394
445
  timeoutMs?: number;
395
446
  /** Injectable fetch (useful for tests). */
396
447
  fetchImpl?: typeof fetch;
448
+ /**
449
+ * Refresh the token when its remaining lifetime falls below this window.
450
+ * Defaults to 5 minutes.
451
+ */
452
+ tokenRefreshSkewMs?: number;
453
+ /** Injectable clock (epoch milliseconds); useful for tests. */
454
+ now?: () => number;
397
455
  }
398
456
  /**
399
457
  * DGII e-CF client. Every method returns a `Result` — no thrown domain errors.
@@ -404,6 +462,10 @@ declare class ECF {
404
462
  private readonly idempotency;
405
463
  private readonly credentials;
406
464
  private readonly inFlight;
465
+ private readonly tokenRefreshSkewMs;
466
+ private readonly now;
467
+ private tokenExpiresAt;
468
+ private refreshPromise;
407
469
  constructor(credentials: P12ReaderData, environment?: Environment, options?: EcfOptions);
408
470
  private buildIdempotencyKey;
409
471
  /**
@@ -415,6 +477,17 @@ declare class ECF {
415
477
  */
416
478
  private submitIdempotent;
417
479
  private singleFlight;
480
+ /**
481
+ * Refresh the token when it is missing or about to expire.
482
+ *
483
+ * A pre-supplied `token` has no known expiry, so it is never refreshed
484
+ * proactively — only a 401 triggers a re-authentication for it.
485
+ */
486
+ private ensureToken;
487
+ /** Authenticate, coalescing concurrent refreshes into a single round-trip. */
488
+ private refreshToken;
489
+ /** Drop the rejected token and authenticate once. Returns `true` on success. */
490
+ private reauthenticate;
418
491
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
419
492
  authenticate(buyerHost?: string): Promise<Result<AuthToken, DgiiError>>;
420
493
  private getSeed;
@@ -426,6 +499,15 @@ declare class ECF {
426
499
  sendElectronicDocument(signedXml: string, fileName: string, buyerHost?: string): Promise<Result<InvoiceResponse, DgiiError>>;
427
500
  /** Submit a signed summary invoice (RFCE). Idempotent like submissions. */
428
501
  sendSummary(signedXml: string, fileName: string): Promise<Result<InvoiceSummaryResponse, DgiiError>>;
502
+ /**
503
+ * Submit a signed document to the endpoint its own content belongs to.
504
+ *
505
+ * Reads the root element, `TipoeCF` and `MontoTotal` from the signed XML and
506
+ * dispatches to {@link sendSummary} for an RFCE or to
507
+ * {@link sendElectronicDocument} for an ECF. An E32 below RD$250,000 is
508
+ * rejected because DGII only accepts the summary form for those.
509
+ */
510
+ sendDocument(signedXml: string, fileName: string, buyerHost?: string): Promise<Result<InvoiceResponse | InvoiceSummaryResponse, DgiiError>>;
429
511
  statusTrackId(trackId: string): Promise<Result<TrackingStatusResponse, DgiiError>>;
430
512
  inquiryStatus(rncEmisor: string, ncfElectronico: string, rncComprador?: string, codigoSeguridad?: string): Promise<Result<InquiryStatusResponse, DgiiError>>;
431
513
  trackStatuses(rncEmisor: string, encf: string): Promise<Result<SummaryTrackingStatusResponse[], DgiiError>>;
@@ -445,4 +527,4 @@ declare class ECF {
445
527
  voidENCF(signedXml: string, fileName: string): Promise<Result<VoidNCFResponse, DgiiError>>;
446
528
  }
447
529
 
448
- export { type AuthToken, BaseUrl, type BaseUrlKey, type CertificateInfo, type CommercialApprovalResponse, type DgiiDocumentType, DgiiError, type DgiiErrorCode, type DgiiErrorContext, type DgiiErrorOptions, type DgiiResult, ECF, type EcfOptions, Endpoint, Environment, HttpClient, type HttpClientOptions, type IdempotencyStore, InMemoryIdempotencyStore, type InMemoryIdempotencyStoreOptions, type InquiryInvoiceSummary, type InquiryStatus, type InquiryStatusCode, type InquiryStatusResponse, type InvoiceResponse, type InvoiceSummaryResponse, type Mensaje, P12Reader, type P12ReaderData, type RequestOptions, type ResilienceOptions, type ResiliencePolicies, SenderReceiverEndpoint, type ServiceDirectoryResponse, Signature, type SummaryTrackingStatusResponse, type TrackStatusCode, TrackStatusEnum, type TrackingStatusResponse, Transformer, type VoidNCFResponse, type XmlCertificateValidation, buildSenderReceiverUrl, buildUrl, convertECF32ToRFCE, createResiliencePolicies, defaultResilienceOptions, extractDgiiErrorMessage, generateEcfQRCodeURL, generateFcQRCodeURL, getCodeSixDigitfromSignature, getCurrentFormattedDate, getCurrentFormattedDateTime, getXmlFromBodyResponse, isRetryableError, isRetryableStatus, roundHalfUpToCents, toDgiiError, validateXMLCertificate };
530
+ export { type AuthToken, BaseUrl, type BaseUrlKey, CONSUMPTION_SUMMARY_THRESHOLD, type CertificateInfo, type CommercialApprovalResponse, type DgiiDocumentType, DgiiError, type DgiiErrorCode, type DgiiErrorContext, type DgiiErrorOptions, type DgiiResult, ECF, ECF_TYPE_CODES, type EcfOptions, type EcfTypeCode, Endpoint, Environment, type GeneralReceptionRoute, HttpClient, type HttpClientOptions, type IdempotencyStore, InMemoryIdempotencyStore, type InMemoryIdempotencyStoreOptions, type InquiryInvoiceSummary, type InquiryStatus, type InquiryStatusCode, type InquiryStatusResponse, type InvoiceResponse, type InvoiceSummaryResponse, type Mensaje, P12Reader, type P12ReaderData, type ReceptionRoute, type RequestOptions, type ResilienceOptions, type ResiliencePolicies, SenderReceiverEndpoint, type ServiceDirectoryResponse, Signature, type SummaryReceptionRoute, type SummaryTrackingStatusResponse, type TrackStatusCode, TrackStatusEnum, type TrackingStatusResponse, Transformer, type VoidNCFResponse, type XmlCertificateValidation, buildSenderReceiverUrl, buildUrl, compareDecimal, convertECF32ToRFCE, createResiliencePolicies, defaultResilienceOptions, extractDgiiErrorMessage, generateEcfQRCodeURL, generateFcQRCodeURL, getCodeSixDigitfromSignature, getCurrentFormattedDate, getCurrentFormattedDateTime, getXmlFromBodyResponse, isEcfTypeCode, isRetryableError, isRetryableStatus, resolveReceptionRoute, roundHalfUpToCents, toDgiiError, validateXMLCertificate };
package/dist/index.d.ts CHANGED
@@ -259,6 +259,10 @@ interface HttpClientOptions {
259
259
  policies?: ResiliencePolicies;
260
260
  timeoutMs?: number;
261
261
  fetchImpl?: typeof fetch;
262
+ /** Runs before every authenticated request; may refresh credentials. */
263
+ beforeRequest?: () => Promise<void>;
264
+ /** Runs on a 401; return `true` to replay the request once. */
265
+ onUnauthorized?: () => Promise<boolean>;
262
266
  }
263
267
  interface RequestOptions {
264
268
  method?: 'GET' | 'POST';
@@ -269,6 +273,8 @@ interface RequestOptions {
269
273
  body?: BodyInit;
270
274
  query?: Record<string, string | number | undefined>;
271
275
  resource?: string;
276
+ /** Skip the auth hooks (used by the authentication round-trip itself). */
277
+ skipAuthHooks?: boolean;
272
278
  }
273
279
  /** Thin HTTP client over the native `fetch`, wrapped in resilience policies. */
274
280
  declare class HttpClient {
@@ -276,17 +282,22 @@ declare class HttpClient {
276
282
  private readonly policies;
277
283
  private readonly fetchImpl;
278
284
  private readonly baseUrl;
285
+ private readonly beforeRequest;
286
+ private readonly onUnauthorized;
279
287
  constructor(options: HttpClientOptions);
280
288
  setToken(token: string): void;
289
+ getToken(): string | undefined;
281
290
  clearToken(): void;
282
291
  private buildUrl;
283
292
  private authHeaders;
284
293
  private readBody;
285
294
  private parseResponse;
286
295
  request<T>(options: RequestOptions): Promise<Result<T, DgiiError>>;
296
+ private execute;
287
297
  get<T>(path: string, query?: RequestOptions['query'], opts?: {
288
298
  absoluteUrl?: boolean;
289
299
  resource?: string;
300
+ skipAuthHooks?: boolean;
290
301
  }): Promise<Result<T, DgiiError>>;
291
302
  /**
292
303
  * POST a signed XML as a multipart `xml` file part. The runtime computes
@@ -295,6 +306,7 @@ declare class HttpClient {
295
306
  postMultipart<T>(path: string, fileName: string, xml: string, opts?: {
296
307
  absoluteUrl?: boolean;
297
308
  resource?: string;
309
+ skipAuthHooks?: boolean;
298
310
  }): Promise<Result<T, DgiiError>>;
299
311
  }
300
312
 
@@ -364,6 +376,45 @@ declare class Transformer {
364
376
  xml2json<T>(xml: string): Result<T, DgiiError>;
365
377
  }
366
378
 
379
+ /** Every e-CF type code defined by DGII. */
380
+ declare const ECF_TYPE_CODES: readonly ["31", "32", "33", "34", "41", "43", "44", "45", "46", "47"];
381
+ type EcfTypeCode = (typeof ECF_TYPE_CODES)[number];
382
+ /**
383
+ * Consumption invoices (E32) at or above this amount go to the general
384
+ * reception endpoint; below it they must be sent as an RFCE summary.
385
+ */
386
+ declare const CONSUMPTION_SUMMARY_THRESHOLD = "250000";
387
+ interface GeneralReceptionRoute {
388
+ readonly kind: 'general';
389
+ readonly endpoint: Endpoint.SendInvoice;
390
+ }
391
+ interface SummaryReceptionRoute {
392
+ readonly kind: 'summary';
393
+ readonly endpoint: Endpoint.SendSummary;
394
+ }
395
+ type ReceptionRoute = GeneralReceptionRoute | SummaryReceptionRoute;
396
+ declare const isEcfTypeCode: (value: string) => value is EcfTypeCode;
397
+ /**
398
+ * Compare two non-negative decimal strings without going through `number`, so
399
+ * a money amount never picks up binary floating-point error.
400
+ *
401
+ * Returns a negative number when `a < b`, zero when equal, positive otherwise.
402
+ */
403
+ declare const compareDecimal: (a: string, b: string) => number;
404
+ /**
405
+ * Decide which DGII reception endpoint a signed document belongs to.
406
+ *
407
+ * - `<RFCE>` → the consumption-summary reception (`recepcionfc`).
408
+ * - `<ECF>` with any type other than E32 → the general reception.
409
+ * - `<ECF>` type E32 at or above RD$250,000 → the general reception.
410
+ * - `<ECF>` type E32 below RD$250,000 → rejected: DGII only accepts the
411
+ * summary form for those, so the caller must run `convertECF32ToRFCE` first.
412
+ *
413
+ * The decision reads `TipoeCF` and `MontoTotal` from the signed document, so it
414
+ * cannot drift from what is actually being transmitted.
415
+ */
416
+ declare const resolveReceptionRoute: (signedXml: string) => Result<ReceptionRoute, DgiiError>;
417
+
367
418
  /** First 6 chars of the `<SignatureValue>` (DGII security code). */
368
419
  declare const getCodeSixDigitfromSignature: (signedXml: string) => Result<string, DgiiError>;
369
420
  /** QR URL for a factura de consumo (FC). */
@@ -394,6 +445,13 @@ interface EcfOptions {
394
445
  timeoutMs?: number;
395
446
  /** Injectable fetch (useful for tests). */
396
447
  fetchImpl?: typeof fetch;
448
+ /**
449
+ * Refresh the token when its remaining lifetime falls below this window.
450
+ * Defaults to 5 minutes.
451
+ */
452
+ tokenRefreshSkewMs?: number;
453
+ /** Injectable clock (epoch milliseconds); useful for tests. */
454
+ now?: () => number;
397
455
  }
398
456
  /**
399
457
  * DGII e-CF client. Every method returns a `Result` — no thrown domain errors.
@@ -404,6 +462,10 @@ declare class ECF {
404
462
  private readonly idempotency;
405
463
  private readonly credentials;
406
464
  private readonly inFlight;
465
+ private readonly tokenRefreshSkewMs;
466
+ private readonly now;
467
+ private tokenExpiresAt;
468
+ private refreshPromise;
407
469
  constructor(credentials: P12ReaderData, environment?: Environment, options?: EcfOptions);
408
470
  private buildIdempotencyKey;
409
471
  /**
@@ -415,6 +477,17 @@ declare class ECF {
415
477
  */
416
478
  private submitIdempotent;
417
479
  private singleFlight;
480
+ /**
481
+ * Refresh the token when it is missing or about to expire.
482
+ *
483
+ * A pre-supplied `token` has no known expiry, so it is never refreshed
484
+ * proactively — only a 401 triggers a re-authentication for it.
485
+ */
486
+ private ensureToken;
487
+ /** Authenticate, coalescing concurrent refreshes into a single round-trip. */
488
+ private refreshToken;
489
+ /** Drop the rejected token and authenticate once. Returns `true` on success. */
490
+ private reauthenticate;
418
491
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
419
492
  authenticate(buyerHost?: string): Promise<Result<AuthToken, DgiiError>>;
420
493
  private getSeed;
@@ -426,6 +499,15 @@ declare class ECF {
426
499
  sendElectronicDocument(signedXml: string, fileName: string, buyerHost?: string): Promise<Result<InvoiceResponse, DgiiError>>;
427
500
  /** Submit a signed summary invoice (RFCE). Idempotent like submissions. */
428
501
  sendSummary(signedXml: string, fileName: string): Promise<Result<InvoiceSummaryResponse, DgiiError>>;
502
+ /**
503
+ * Submit a signed document to the endpoint its own content belongs to.
504
+ *
505
+ * Reads the root element, `TipoeCF` and `MontoTotal` from the signed XML and
506
+ * dispatches to {@link sendSummary} for an RFCE or to
507
+ * {@link sendElectronicDocument} for an ECF. An E32 below RD$250,000 is
508
+ * rejected because DGII only accepts the summary form for those.
509
+ */
510
+ sendDocument(signedXml: string, fileName: string, buyerHost?: string): Promise<Result<InvoiceResponse | InvoiceSummaryResponse, DgiiError>>;
429
511
  statusTrackId(trackId: string): Promise<Result<TrackingStatusResponse, DgiiError>>;
430
512
  inquiryStatus(rncEmisor: string, ncfElectronico: string, rncComprador?: string, codigoSeguridad?: string): Promise<Result<InquiryStatusResponse, DgiiError>>;
431
513
  trackStatuses(rncEmisor: string, encf: string): Promise<Result<SummaryTrackingStatusResponse[], DgiiError>>;
@@ -445,4 +527,4 @@ declare class ECF {
445
527
  voidENCF(signedXml: string, fileName: string): Promise<Result<VoidNCFResponse, DgiiError>>;
446
528
  }
447
529
 
448
- export { type AuthToken, BaseUrl, type BaseUrlKey, type CertificateInfo, type CommercialApprovalResponse, type DgiiDocumentType, DgiiError, type DgiiErrorCode, type DgiiErrorContext, type DgiiErrorOptions, type DgiiResult, ECF, type EcfOptions, Endpoint, Environment, HttpClient, type HttpClientOptions, type IdempotencyStore, InMemoryIdempotencyStore, type InMemoryIdempotencyStoreOptions, type InquiryInvoiceSummary, type InquiryStatus, type InquiryStatusCode, type InquiryStatusResponse, type InvoiceResponse, type InvoiceSummaryResponse, type Mensaje, P12Reader, type P12ReaderData, type RequestOptions, type ResilienceOptions, type ResiliencePolicies, SenderReceiverEndpoint, type ServiceDirectoryResponse, Signature, type SummaryTrackingStatusResponse, type TrackStatusCode, TrackStatusEnum, type TrackingStatusResponse, Transformer, type VoidNCFResponse, type XmlCertificateValidation, buildSenderReceiverUrl, buildUrl, convertECF32ToRFCE, createResiliencePolicies, defaultResilienceOptions, extractDgiiErrorMessage, generateEcfQRCodeURL, generateFcQRCodeURL, getCodeSixDigitfromSignature, getCurrentFormattedDate, getCurrentFormattedDateTime, getXmlFromBodyResponse, isRetryableError, isRetryableStatus, roundHalfUpToCents, toDgiiError, validateXMLCertificate };
530
+ export { type AuthToken, BaseUrl, type BaseUrlKey, CONSUMPTION_SUMMARY_THRESHOLD, type CertificateInfo, type CommercialApprovalResponse, type DgiiDocumentType, DgiiError, type DgiiErrorCode, type DgiiErrorContext, type DgiiErrorOptions, type DgiiResult, ECF, ECF_TYPE_CODES, type EcfOptions, type EcfTypeCode, Endpoint, Environment, type GeneralReceptionRoute, HttpClient, type HttpClientOptions, type IdempotencyStore, InMemoryIdempotencyStore, type InMemoryIdempotencyStoreOptions, type InquiryInvoiceSummary, type InquiryStatus, type InquiryStatusCode, type InquiryStatusResponse, type InvoiceResponse, type InvoiceSummaryResponse, type Mensaje, P12Reader, type P12ReaderData, type ReceptionRoute, type RequestOptions, type ResilienceOptions, type ResiliencePolicies, SenderReceiverEndpoint, type ServiceDirectoryResponse, Signature, type SummaryReceptionRoute, type SummaryTrackingStatusResponse, type TrackStatusCode, TrackStatusEnum, type TrackingStatusResponse, Transformer, type VoidNCFResponse, type XmlCertificateValidation, buildSenderReceiverUrl, buildUrl, compareDecimal, convertECF32ToRFCE, createResiliencePolicies, defaultResilienceOptions, extractDgiiErrorMessage, generateEcfQRCodeURL, generateFcQRCodeURL, getCodeSixDigitfromSignature, getCurrentFormattedDate, getCurrentFormattedDateTime, getXmlFromBodyResponse, isEcfTypeCode, isRetryableError, isRetryableStatus, resolveReceptionRoute, roundHalfUpToCents, toDgiiError, validateXMLCertificate };
package/dist/index.js CHANGED
@@ -370,9 +370,13 @@ var HttpClient = class {
370
370
  policies;
371
371
  fetchImpl;
372
372
  baseUrl;
373
+ beforeRequest;
374
+ onUnauthorized;
373
375
  constructor(options) {
374
376
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
375
377
  this.token = options.token;
378
+ this.beforeRequest = options.beforeRequest;
379
+ this.onUnauthorized = options.onUnauthorized;
376
380
  this.policies = options.policies ?? createResiliencePolicies({
377
381
  ...options.resilience,
378
382
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
@@ -382,6 +386,9 @@ var HttpClient = class {
382
386
  setToken(token) {
383
387
  this.token = token;
384
388
  }
389
+ getToken() {
390
+ return this.token;
391
+ }
385
392
  clearToken() {
386
393
  this.token = void 0;
387
394
  }
@@ -441,6 +448,19 @@ var HttpClient = class {
441
448
  return err(error);
442
449
  }
443
450
  async request(options) {
451
+ if (!options.skipAuthHooks) {
452
+ await this.beforeRequest?.();
453
+ }
454
+ const first = await this.execute(options);
455
+ if (!options.skipAuthHooks && first.isErr() && first.error.status === 401 && this.onUnauthorized !== void 0) {
456
+ const replayed = await this.onUnauthorized();
457
+ if (replayed) {
458
+ return this.execute(options);
459
+ }
460
+ }
461
+ return first;
462
+ }
463
+ async execute(options) {
444
464
  const url = this.buildUrl(options.path, options.absoluteUrl, options.query);
445
465
  const method = options.method ?? "GET";
446
466
  const headers = this.authHeaders(options.headers);
@@ -469,7 +489,8 @@ var HttpClient = class {
469
489
  path,
470
490
  query,
471
491
  absoluteUrl: opts?.absoluteUrl,
472
- resource: opts?.resource
492
+ resource: opts?.resource,
493
+ skipAuthHooks: opts?.skipAuthHooks
473
494
  });
474
495
  }
475
496
  /**
@@ -485,7 +506,8 @@ var HttpClient = class {
485
506
  body: form,
486
507
  headers: { Accept: "application/json" },
487
508
  absoluteUrl: opts?.absoluteUrl,
488
- resource: opts?.resource
509
+ resource: opts?.resource,
510
+ skipAuthHooks: opts?.skipAuthHooks
489
511
  });
490
512
  }
491
513
  };
@@ -833,6 +855,98 @@ var extractElementText = (xml, tag) => {
833
855
  const text = match?.[1]?.trim();
834
856
  return text === void 0 || text === "" ? void 0 : text;
835
857
  };
858
+ var ROOT_PATTERN = /<\s*(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)[\s/>]/;
859
+ var extractRootElementName = (xml) => {
860
+ const stripped = xml.replace(/<\?[\s\S]*?\?>/g, "").replace(/<!--[\s\S]*?-->/g, "").trimStart();
861
+ return ROOT_PATTERN.exec(stripped)?.[1];
862
+ };
863
+
864
+ // src/reception.ts
865
+ var ECF_TYPE_CODES = [
866
+ "31",
867
+ "32",
868
+ "33",
869
+ "34",
870
+ "41",
871
+ "43",
872
+ "44",
873
+ "45",
874
+ "46",
875
+ "47"
876
+ ];
877
+ var CONSUMPTION_SUMMARY_THRESHOLD = "250000";
878
+ var CONSUMPTION_TYPE = "32";
879
+ var GENERAL = {
880
+ kind: "general",
881
+ endpoint: "recepcion/api/FacturasElectronicas" /* SendInvoice */
882
+ };
883
+ var SUMMARY = {
884
+ kind: "summary",
885
+ endpoint: "recepcionfc/api/recepcion/ecf" /* SendSummary */
886
+ };
887
+ var isEcfTypeCode = (value) => ECF_TYPE_CODES.includes(value);
888
+ var DECIMAL_PATTERN = /^\d+(?:\.\d+)?$/;
889
+ var compareDecimal = (a, b) => {
890
+ const [aInt = "", aFrac = ""] = a.split(".");
891
+ const [bInt = "", bFrac = ""] = b.split(".");
892
+ const aWhole = aInt.replace(/^0+(?=\d)/, "");
893
+ const bWhole = bInt.replace(/^0+(?=\d)/, "");
894
+ if (aWhole.length !== bWhole.length) {
895
+ return aWhole.length - bWhole.length;
896
+ }
897
+ if (aWhole !== bWhole) {
898
+ return aWhole < bWhole ? -1 : 1;
899
+ }
900
+ const width = Math.max(aFrac.length, bFrac.length);
901
+ const aPadded = aFrac.padEnd(width, "0");
902
+ const bPadded = bFrac.padEnd(width, "0");
903
+ if (aPadded === bPadded) {
904
+ return 0;
905
+ }
906
+ return aPadded < bPadded ? -1 : 1;
907
+ };
908
+ var validationError = (message) => new DgiiError(message, { code: "VALIDATION" });
909
+ var resolveReceptionRoute = (signedXml) => {
910
+ const root = extractRootElementName(signedXml);
911
+ if (root === "RFCE") {
912
+ return ok(SUMMARY);
913
+ }
914
+ if (root !== "ECF") {
915
+ return err(
916
+ validationError(
917
+ `Unsupported document root "${root ?? "unknown"}"; expected "ECF" or "RFCE"`
918
+ )
919
+ );
920
+ }
921
+ const tipo = extractElementText(signedXml, "TipoeCF");
922
+ if (tipo === void 0) {
923
+ return err(validationError("TipoeCF is missing from the document"));
924
+ }
925
+ if (!isEcfTypeCode(tipo)) {
926
+ return err(validationError(`Unknown TipoeCF "${tipo}"`));
927
+ }
928
+ if (tipo !== CONSUMPTION_TYPE) {
929
+ return ok(GENERAL);
930
+ }
931
+ const total = extractElementText(signedXml, "MontoTotal");
932
+ if (total === void 0) {
933
+ return err(
934
+ validationError(
935
+ "MontoTotal is required to route an E32 consumption invoice"
936
+ )
937
+ );
938
+ }
939
+ if (!DECIMAL_PATTERN.test(total)) {
940
+ return err(
941
+ validationError(`MontoTotal "${total}" is not a decimal amount`)
942
+ );
943
+ }
944
+ return compareDecimal(total, CONSUMPTION_SUMMARY_THRESHOLD) < 0 ? err(
945
+ validationError(
946
+ "An E32 below RD$250,000 must be sent as an RFCE summary; convert it with convertECF32ToRFCE first"
947
+ )
948
+ ) : ok(GENERAL);
949
+ };
836
950
 
837
951
  // src/utils.ts
838
952
  var getCodeSixDigitfromSignature = (signedXml) => {
@@ -996,22 +1110,39 @@ import { createHash } from "crypto";
996
1110
  var PENDING_SUBMISSION = { pending: true };
997
1111
  var isPendingSubmission = (value) => typeof value === "object" && value !== null && value.pending === true;
998
1112
  var sha256Hex = (value) => createHash("sha256").update(value, "utf8").digest("hex");
1113
+ var parseExpiry = (value) => {
1114
+ if (value === void 0) {
1115
+ return void 0;
1116
+ }
1117
+ const parsed = Date.parse(value);
1118
+ return Number.isNaN(parsed) ? void 0 : parsed;
1119
+ };
999
1120
  var ECF = class {
1000
1121
  environment;
1001
1122
  http;
1002
1123
  idempotency;
1003
1124
  credentials;
1004
1125
  inFlight = /* @__PURE__ */ new Map();
1126
+ tokenRefreshSkewMs;
1127
+ now;
1128
+ tokenExpiresAt;
1129
+ refreshPromise;
1005
1130
  constructor(credentials, environment = "TesteCF" /* Dev */, options = {}) {
1006
1131
  this.credentials = credentials;
1007
1132
  this.environment = environment;
1133
+ this.tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 5 * 60 * 1e3;
1134
+ this.now = options.now ?? Date.now;
1008
1135
  this.idempotency = options.idempotencyStore ?? new InMemoryIdempotencyStore();
1009
1136
  this.http = new HttpClient({
1010
1137
  baseUrl: BaseUrl.ECF,
1011
1138
  ...options.token !== void 0 ? { token: options.token } : {},
1012
1139
  ...options.resilience !== void 0 ? { resilience: options.resilience } : {},
1013
1140
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
1014
- ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
1141
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
1142
+ beforeRequest: async () => {
1143
+ await this.ensureToken();
1144
+ },
1145
+ onUnauthorized: () => this.reauthenticate()
1015
1146
  });
1016
1147
  }
1017
1148
  buildIdempotencyKey(signedXml) {
@@ -1063,6 +1194,40 @@ var ECF = class {
1063
1194
  this.inFlight.delete(key);
1064
1195
  }
1065
1196
  }
1197
+ /**
1198
+ * Refresh the token when it is missing or about to expire.
1199
+ *
1200
+ * A pre-supplied `token` has no known expiry, so it is never refreshed
1201
+ * proactively — only a 401 triggers a re-authentication for it.
1202
+ */
1203
+ async ensureToken() {
1204
+ if (this.tokenExpiresAt === void 0) {
1205
+ return ok(void 0);
1206
+ }
1207
+ if (this.now() < this.tokenExpiresAt - this.tokenRefreshSkewMs) {
1208
+ return ok(void 0);
1209
+ }
1210
+ const refreshed = await this.refreshToken();
1211
+ return refreshed.isOk() ? ok(void 0) : err(refreshed.error);
1212
+ }
1213
+ /** Authenticate, coalescing concurrent refreshes into a single round-trip. */
1214
+ refreshToken() {
1215
+ if (this.refreshPromise !== void 0) {
1216
+ return this.refreshPromise;
1217
+ }
1218
+ const promise = this.authenticate().finally(() => {
1219
+ this.refreshPromise = void 0;
1220
+ });
1221
+ this.refreshPromise = promise;
1222
+ return promise;
1223
+ }
1224
+ /** Drop the rejected token and authenticate once. Returns `true` on success. */
1225
+ async reauthenticate() {
1226
+ this.http.clearToken();
1227
+ this.tokenExpiresAt = void 0;
1228
+ const refreshed = await this.refreshToken();
1229
+ return refreshed.isOk();
1230
+ }
1066
1231
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
1067
1232
  async authenticate(buyerHost) {
1068
1233
  const seed = await this.getSeed(buyerHost);
@@ -1089,6 +1254,7 @@ var ECF = class {
1089
1254
  return err(token.error);
1090
1255
  }
1091
1256
  this.http.setToken(token.value.token);
1257
+ this.tokenExpiresAt = parseExpiry(token.value.expira);
1092
1258
  return ok(token.value);
1093
1259
  }
1094
1260
  getSeed(buyerHost) {
@@ -1099,20 +1265,23 @@ var ECF = class {
1099
1265
  );
1100
1266
  return this.http.get(url2, void 0, {
1101
1267
  absoluteUrl: true,
1102
- resource: "fe/autenticacion/api/semilla" /* Seed */
1268
+ resource: "fe/autenticacion/api/semilla" /* Seed */,
1269
+ skipAuthHooks: true
1103
1270
  });
1104
1271
  }
1105
1272
  const url = buildUrl(this.environment, "Autenticacion/api/Autenticacion/Semilla" /* Seed */);
1106
1273
  return this.http.get(url, void 0, {
1107
1274
  absoluteUrl: true,
1108
- resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */
1275
+ resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */,
1276
+ skipAuthHooks: true
1109
1277
  });
1110
1278
  }
1111
1279
  getAuthToken(signedSeed, buyerHost) {
1112
1280
  const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/autenticacion/api/validacioncertificado" /* ValidateSeed */) : buildUrl(this.environment, "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */);
1113
1281
  return this.http.postMultipart(url, "signed.xml", signedSeed, {
1114
1282
  absoluteUrl: true,
1115
- resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */
1283
+ resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */,
1284
+ skipAuthHooks: true
1116
1285
  });
1117
1286
  }
1118
1287
  /**
@@ -1144,6 +1313,32 @@ var ECF = class {
1144
1313
  )
1145
1314
  );
1146
1315
  }
1316
+ /**
1317
+ * Submit a signed document to the endpoint its own content belongs to.
1318
+ *
1319
+ * Reads the root element, `TipoeCF` and `MontoTotal` from the signed XML and
1320
+ * dispatches to {@link sendSummary} for an RFCE or to
1321
+ * {@link sendElectronicDocument} for an ECF. An E32 below RD$250,000 is
1322
+ * rejected because DGII only accepts the summary form for those.
1323
+ */
1324
+ async sendDocument(signedXml, fileName, buyerHost) {
1325
+ const route = resolveReceptionRoute(signedXml);
1326
+ if (route.isErr()) {
1327
+ return err(route.error);
1328
+ }
1329
+ if (route.value.kind === "summary") {
1330
+ if (buyerHost !== void 0) {
1331
+ return err(
1332
+ new DgiiError(
1333
+ "An RFCE summary cannot be delivered directly to a buyer host",
1334
+ { code: "VALIDATION" }
1335
+ )
1336
+ );
1337
+ }
1338
+ return this.sendSummary(signedXml, fileName);
1339
+ }
1340
+ return this.sendElectronicDocument(signedXml, fileName, buyerHost);
1341
+ }
1147
1342
  statusTrackId(trackId) {
1148
1343
  const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
1149
1344
  return this.http.get(
@@ -1237,8 +1432,10 @@ var ECF = class {
1237
1432
  };
1238
1433
  export {
1239
1434
  BaseUrl,
1435
+ CONSUMPTION_SUMMARY_THRESHOLD,
1240
1436
  DgiiError,
1241
1437
  ECF,
1438
+ ECF_TYPE_CODES,
1242
1439
  Endpoint,
1243
1440
  Environment,
1244
1441
  Err,
@@ -1253,6 +1450,7 @@ export {
1253
1450
  Transformer,
1254
1451
  buildSenderReceiverUrl,
1255
1452
  buildUrl,
1453
+ compareDecimal,
1256
1454
  convertECF32ToRFCE,
1257
1455
  createResiliencePolicies,
1258
1456
  defaultResilienceOptions,
@@ -1268,10 +1466,12 @@ export {
1268
1466
  getCurrentFormattedDate,
1269
1467
  getCurrentFormattedDateTime,
1270
1468
  getXmlFromBodyResponse,
1469
+ isEcfTypeCode,
1271
1470
  isRetryableError,
1272
1471
  isRetryableStatus,
1273
1472
  ok,
1274
1473
  okAsync,
1474
+ resolveReceptionRoute,
1275
1475
  roundHalfUpToCents,
1276
1476
  toDgiiError,
1277
1477
  validateXMLCertificate