@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.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
  };
@@ -885,6 +912,98 @@ var extractElementText = (xml, tag) => {
885
912
  const text = match?.[1]?.trim();
886
913
  return text === void 0 || text === "" ? void 0 : text;
887
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
+ };
888
1007
 
889
1008
  // src/utils.ts
890
1009
  var getCodeSixDigitfromSignature = (signedXml) => {
@@ -1048,22 +1167,39 @@ var import_node_crypto2 = require("crypto");
1048
1167
  var PENDING_SUBMISSION = { pending: true };
1049
1168
  var isPendingSubmission = (value) => typeof value === "object" && value !== null && value.pending === true;
1050
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
+ };
1051
1177
  var ECF = class {
1052
1178
  environment;
1053
1179
  http;
1054
1180
  idempotency;
1055
1181
  credentials;
1056
1182
  inFlight = /* @__PURE__ */ new Map();
1183
+ tokenRefreshSkewMs;
1184
+ now;
1185
+ tokenExpiresAt;
1186
+ refreshPromise;
1057
1187
  constructor(credentials, environment = "TesteCF" /* Dev */, options = {}) {
1058
1188
  this.credentials = credentials;
1059
1189
  this.environment = environment;
1190
+ this.tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 5 * 60 * 1e3;
1191
+ this.now = options.now ?? Date.now;
1060
1192
  this.idempotency = options.idempotencyStore ?? new InMemoryIdempotencyStore();
1061
1193
  this.http = new HttpClient({
1062
1194
  baseUrl: BaseUrl.ECF,
1063
1195
  ...options.token !== void 0 ? { token: options.token } : {},
1064
1196
  ...options.resilience !== void 0 ? { resilience: options.resilience } : {},
1065
1197
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
1066
- ...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()
1067
1203
  });
1068
1204
  }
1069
1205
  buildIdempotencyKey(signedXml) {
@@ -1115,6 +1251,40 @@ var ECF = class {
1115
1251
  this.inFlight.delete(key);
1116
1252
  }
1117
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
+ }
1118
1288
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
1119
1289
  async authenticate(buyerHost) {
1120
1290
  const seed = await this.getSeed(buyerHost);
@@ -1141,6 +1311,7 @@ var ECF = class {
1141
1311
  return (0, import_neverthrow.err)(token.error);
1142
1312
  }
1143
1313
  this.http.setToken(token.value.token);
1314
+ this.tokenExpiresAt = parseExpiry(token.value.expira);
1144
1315
  return (0, import_neverthrow.ok)(token.value);
1145
1316
  }
1146
1317
  getSeed(buyerHost) {
@@ -1151,20 +1322,23 @@ var ECF = class {
1151
1322
  );
1152
1323
  return this.http.get(url2, void 0, {
1153
1324
  absoluteUrl: true,
1154
- resource: "fe/autenticacion/api/semilla" /* Seed */
1325
+ resource: "fe/autenticacion/api/semilla" /* Seed */,
1326
+ skipAuthHooks: true
1155
1327
  });
1156
1328
  }
1157
1329
  const url = buildUrl(this.environment, "Autenticacion/api/Autenticacion/Semilla" /* Seed */);
1158
1330
  return this.http.get(url, void 0, {
1159
1331
  absoluteUrl: true,
1160
- resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */
1332
+ resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */,
1333
+ skipAuthHooks: true
1161
1334
  });
1162
1335
  }
1163
1336
  getAuthToken(signedSeed, buyerHost) {
1164
1337
  const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/autenticacion/api/validacioncertificado" /* ValidateSeed */) : buildUrl(this.environment, "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */);
1165
1338
  return this.http.postMultipart(url, "signed.xml", signedSeed, {
1166
1339
  absoluteUrl: true,
1167
- resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */
1340
+ resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */,
1341
+ skipAuthHooks: true
1168
1342
  });
1169
1343
  }
1170
1344
  /**
@@ -1196,6 +1370,32 @@ var ECF = class {
1196
1370
  )
1197
1371
  );
1198
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
+ }
1199
1399
  statusTrackId(trackId) {
1200
1400
  const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
1201
1401
  return this.http.get(
@@ -1290,8 +1490,10 @@ var ECF = class {
1290
1490
  // Annotate the CommonJS export names for ESM import in node:
1291
1491
  0 && (module.exports = {
1292
1492
  BaseUrl,
1493
+ CONSUMPTION_SUMMARY_THRESHOLD,
1293
1494
  DgiiError,
1294
1495
  ECF,
1496
+ ECF_TYPE_CODES,
1295
1497
  Endpoint,
1296
1498
  Environment,
1297
1499
  Err,
@@ -1306,6 +1508,7 @@ var ECF = class {
1306
1508
  Transformer,
1307
1509
  buildSenderReceiverUrl,
1308
1510
  buildUrl,
1511
+ compareDecimal,
1309
1512
  convertECF32ToRFCE,
1310
1513
  createResiliencePolicies,
1311
1514
  defaultResilienceOptions,
@@ -1321,10 +1524,12 @@ var ECF = class {
1321
1524
  getCurrentFormattedDate,
1322
1525
  getCurrentFormattedDateTime,
1323
1526
  getXmlFromBodyResponse,
1527
+ isEcfTypeCode,
1324
1528
  isRetryableError,
1325
1529
  isRetryableStatus,
1326
1530
  ok,
1327
1531
  okAsync,
1532
+ resolveReceptionRoute,
1328
1533
  roundHalfUpToCents,
1329
1534
  toDgiiError,
1330
1535
  validateXMLCertificate