@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.cjs CHANGED
@@ -31,8 +31,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BaseUrl: () => BaseUrl,
34
+ CONSUMPTION_SUMMARY_THRESHOLD: () => CONSUMPTION_SUMMARY_THRESHOLD,
34
35
  DgiiError: () => DgiiError,
35
36
  ECF: () => ECF,
37
+ ECF_TYPE_CODES: () => ECF_TYPE_CODES,
36
38
  Endpoint: () => Endpoint,
37
39
  Environment: () => Environment,
38
40
  Err: () => import_neverthrow.Err,
@@ -47,6 +49,7 @@ __export(index_exports, {
47
49
  Transformer: () => Transformer,
48
50
  buildSenderReceiverUrl: () => buildSenderReceiverUrl,
49
51
  buildUrl: () => buildUrl,
52
+ compareDecimal: () => compareDecimal,
50
53
  convertECF32ToRFCE: () => convertECF32ToRFCE,
51
54
  createResiliencePolicies: () => createResiliencePolicies,
52
55
  defaultResilienceOptions: () => defaultResilienceOptions,
@@ -62,10 +65,12 @@ __export(index_exports, {
62
65
  getCurrentFormattedDate: () => getCurrentFormattedDate,
63
66
  getCurrentFormattedDateTime: () => getCurrentFormattedDateTime,
64
67
  getXmlFromBodyResponse: () => getXmlFromBodyResponse,
68
+ isEcfTypeCode: () => isEcfTypeCode,
65
69
  isRetryableError: () => isRetryableError,
66
70
  isRetryableStatus: () => isRetryableStatus,
67
71
  ok: () => import_neverthrow.ok,
68
72
  okAsync: () => import_neverthrow.okAsync,
73
+ resolveReceptionRoute: () => resolveReceptionRoute,
69
74
  roundHalfUpToCents: () => roundHalfUpToCents,
70
75
  toDgiiError: () => toDgiiError,
71
76
  validateXMLCertificate: () => validateXMLCertificate
@@ -422,9 +427,13 @@ var HttpClient = class {
422
427
  policies;
423
428
  fetchImpl;
424
429
  baseUrl;
430
+ beforeRequest;
431
+ onUnauthorized;
425
432
  constructor(options) {
426
433
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
427
434
  this.token = options.token;
435
+ this.beforeRequest = options.beforeRequest;
436
+ this.onUnauthorized = options.onUnauthorized;
428
437
  this.policies = options.policies ?? createResiliencePolicies({
429
438
  ...options.resilience,
430
439
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
@@ -434,6 +443,9 @@ var HttpClient = class {
434
443
  setToken(token) {
435
444
  this.token = token;
436
445
  }
446
+ getToken() {
447
+ return this.token;
448
+ }
437
449
  clearToken() {
438
450
  this.token = void 0;
439
451
  }
@@ -493,6 +505,19 @@ var HttpClient = class {
493
505
  return (0, import_neverthrow.err)(error);
494
506
  }
495
507
  async request(options) {
508
+ if (!options.skipAuthHooks) {
509
+ await this.beforeRequest?.();
510
+ }
511
+ const first = await this.execute(options);
512
+ if (!options.skipAuthHooks && first.isErr() && first.error.status === 401 && this.onUnauthorized !== void 0) {
513
+ const replayed = await this.onUnauthorized();
514
+ if (replayed) {
515
+ return this.execute(options);
516
+ }
517
+ }
518
+ return first;
519
+ }
520
+ async execute(options) {
496
521
  const url = this.buildUrl(options.path, options.absoluteUrl, options.query);
497
522
  const method = options.method ?? "GET";
498
523
  const headers = this.authHeaders(options.headers);
@@ -521,7 +546,8 @@ var HttpClient = class {
521
546
  path,
522
547
  query,
523
548
  absoluteUrl: opts?.absoluteUrl,
524
- resource: opts?.resource
549
+ resource: opts?.resource,
550
+ skipAuthHooks: opts?.skipAuthHooks
525
551
  });
526
552
  }
527
553
  /**
@@ -537,7 +563,8 @@ var HttpClient = class {
537
563
  body: form,
538
564
  headers: { Accept: "application/json" },
539
565
  absoluteUrl: opts?.absoluteUrl,
540
- resource: opts?.resource
566
+ resource: opts?.resource,
567
+ skipAuthHooks: opts?.skipAuthHooks
541
568
  });
542
569
  }
543
570
  };
@@ -668,22 +695,6 @@ var C14N_INCLUSIVE = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
668
695
  var RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
669
696
  var SHA256 = "http://www.w3.org/2001/04/xmlenc#sha256";
670
697
  var ENVELOPED = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
671
- var CUSTOM_DIGEST_URI = "http://dgii.gov.do/digest/sha256-c14n";
672
- var DgiiDigest = class {
673
- getHash(xml) {
674
- const doc = new import_xmldom.DOMParser().parseFromString(
675
- xml
676
- );
677
- const canonical = new import_xml_crypto.C14nCanonicalization().process(
678
- doc,
679
- {}
680
- );
681
- return import_node_crypto.default.createHash("sha256").update(canonical, "utf8").digest("base64");
682
- }
683
- getAlgorithmName() {
684
- return SHA256;
685
- }
686
- };
687
698
  var parseStrict = (xml) => {
688
699
  const errors = [];
689
700
  const doc = new import_xmldom.DOMParser({
@@ -752,12 +763,10 @@ var Signature = class {
752
763
  signatureAlgorithm: RSA_SHA256,
753
764
  canonicalizationAlgorithm: C14N_INCLUSIVE
754
765
  });
755
- const hashAlgorithms = sig.HashAlgorithms;
756
- hashAlgorithms[CUSTOM_DIGEST_URI] = DgiiDigest;
757
766
  sig.addReference({
758
767
  xpath: `//*[local-name(.)='${elementName}']`,
759
768
  transforms: [ENVELOPED],
760
- digestAlgorithm: CUSTOM_DIGEST_URI,
769
+ digestAlgorithm: SHA256,
761
770
  isEmptyUri: true
762
771
  });
763
772
  cleanNodes(doc);
@@ -903,6 +912,98 @@ var extractElementText = (xml, tag) => {
903
912
  const text = match?.[1]?.trim();
904
913
  return text === void 0 || text === "" ? void 0 : text;
905
914
  };
915
+ var ROOT_PATTERN = /<\s*(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)[\s/>]/;
916
+ var extractRootElementName = (xml) => {
917
+ const stripped = xml.replace(/<\?[\s\S]*?\?>/g, "").replace(/<!--[\s\S]*?-->/g, "").trimStart();
918
+ return ROOT_PATTERN.exec(stripped)?.[1];
919
+ };
920
+
921
+ // src/reception.ts
922
+ var ECF_TYPE_CODES = [
923
+ "31",
924
+ "32",
925
+ "33",
926
+ "34",
927
+ "41",
928
+ "43",
929
+ "44",
930
+ "45",
931
+ "46",
932
+ "47"
933
+ ];
934
+ var CONSUMPTION_SUMMARY_THRESHOLD = "250000";
935
+ var CONSUMPTION_TYPE = "32";
936
+ var GENERAL = {
937
+ kind: "general",
938
+ endpoint: "recepcion/api/FacturasElectronicas" /* SendInvoice */
939
+ };
940
+ var SUMMARY = {
941
+ kind: "summary",
942
+ endpoint: "recepcionfc/api/recepcion/ecf" /* SendSummary */
943
+ };
944
+ var isEcfTypeCode = (value) => ECF_TYPE_CODES.includes(value);
945
+ var DECIMAL_PATTERN = /^\d+(?:\.\d+)?$/;
946
+ var compareDecimal = (a, b) => {
947
+ const [aInt = "", aFrac = ""] = a.split(".");
948
+ const [bInt = "", bFrac = ""] = b.split(".");
949
+ const aWhole = aInt.replace(/^0+(?=\d)/, "");
950
+ const bWhole = bInt.replace(/^0+(?=\d)/, "");
951
+ if (aWhole.length !== bWhole.length) {
952
+ return aWhole.length - bWhole.length;
953
+ }
954
+ if (aWhole !== bWhole) {
955
+ return aWhole < bWhole ? -1 : 1;
956
+ }
957
+ const width = Math.max(aFrac.length, bFrac.length);
958
+ const aPadded = aFrac.padEnd(width, "0");
959
+ const bPadded = bFrac.padEnd(width, "0");
960
+ if (aPadded === bPadded) {
961
+ return 0;
962
+ }
963
+ return aPadded < bPadded ? -1 : 1;
964
+ };
965
+ var validationError = (message) => new DgiiError(message, { code: "VALIDATION" });
966
+ var resolveReceptionRoute = (signedXml) => {
967
+ const root = extractRootElementName(signedXml);
968
+ if (root === "RFCE") {
969
+ return (0, import_neverthrow.ok)(SUMMARY);
970
+ }
971
+ if (root !== "ECF") {
972
+ return (0, import_neverthrow.err)(
973
+ validationError(
974
+ `Unsupported document root "${root ?? "unknown"}"; expected "ECF" or "RFCE"`
975
+ )
976
+ );
977
+ }
978
+ const tipo = extractElementText(signedXml, "TipoeCF");
979
+ if (tipo === void 0) {
980
+ return (0, import_neverthrow.err)(validationError("TipoeCF is missing from the document"));
981
+ }
982
+ if (!isEcfTypeCode(tipo)) {
983
+ return (0, import_neverthrow.err)(validationError(`Unknown TipoeCF "${tipo}"`));
984
+ }
985
+ if (tipo !== CONSUMPTION_TYPE) {
986
+ return (0, import_neverthrow.ok)(GENERAL);
987
+ }
988
+ const total = extractElementText(signedXml, "MontoTotal");
989
+ if (total === void 0) {
990
+ return (0, import_neverthrow.err)(
991
+ validationError(
992
+ "MontoTotal is required to route an E32 consumption invoice"
993
+ )
994
+ );
995
+ }
996
+ if (!DECIMAL_PATTERN.test(total)) {
997
+ return (0, import_neverthrow.err)(
998
+ validationError(`MontoTotal "${total}" is not a decimal amount`)
999
+ );
1000
+ }
1001
+ return compareDecimal(total, CONSUMPTION_SUMMARY_THRESHOLD) < 0 ? (0, import_neverthrow.err)(
1002
+ validationError(
1003
+ "An E32 below RD$250,000 must be sent as an RFCE summary; convert it with convertECF32ToRFCE first"
1004
+ )
1005
+ ) : (0, import_neverthrow.ok)(GENERAL);
1006
+ };
906
1007
 
907
1008
  // src/utils.ts
908
1009
  var getCodeSixDigitfromSignature = (signedXml) => {
@@ -1066,22 +1167,39 @@ var import_node_crypto2 = require("crypto");
1066
1167
  var PENDING_SUBMISSION = { pending: true };
1067
1168
  var isPendingSubmission = (value) => typeof value === "object" && value !== null && value.pending === true;
1068
1169
  var sha256Hex = (value) => (0, import_node_crypto2.createHash)("sha256").update(value, "utf8").digest("hex");
1170
+ var parseExpiry = (value) => {
1171
+ if (value === void 0) {
1172
+ return void 0;
1173
+ }
1174
+ const parsed = Date.parse(value);
1175
+ return Number.isNaN(parsed) ? void 0 : parsed;
1176
+ };
1069
1177
  var ECF = class {
1070
1178
  environment;
1071
1179
  http;
1072
1180
  idempotency;
1073
1181
  credentials;
1074
1182
  inFlight = /* @__PURE__ */ new Map();
1183
+ tokenRefreshSkewMs;
1184
+ now;
1185
+ tokenExpiresAt;
1186
+ refreshPromise;
1075
1187
  constructor(credentials, environment = "TesteCF" /* Dev */, options = {}) {
1076
1188
  this.credentials = credentials;
1077
1189
  this.environment = environment;
1190
+ this.tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 5 * 60 * 1e3;
1191
+ this.now = options.now ?? Date.now;
1078
1192
  this.idempotency = options.idempotencyStore ?? new InMemoryIdempotencyStore();
1079
1193
  this.http = new HttpClient({
1080
1194
  baseUrl: BaseUrl.ECF,
1081
1195
  ...options.token !== void 0 ? { token: options.token } : {},
1082
1196
  ...options.resilience !== void 0 ? { resilience: options.resilience } : {},
1083
1197
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
1084
- ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
1198
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
1199
+ beforeRequest: async () => {
1200
+ await this.ensureToken();
1201
+ },
1202
+ onUnauthorized: () => this.reauthenticate()
1085
1203
  });
1086
1204
  }
1087
1205
  buildIdempotencyKey(signedXml) {
@@ -1133,6 +1251,40 @@ var ECF = class {
1133
1251
  this.inFlight.delete(key);
1134
1252
  }
1135
1253
  }
1254
+ /**
1255
+ * Refresh the token when it is missing or about to expire.
1256
+ *
1257
+ * A pre-supplied `token` has no known expiry, so it is never refreshed
1258
+ * proactively — only a 401 triggers a re-authentication for it.
1259
+ */
1260
+ async ensureToken() {
1261
+ if (this.tokenExpiresAt === void 0) {
1262
+ return (0, import_neverthrow.ok)(void 0);
1263
+ }
1264
+ if (this.now() < this.tokenExpiresAt - this.tokenRefreshSkewMs) {
1265
+ return (0, import_neverthrow.ok)(void 0);
1266
+ }
1267
+ const refreshed = await this.refreshToken();
1268
+ return refreshed.isOk() ? (0, import_neverthrow.ok)(void 0) : (0, import_neverthrow.err)(refreshed.error);
1269
+ }
1270
+ /** Authenticate, coalescing concurrent refreshes into a single round-trip. */
1271
+ refreshToken() {
1272
+ if (this.refreshPromise !== void 0) {
1273
+ return this.refreshPromise;
1274
+ }
1275
+ const promise = this.authenticate().finally(() => {
1276
+ this.refreshPromise = void 0;
1277
+ });
1278
+ this.refreshPromise = promise;
1279
+ return promise;
1280
+ }
1281
+ /** Drop the rejected token and authenticate once. Returns `true` on success. */
1282
+ async reauthenticate() {
1283
+ this.http.clearToken();
1284
+ this.tokenExpiresAt = void 0;
1285
+ const refreshed = await this.refreshToken();
1286
+ return refreshed.isOk();
1287
+ }
1136
1288
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
1137
1289
  async authenticate(buyerHost) {
1138
1290
  const seed = await this.getSeed(buyerHost);
@@ -1159,6 +1311,7 @@ var ECF = class {
1159
1311
  return (0, import_neverthrow.err)(token.error);
1160
1312
  }
1161
1313
  this.http.setToken(token.value.token);
1314
+ this.tokenExpiresAt = parseExpiry(token.value.expira);
1162
1315
  return (0, import_neverthrow.ok)(token.value);
1163
1316
  }
1164
1317
  getSeed(buyerHost) {
@@ -1169,20 +1322,23 @@ var ECF = class {
1169
1322
  );
1170
1323
  return this.http.get(url2, void 0, {
1171
1324
  absoluteUrl: true,
1172
- resource: "fe/autenticacion/api/semilla" /* Seed */
1325
+ resource: "fe/autenticacion/api/semilla" /* Seed */,
1326
+ skipAuthHooks: true
1173
1327
  });
1174
1328
  }
1175
1329
  const url = buildUrl(this.environment, "Autenticacion/api/Autenticacion/Semilla" /* Seed */);
1176
1330
  return this.http.get(url, void 0, {
1177
1331
  absoluteUrl: true,
1178
- resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */
1332
+ resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */,
1333
+ skipAuthHooks: true
1179
1334
  });
1180
1335
  }
1181
1336
  getAuthToken(signedSeed, buyerHost) {
1182
1337
  const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/autenticacion/api/validacioncertificado" /* ValidateSeed */) : buildUrl(this.environment, "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */);
1183
1338
  return this.http.postMultipart(url, "signed.xml", signedSeed, {
1184
1339
  absoluteUrl: true,
1185
- resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */
1340
+ resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */,
1341
+ skipAuthHooks: true
1186
1342
  });
1187
1343
  }
1188
1344
  /**
@@ -1214,6 +1370,32 @@ var ECF = class {
1214
1370
  )
1215
1371
  );
1216
1372
  }
1373
+ /**
1374
+ * Submit a signed document to the endpoint its own content belongs to.
1375
+ *
1376
+ * Reads the root element, `TipoeCF` and `MontoTotal` from the signed XML and
1377
+ * dispatches to {@link sendSummary} for an RFCE or to
1378
+ * {@link sendElectronicDocument} for an ECF. An E32 below RD$250,000 is
1379
+ * rejected because DGII only accepts the summary form for those.
1380
+ */
1381
+ async sendDocument(signedXml, fileName, buyerHost) {
1382
+ const route = resolveReceptionRoute(signedXml);
1383
+ if (route.isErr()) {
1384
+ return (0, import_neverthrow.err)(route.error);
1385
+ }
1386
+ if (route.value.kind === "summary") {
1387
+ if (buyerHost !== void 0) {
1388
+ return (0, import_neverthrow.err)(
1389
+ new DgiiError(
1390
+ "An RFCE summary cannot be delivered directly to a buyer host",
1391
+ { code: "VALIDATION" }
1392
+ )
1393
+ );
1394
+ }
1395
+ return this.sendSummary(signedXml, fileName);
1396
+ }
1397
+ return this.sendElectronicDocument(signedXml, fileName, buyerHost);
1398
+ }
1217
1399
  statusTrackId(trackId) {
1218
1400
  const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
1219
1401
  return this.http.get(
@@ -1308,8 +1490,10 @@ var ECF = class {
1308
1490
  // Annotate the CommonJS export names for ESM import in node:
1309
1491
  0 && (module.exports = {
1310
1492
  BaseUrl,
1493
+ CONSUMPTION_SUMMARY_THRESHOLD,
1311
1494
  DgiiError,
1312
1495
  ECF,
1496
+ ECF_TYPE_CODES,
1313
1497
  Endpoint,
1314
1498
  Environment,
1315
1499
  Err,
@@ -1324,6 +1508,7 @@ var ECF = class {
1324
1508
  Transformer,
1325
1509
  buildSenderReceiverUrl,
1326
1510
  buildUrl,
1511
+ compareDecimal,
1327
1512
  convertECF32ToRFCE,
1328
1513
  createResiliencePolicies,
1329
1514
  defaultResilienceOptions,
@@ -1339,10 +1524,12 @@ var ECF = class {
1339
1524
  getCurrentFormattedDate,
1340
1525
  getCurrentFormattedDateTime,
1341
1526
  getXmlFromBodyResponse,
1527
+ isEcfTypeCode,
1342
1528
  isRetryableError,
1343
1529
  isRetryableStatus,
1344
1530
  ok,
1345
1531
  okAsync,
1532
+ resolveReceptionRoute,
1346
1533
  roundHalfUpToCents,
1347
1534
  toDgiiError,
1348
1535
  validateXMLCertificate