@deuriib/dgii-ecf 0.4.1 → 0.5.1

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
@@ -67,9 +67,10 @@ var readMensajes = (value) => {
67
67
  if (typeof item === "string" && item.trim()) {
68
68
  out.push({ valor: item.trim(), codigo: 0 });
69
69
  } else if (isObject(item) && typeof item.valor === "string") {
70
+ const codigo = item.codigo;
70
71
  out.push({
71
72
  valor: item.valor,
72
- codigo: typeof item.codigo === "number" ? item.codigo : 0
73
+ codigo: typeof codigo === "number" || typeof codigo === "string" ? codigo : 0
73
74
  });
74
75
  }
75
76
  }
@@ -370,9 +371,13 @@ var HttpClient = class {
370
371
  policies;
371
372
  fetchImpl;
372
373
  baseUrl;
374
+ beforeRequest;
375
+ onUnauthorized;
373
376
  constructor(options) {
374
377
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
375
378
  this.token = options.token;
379
+ this.beforeRequest = options.beforeRequest;
380
+ this.onUnauthorized = options.onUnauthorized;
376
381
  this.policies = options.policies ?? createResiliencePolicies({
377
382
  ...options.resilience,
378
383
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
@@ -382,6 +387,9 @@ var HttpClient = class {
382
387
  setToken(token) {
383
388
  this.token = token;
384
389
  }
390
+ getToken() {
391
+ return this.token;
392
+ }
385
393
  clearToken() {
386
394
  this.token = void 0;
387
395
  }
@@ -441,6 +449,19 @@ var HttpClient = class {
441
449
  return err(error);
442
450
  }
443
451
  async request(options) {
452
+ if (!options.skipAuthHooks) {
453
+ await this.beforeRequest?.();
454
+ }
455
+ const first = await this.execute(options);
456
+ if (!options.skipAuthHooks && first.isErr() && first.error.status === 401 && this.onUnauthorized !== void 0) {
457
+ const replayed = await this.onUnauthorized();
458
+ if (replayed) {
459
+ return this.execute(options);
460
+ }
461
+ }
462
+ return first;
463
+ }
464
+ async execute(options) {
444
465
  const url = this.buildUrl(options.path, options.absoluteUrl, options.query);
445
466
  const method = options.method ?? "GET";
446
467
  const headers = this.authHeaders(options.headers);
@@ -469,7 +490,8 @@ var HttpClient = class {
469
490
  path,
470
491
  query,
471
492
  absoluteUrl: opts?.absoluteUrl,
472
- resource: opts?.resource
493
+ resource: opts?.resource,
494
+ skipAuthHooks: opts?.skipAuthHooks
473
495
  });
474
496
  }
475
497
  /**
@@ -485,7 +507,8 @@ var HttpClient = class {
485
507
  body: form,
486
508
  headers: { Accept: "application/json" },
487
509
  absoluteUrl: opts?.absoluteUrl,
488
- resource: opts?.resource
510
+ resource: opts?.resource,
511
+ skipAuthHooks: opts?.skipAuthHooks
489
512
  });
490
513
  }
491
514
  };
@@ -833,6 +856,98 @@ var extractElementText = (xml, tag) => {
833
856
  const text = match?.[1]?.trim();
834
857
  return text === void 0 || text === "" ? void 0 : text;
835
858
  };
859
+ var ROOT_PATTERN = /<\s*(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)[\s/>]/;
860
+ var extractRootElementName = (xml) => {
861
+ const stripped = xml.replace(/<\?[\s\S]*?\?>/g, "").replace(/<!--[\s\S]*?-->/g, "").trimStart();
862
+ return ROOT_PATTERN.exec(stripped)?.[1];
863
+ };
864
+
865
+ // src/reception.ts
866
+ var ECF_TYPE_CODES = [
867
+ "31",
868
+ "32",
869
+ "33",
870
+ "34",
871
+ "41",
872
+ "43",
873
+ "44",
874
+ "45",
875
+ "46",
876
+ "47"
877
+ ];
878
+ var CONSUMPTION_SUMMARY_THRESHOLD = "250000";
879
+ var CONSUMPTION_TYPE = "32";
880
+ var GENERAL = {
881
+ kind: "general",
882
+ endpoint: "recepcion/api/FacturasElectronicas" /* SendInvoice */
883
+ };
884
+ var SUMMARY = {
885
+ kind: "summary",
886
+ endpoint: "recepcionfc/api/recepcion/ecf" /* SendSummary */
887
+ };
888
+ var isEcfTypeCode = (value) => ECF_TYPE_CODES.includes(value);
889
+ var DECIMAL_PATTERN = /^\d+(?:\.\d+)?$/;
890
+ var compareDecimal = (a, b) => {
891
+ const [aInt = "", aFrac = ""] = a.split(".");
892
+ const [bInt = "", bFrac = ""] = b.split(".");
893
+ const aWhole = aInt.replace(/^0+(?=\d)/, "");
894
+ const bWhole = bInt.replace(/^0+(?=\d)/, "");
895
+ if (aWhole.length !== bWhole.length) {
896
+ return aWhole.length - bWhole.length;
897
+ }
898
+ if (aWhole !== bWhole) {
899
+ return aWhole < bWhole ? -1 : 1;
900
+ }
901
+ const width = Math.max(aFrac.length, bFrac.length);
902
+ const aPadded = aFrac.padEnd(width, "0");
903
+ const bPadded = bFrac.padEnd(width, "0");
904
+ if (aPadded === bPadded) {
905
+ return 0;
906
+ }
907
+ return aPadded < bPadded ? -1 : 1;
908
+ };
909
+ var validationError = (message) => new DgiiError(message, { code: "VALIDATION" });
910
+ var resolveReceptionRoute = (signedXml) => {
911
+ const root = extractRootElementName(signedXml);
912
+ if (root === "RFCE") {
913
+ return ok(SUMMARY);
914
+ }
915
+ if (root !== "ECF") {
916
+ return err(
917
+ validationError(
918
+ `Unsupported document root "${root ?? "unknown"}"; expected "ECF" or "RFCE"`
919
+ )
920
+ );
921
+ }
922
+ const tipo = extractElementText(signedXml, "TipoeCF");
923
+ if (tipo === void 0) {
924
+ return err(validationError("TipoeCF is missing from the document"));
925
+ }
926
+ if (!isEcfTypeCode(tipo)) {
927
+ return err(validationError(`Unknown TipoeCF "${tipo}"`));
928
+ }
929
+ if (tipo !== CONSUMPTION_TYPE) {
930
+ return ok(GENERAL);
931
+ }
932
+ const total = extractElementText(signedXml, "MontoTotal");
933
+ if (total === void 0) {
934
+ return err(
935
+ validationError(
936
+ "MontoTotal is required to route an E32 consumption invoice"
937
+ )
938
+ );
939
+ }
940
+ if (!DECIMAL_PATTERN.test(total)) {
941
+ return err(
942
+ validationError(`MontoTotal "${total}" is not a decimal amount`)
943
+ );
944
+ }
945
+ return compareDecimal(total, CONSUMPTION_SUMMARY_THRESHOLD) < 0 ? err(
946
+ validationError(
947
+ "An E32 below RD$250,000 must be sent as an RFCE summary; convert it with convertECF32ToRFCE first"
948
+ )
949
+ ) : ok(GENERAL);
950
+ };
836
951
 
837
952
  // src/utils.ts
838
953
  var getCodeSixDigitfromSignature = (signedXml) => {
@@ -996,22 +1111,39 @@ import { createHash } from "crypto";
996
1111
  var PENDING_SUBMISSION = { pending: true };
997
1112
  var isPendingSubmission = (value) => typeof value === "object" && value !== null && value.pending === true;
998
1113
  var sha256Hex = (value) => createHash("sha256").update(value, "utf8").digest("hex");
1114
+ var parseExpiry = (value) => {
1115
+ if (value === void 0) {
1116
+ return void 0;
1117
+ }
1118
+ const parsed = Date.parse(value);
1119
+ return Number.isNaN(parsed) ? void 0 : parsed;
1120
+ };
999
1121
  var ECF = class {
1000
1122
  environment;
1001
1123
  http;
1002
1124
  idempotency;
1003
1125
  credentials;
1004
1126
  inFlight = /* @__PURE__ */ new Map();
1127
+ tokenRefreshSkewMs;
1128
+ now;
1129
+ tokenExpiresAt;
1130
+ refreshPromise;
1005
1131
  constructor(credentials, environment = "TesteCF" /* Dev */, options = {}) {
1006
1132
  this.credentials = credentials;
1007
1133
  this.environment = environment;
1134
+ this.tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 5 * 60 * 1e3;
1135
+ this.now = options.now ?? Date.now;
1008
1136
  this.idempotency = options.idempotencyStore ?? new InMemoryIdempotencyStore();
1009
1137
  this.http = new HttpClient({
1010
1138
  baseUrl: BaseUrl.ECF,
1011
1139
  ...options.token !== void 0 ? { token: options.token } : {},
1012
1140
  ...options.resilience !== void 0 ? { resilience: options.resilience } : {},
1013
1141
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
1014
- ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
1142
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
1143
+ beforeRequest: async () => {
1144
+ await this.ensureToken();
1145
+ },
1146
+ onUnauthorized: () => this.reauthenticate()
1015
1147
  });
1016
1148
  }
1017
1149
  buildIdempotencyKey(signedXml) {
@@ -1063,6 +1195,40 @@ var ECF = class {
1063
1195
  this.inFlight.delete(key);
1064
1196
  }
1065
1197
  }
1198
+ /**
1199
+ * Refresh the token when it is missing or about to expire.
1200
+ *
1201
+ * A pre-supplied `token` has no known expiry, so it is never refreshed
1202
+ * proactively — only a 401 triggers a re-authentication for it.
1203
+ */
1204
+ async ensureToken() {
1205
+ if (this.tokenExpiresAt === void 0) {
1206
+ return ok(void 0);
1207
+ }
1208
+ if (this.now() < this.tokenExpiresAt - this.tokenRefreshSkewMs) {
1209
+ return ok(void 0);
1210
+ }
1211
+ const refreshed = await this.refreshToken();
1212
+ return refreshed.isOk() ? ok(void 0) : err(refreshed.error);
1213
+ }
1214
+ /** Authenticate, coalescing concurrent refreshes into a single round-trip. */
1215
+ refreshToken() {
1216
+ if (this.refreshPromise !== void 0) {
1217
+ return this.refreshPromise;
1218
+ }
1219
+ const promise = this.authenticate().finally(() => {
1220
+ this.refreshPromise = void 0;
1221
+ });
1222
+ this.refreshPromise = promise;
1223
+ return promise;
1224
+ }
1225
+ /** Drop the rejected token and authenticate once. Returns `true` on success. */
1226
+ async reauthenticate() {
1227
+ this.http.clearToken();
1228
+ this.tokenExpiresAt = void 0;
1229
+ const refreshed = await this.refreshToken();
1230
+ return refreshed.isOk();
1231
+ }
1066
1232
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
1067
1233
  async authenticate(buyerHost) {
1068
1234
  const seed = await this.getSeed(buyerHost);
@@ -1089,6 +1255,7 @@ var ECF = class {
1089
1255
  return err(token.error);
1090
1256
  }
1091
1257
  this.http.setToken(token.value.token);
1258
+ this.tokenExpiresAt = parseExpiry(token.value.expira);
1092
1259
  return ok(token.value);
1093
1260
  }
1094
1261
  getSeed(buyerHost) {
@@ -1099,20 +1266,23 @@ var ECF = class {
1099
1266
  );
1100
1267
  return this.http.get(url2, void 0, {
1101
1268
  absoluteUrl: true,
1102
- resource: "fe/autenticacion/api/semilla" /* Seed */
1269
+ resource: "fe/autenticacion/api/semilla" /* Seed */,
1270
+ skipAuthHooks: true
1103
1271
  });
1104
1272
  }
1105
1273
  const url = buildUrl(this.environment, "Autenticacion/api/Autenticacion/Semilla" /* Seed */);
1106
1274
  return this.http.get(url, void 0, {
1107
1275
  absoluteUrl: true,
1108
- resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */
1276
+ resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */,
1277
+ skipAuthHooks: true
1109
1278
  });
1110
1279
  }
1111
1280
  getAuthToken(signedSeed, buyerHost) {
1112
1281
  const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/autenticacion/api/validacioncertificado" /* ValidateSeed */) : buildUrl(this.environment, "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */);
1113
1282
  return this.http.postMultipart(url, "signed.xml", signedSeed, {
1114
1283
  absoluteUrl: true,
1115
- resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */
1284
+ resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */,
1285
+ skipAuthHooks: true
1116
1286
  });
1117
1287
  }
1118
1288
  /**
@@ -1144,6 +1314,32 @@ var ECF = class {
1144
1314
  )
1145
1315
  );
1146
1316
  }
1317
+ /**
1318
+ * Submit a signed document to the endpoint its own content belongs to.
1319
+ *
1320
+ * Reads the root element, `TipoeCF` and `MontoTotal` from the signed XML and
1321
+ * dispatches to {@link sendSummary} for an RFCE or to
1322
+ * {@link sendElectronicDocument} for an ECF. An E32 below RD$250,000 is
1323
+ * rejected because DGII only accepts the summary form for those.
1324
+ */
1325
+ async sendDocument(signedXml, fileName, buyerHost) {
1326
+ const route = resolveReceptionRoute(signedXml);
1327
+ if (route.isErr()) {
1328
+ return err(route.error);
1329
+ }
1330
+ if (route.value.kind === "summary") {
1331
+ if (buyerHost !== void 0) {
1332
+ return err(
1333
+ new DgiiError(
1334
+ "An RFCE summary cannot be delivered directly to a buyer host",
1335
+ { code: "VALIDATION" }
1336
+ )
1337
+ );
1338
+ }
1339
+ return this.sendSummary(signedXml, fileName);
1340
+ }
1341
+ return this.sendElectronicDocument(signedXml, fileName, buyerHost);
1342
+ }
1147
1343
  statusTrackId(trackId) {
1148
1344
  const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
1149
1345
  return this.http.get(
@@ -1237,8 +1433,10 @@ var ECF = class {
1237
1433
  };
1238
1434
  export {
1239
1435
  BaseUrl,
1436
+ CONSUMPTION_SUMMARY_THRESHOLD,
1240
1437
  DgiiError,
1241
1438
  ECF,
1439
+ ECF_TYPE_CODES,
1242
1440
  Endpoint,
1243
1441
  Environment,
1244
1442
  Err,
@@ -1253,6 +1451,7 @@ export {
1253
1451
  Transformer,
1254
1452
  buildSenderReceiverUrl,
1255
1453
  buildUrl,
1454
+ compareDecimal,
1256
1455
  convertECF32ToRFCE,
1257
1456
  createResiliencePolicies,
1258
1457
  defaultResilienceOptions,
@@ -1268,10 +1467,12 @@ export {
1268
1467
  getCurrentFormattedDate,
1269
1468
  getCurrentFormattedDateTime,
1270
1469
  getXmlFromBodyResponse,
1470
+ isEcfTypeCode,
1271
1471
  isRetryableError,
1272
1472
  isRetryableStatus,
1273
1473
  ok,
1274
1474
  okAsync,
1475
+ resolveReceptionRoute,
1275
1476
  roundHalfUpToCents,
1276
1477
  toDgiiError,
1277
1478
  validateXMLCertificate