@deuriib/dgii-ecf 0.4.0 → 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.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
  };
@@ -611,27 +633,11 @@ var P12Reader = class {
611
633
  // src/signing.ts
612
634
  import crypto from "crypto";
613
635
  import { DOMParser } from "@xmldom/xmldom";
614
- import { C14nCanonicalization, SignedXml } from "xml-crypto";
636
+ import { SignedXml } from "xml-crypto";
615
637
  var C14N_INCLUSIVE = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
616
638
  var RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
617
639
  var SHA256 = "http://www.w3.org/2001/04/xmlenc#sha256";
618
640
  var ENVELOPED = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
619
- var CUSTOM_DIGEST_URI = "http://dgii.gov.do/digest/sha256-c14n";
620
- var DgiiDigest = class {
621
- getHash(xml) {
622
- const doc = new DOMParser().parseFromString(
623
- xml
624
- );
625
- const canonical = new C14nCanonicalization().process(
626
- doc,
627
- {}
628
- );
629
- return crypto.createHash("sha256").update(canonical, "utf8").digest("base64");
630
- }
631
- getAlgorithmName() {
632
- return SHA256;
633
- }
634
- };
635
641
  var parseStrict = (xml) => {
636
642
  const errors = [];
637
643
  const doc = new DOMParser({
@@ -700,12 +706,10 @@ var Signature = class {
700
706
  signatureAlgorithm: RSA_SHA256,
701
707
  canonicalizationAlgorithm: C14N_INCLUSIVE
702
708
  });
703
- const hashAlgorithms = sig.HashAlgorithms;
704
- hashAlgorithms[CUSTOM_DIGEST_URI] = DgiiDigest;
705
709
  sig.addReference({
706
710
  xpath: `//*[local-name(.)='${elementName}']`,
707
711
  transforms: [ENVELOPED],
708
- digestAlgorithm: CUSTOM_DIGEST_URI,
712
+ digestAlgorithm: SHA256,
709
713
  isEmptyUri: true
710
714
  });
711
715
  cleanNodes(doc);
@@ -851,6 +855,98 @@ var extractElementText = (xml, tag) => {
851
855
  const text = match?.[1]?.trim();
852
856
  return text === void 0 || text === "" ? void 0 : text;
853
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
+ };
854
950
 
855
951
  // src/utils.ts
856
952
  var getCodeSixDigitfromSignature = (signedXml) => {
@@ -1014,22 +1110,39 @@ import { createHash } from "crypto";
1014
1110
  var PENDING_SUBMISSION = { pending: true };
1015
1111
  var isPendingSubmission = (value) => typeof value === "object" && value !== null && value.pending === true;
1016
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
+ };
1017
1120
  var ECF = class {
1018
1121
  environment;
1019
1122
  http;
1020
1123
  idempotency;
1021
1124
  credentials;
1022
1125
  inFlight = /* @__PURE__ */ new Map();
1126
+ tokenRefreshSkewMs;
1127
+ now;
1128
+ tokenExpiresAt;
1129
+ refreshPromise;
1023
1130
  constructor(credentials, environment = "TesteCF" /* Dev */, options = {}) {
1024
1131
  this.credentials = credentials;
1025
1132
  this.environment = environment;
1133
+ this.tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 5 * 60 * 1e3;
1134
+ this.now = options.now ?? Date.now;
1026
1135
  this.idempotency = options.idempotencyStore ?? new InMemoryIdempotencyStore();
1027
1136
  this.http = new HttpClient({
1028
1137
  baseUrl: BaseUrl.ECF,
1029
1138
  ...options.token !== void 0 ? { token: options.token } : {},
1030
1139
  ...options.resilience !== void 0 ? { resilience: options.resilience } : {},
1031
1140
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
1032
- ...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()
1033
1146
  });
1034
1147
  }
1035
1148
  buildIdempotencyKey(signedXml) {
@@ -1081,6 +1194,40 @@ var ECF = class {
1081
1194
  this.inFlight.delete(key);
1082
1195
  }
1083
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
+ }
1084
1231
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
1085
1232
  async authenticate(buyerHost) {
1086
1233
  const seed = await this.getSeed(buyerHost);
@@ -1107,6 +1254,7 @@ var ECF = class {
1107
1254
  return err(token.error);
1108
1255
  }
1109
1256
  this.http.setToken(token.value.token);
1257
+ this.tokenExpiresAt = parseExpiry(token.value.expira);
1110
1258
  return ok(token.value);
1111
1259
  }
1112
1260
  getSeed(buyerHost) {
@@ -1117,20 +1265,23 @@ var ECF = class {
1117
1265
  );
1118
1266
  return this.http.get(url2, void 0, {
1119
1267
  absoluteUrl: true,
1120
- resource: "fe/autenticacion/api/semilla" /* Seed */
1268
+ resource: "fe/autenticacion/api/semilla" /* Seed */,
1269
+ skipAuthHooks: true
1121
1270
  });
1122
1271
  }
1123
1272
  const url = buildUrl(this.environment, "Autenticacion/api/Autenticacion/Semilla" /* Seed */);
1124
1273
  return this.http.get(url, void 0, {
1125
1274
  absoluteUrl: true,
1126
- resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */
1275
+ resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */,
1276
+ skipAuthHooks: true
1127
1277
  });
1128
1278
  }
1129
1279
  getAuthToken(signedSeed, buyerHost) {
1130
1280
  const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/autenticacion/api/validacioncertificado" /* ValidateSeed */) : buildUrl(this.environment, "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */);
1131
1281
  return this.http.postMultipart(url, "signed.xml", signedSeed, {
1132
1282
  absoluteUrl: true,
1133
- resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */
1283
+ resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */,
1284
+ skipAuthHooks: true
1134
1285
  });
1135
1286
  }
1136
1287
  /**
@@ -1162,6 +1313,32 @@ var ECF = class {
1162
1313
  )
1163
1314
  );
1164
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
+ }
1165
1342
  statusTrackId(trackId) {
1166
1343
  const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
1167
1344
  return this.http.get(
@@ -1255,8 +1432,10 @@ var ECF = class {
1255
1432
  };
1256
1433
  export {
1257
1434
  BaseUrl,
1435
+ CONSUMPTION_SUMMARY_THRESHOLD,
1258
1436
  DgiiError,
1259
1437
  ECF,
1438
+ ECF_TYPE_CODES,
1260
1439
  Endpoint,
1261
1440
  Environment,
1262
1441
  Err,
@@ -1271,6 +1450,7 @@ export {
1271
1450
  Transformer,
1272
1451
  buildSenderReceiverUrl,
1273
1452
  buildUrl,
1453
+ compareDecimal,
1274
1454
  convertECF32ToRFCE,
1275
1455
  createResiliencePolicies,
1276
1456
  defaultResilienceOptions,
@@ -1286,10 +1466,12 @@ export {
1286
1466
  getCurrentFormattedDate,
1287
1467
  getCurrentFormattedDateTime,
1288
1468
  getXmlFromBodyResponse,
1469
+ isEcfTypeCode,
1289
1470
  isRetryableError,
1290
1471
  isRetryableStatus,
1291
1472
  ok,
1292
1473
  okAsync,
1474
+ resolveReceptionRoute,
1293
1475
  roundHalfUpToCents,
1294
1476
  toDgiiError,
1295
1477
  validateXMLCertificate