@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.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
@@ -141,9 +146,10 @@ var readMensajes = (value) => {
141
146
  if (typeof item === "string" && item.trim()) {
142
147
  out.push({ valor: item.trim(), codigo: 0 });
143
148
  } else if (isObject(item) && typeof item.valor === "string") {
149
+ const codigo = item.codigo;
144
150
  out.push({
145
151
  valor: item.valor,
146
- codigo: typeof item.codigo === "number" ? item.codigo : 0
152
+ codigo: typeof codigo === "number" || typeof codigo === "string" ? codigo : 0
147
153
  });
148
154
  }
149
155
  }
@@ -422,9 +428,13 @@ var HttpClient = class {
422
428
  policies;
423
429
  fetchImpl;
424
430
  baseUrl;
431
+ beforeRequest;
432
+ onUnauthorized;
425
433
  constructor(options) {
426
434
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
427
435
  this.token = options.token;
436
+ this.beforeRequest = options.beforeRequest;
437
+ this.onUnauthorized = options.onUnauthorized;
428
438
  this.policies = options.policies ?? createResiliencePolicies({
429
439
  ...options.resilience,
430
440
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
@@ -434,6 +444,9 @@ var HttpClient = class {
434
444
  setToken(token) {
435
445
  this.token = token;
436
446
  }
447
+ getToken() {
448
+ return this.token;
449
+ }
437
450
  clearToken() {
438
451
  this.token = void 0;
439
452
  }
@@ -493,6 +506,19 @@ var HttpClient = class {
493
506
  return (0, import_neverthrow.err)(error);
494
507
  }
495
508
  async request(options) {
509
+ if (!options.skipAuthHooks) {
510
+ await this.beforeRequest?.();
511
+ }
512
+ const first = await this.execute(options);
513
+ if (!options.skipAuthHooks && first.isErr() && first.error.status === 401 && this.onUnauthorized !== void 0) {
514
+ const replayed = await this.onUnauthorized();
515
+ if (replayed) {
516
+ return this.execute(options);
517
+ }
518
+ }
519
+ return first;
520
+ }
521
+ async execute(options) {
496
522
  const url = this.buildUrl(options.path, options.absoluteUrl, options.query);
497
523
  const method = options.method ?? "GET";
498
524
  const headers = this.authHeaders(options.headers);
@@ -521,7 +547,8 @@ var HttpClient = class {
521
547
  path,
522
548
  query,
523
549
  absoluteUrl: opts?.absoluteUrl,
524
- resource: opts?.resource
550
+ resource: opts?.resource,
551
+ skipAuthHooks: opts?.skipAuthHooks
525
552
  });
526
553
  }
527
554
  /**
@@ -537,7 +564,8 @@ var HttpClient = class {
537
564
  body: form,
538
565
  headers: { Accept: "application/json" },
539
566
  absoluteUrl: opts?.absoluteUrl,
540
- resource: opts?.resource
567
+ resource: opts?.resource,
568
+ skipAuthHooks: opts?.skipAuthHooks
541
569
  });
542
570
  }
543
571
  };
@@ -885,6 +913,98 @@ var extractElementText = (xml, tag) => {
885
913
  const text = match?.[1]?.trim();
886
914
  return text === void 0 || text === "" ? void 0 : text;
887
915
  };
916
+ var ROOT_PATTERN = /<\s*(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)[\s/>]/;
917
+ var extractRootElementName = (xml) => {
918
+ const stripped = xml.replace(/<\?[\s\S]*?\?>/g, "").replace(/<!--[\s\S]*?-->/g, "").trimStart();
919
+ return ROOT_PATTERN.exec(stripped)?.[1];
920
+ };
921
+
922
+ // src/reception.ts
923
+ var ECF_TYPE_CODES = [
924
+ "31",
925
+ "32",
926
+ "33",
927
+ "34",
928
+ "41",
929
+ "43",
930
+ "44",
931
+ "45",
932
+ "46",
933
+ "47"
934
+ ];
935
+ var CONSUMPTION_SUMMARY_THRESHOLD = "250000";
936
+ var CONSUMPTION_TYPE = "32";
937
+ var GENERAL = {
938
+ kind: "general",
939
+ endpoint: "recepcion/api/FacturasElectronicas" /* SendInvoice */
940
+ };
941
+ var SUMMARY = {
942
+ kind: "summary",
943
+ endpoint: "recepcionfc/api/recepcion/ecf" /* SendSummary */
944
+ };
945
+ var isEcfTypeCode = (value) => ECF_TYPE_CODES.includes(value);
946
+ var DECIMAL_PATTERN = /^\d+(?:\.\d+)?$/;
947
+ var compareDecimal = (a, b) => {
948
+ const [aInt = "", aFrac = ""] = a.split(".");
949
+ const [bInt = "", bFrac = ""] = b.split(".");
950
+ const aWhole = aInt.replace(/^0+(?=\d)/, "");
951
+ const bWhole = bInt.replace(/^0+(?=\d)/, "");
952
+ if (aWhole.length !== bWhole.length) {
953
+ return aWhole.length - bWhole.length;
954
+ }
955
+ if (aWhole !== bWhole) {
956
+ return aWhole < bWhole ? -1 : 1;
957
+ }
958
+ const width = Math.max(aFrac.length, bFrac.length);
959
+ const aPadded = aFrac.padEnd(width, "0");
960
+ const bPadded = bFrac.padEnd(width, "0");
961
+ if (aPadded === bPadded) {
962
+ return 0;
963
+ }
964
+ return aPadded < bPadded ? -1 : 1;
965
+ };
966
+ var validationError = (message) => new DgiiError(message, { code: "VALIDATION" });
967
+ var resolveReceptionRoute = (signedXml) => {
968
+ const root = extractRootElementName(signedXml);
969
+ if (root === "RFCE") {
970
+ return (0, import_neverthrow.ok)(SUMMARY);
971
+ }
972
+ if (root !== "ECF") {
973
+ return (0, import_neverthrow.err)(
974
+ validationError(
975
+ `Unsupported document root "${root ?? "unknown"}"; expected "ECF" or "RFCE"`
976
+ )
977
+ );
978
+ }
979
+ const tipo = extractElementText(signedXml, "TipoeCF");
980
+ if (tipo === void 0) {
981
+ return (0, import_neverthrow.err)(validationError("TipoeCF is missing from the document"));
982
+ }
983
+ if (!isEcfTypeCode(tipo)) {
984
+ return (0, import_neverthrow.err)(validationError(`Unknown TipoeCF "${tipo}"`));
985
+ }
986
+ if (tipo !== CONSUMPTION_TYPE) {
987
+ return (0, import_neverthrow.ok)(GENERAL);
988
+ }
989
+ const total = extractElementText(signedXml, "MontoTotal");
990
+ if (total === void 0) {
991
+ return (0, import_neverthrow.err)(
992
+ validationError(
993
+ "MontoTotal is required to route an E32 consumption invoice"
994
+ )
995
+ );
996
+ }
997
+ if (!DECIMAL_PATTERN.test(total)) {
998
+ return (0, import_neverthrow.err)(
999
+ validationError(`MontoTotal "${total}" is not a decimal amount`)
1000
+ );
1001
+ }
1002
+ return compareDecimal(total, CONSUMPTION_SUMMARY_THRESHOLD) < 0 ? (0, import_neverthrow.err)(
1003
+ validationError(
1004
+ "An E32 below RD$250,000 must be sent as an RFCE summary; convert it with convertECF32ToRFCE first"
1005
+ )
1006
+ ) : (0, import_neverthrow.ok)(GENERAL);
1007
+ };
888
1008
 
889
1009
  // src/utils.ts
890
1010
  var getCodeSixDigitfromSignature = (signedXml) => {
@@ -1048,22 +1168,39 @@ var import_node_crypto2 = require("crypto");
1048
1168
  var PENDING_SUBMISSION = { pending: true };
1049
1169
  var isPendingSubmission = (value) => typeof value === "object" && value !== null && value.pending === true;
1050
1170
  var sha256Hex = (value) => (0, import_node_crypto2.createHash)("sha256").update(value, "utf8").digest("hex");
1171
+ var parseExpiry = (value) => {
1172
+ if (value === void 0) {
1173
+ return void 0;
1174
+ }
1175
+ const parsed = Date.parse(value);
1176
+ return Number.isNaN(parsed) ? void 0 : parsed;
1177
+ };
1051
1178
  var ECF = class {
1052
1179
  environment;
1053
1180
  http;
1054
1181
  idempotency;
1055
1182
  credentials;
1056
1183
  inFlight = /* @__PURE__ */ new Map();
1184
+ tokenRefreshSkewMs;
1185
+ now;
1186
+ tokenExpiresAt;
1187
+ refreshPromise;
1057
1188
  constructor(credentials, environment = "TesteCF" /* Dev */, options = {}) {
1058
1189
  this.credentials = credentials;
1059
1190
  this.environment = environment;
1191
+ this.tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 5 * 60 * 1e3;
1192
+ this.now = options.now ?? Date.now;
1060
1193
  this.idempotency = options.idempotencyStore ?? new InMemoryIdempotencyStore();
1061
1194
  this.http = new HttpClient({
1062
1195
  baseUrl: BaseUrl.ECF,
1063
1196
  ...options.token !== void 0 ? { token: options.token } : {},
1064
1197
  ...options.resilience !== void 0 ? { resilience: options.resilience } : {},
1065
1198
  ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
1066
- ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
1199
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
1200
+ beforeRequest: async () => {
1201
+ await this.ensureToken();
1202
+ },
1203
+ onUnauthorized: () => this.reauthenticate()
1067
1204
  });
1068
1205
  }
1069
1206
  buildIdempotencyKey(signedXml) {
@@ -1115,6 +1252,40 @@ var ECF = class {
1115
1252
  this.inFlight.delete(key);
1116
1253
  }
1117
1254
  }
1255
+ /**
1256
+ * Refresh the token when it is missing or about to expire.
1257
+ *
1258
+ * A pre-supplied `token` has no known expiry, so it is never refreshed
1259
+ * proactively — only a 401 triggers a re-authentication for it.
1260
+ */
1261
+ async ensureToken() {
1262
+ if (this.tokenExpiresAt === void 0) {
1263
+ return (0, import_neverthrow.ok)(void 0);
1264
+ }
1265
+ if (this.now() < this.tokenExpiresAt - this.tokenRefreshSkewMs) {
1266
+ return (0, import_neverthrow.ok)(void 0);
1267
+ }
1268
+ const refreshed = await this.refreshToken();
1269
+ return refreshed.isOk() ? (0, import_neverthrow.ok)(void 0) : (0, import_neverthrow.err)(refreshed.error);
1270
+ }
1271
+ /** Authenticate, coalescing concurrent refreshes into a single round-trip. */
1272
+ refreshToken() {
1273
+ if (this.refreshPromise !== void 0) {
1274
+ return this.refreshPromise;
1275
+ }
1276
+ const promise = this.authenticate().finally(() => {
1277
+ this.refreshPromise = void 0;
1278
+ });
1279
+ this.refreshPromise = promise;
1280
+ return promise;
1281
+ }
1282
+ /** Drop the rejected token and authenticate once. Returns `true` on success. */
1283
+ async reauthenticate() {
1284
+ this.http.clearToken();
1285
+ this.tokenExpiresAt = void 0;
1286
+ const refreshed = await this.refreshToken();
1287
+ return refreshed.isOk();
1288
+ }
1118
1289
  /** Authenticate against DGII (or a buyer host) and store the bearer token. */
1119
1290
  async authenticate(buyerHost) {
1120
1291
  const seed = await this.getSeed(buyerHost);
@@ -1141,6 +1312,7 @@ var ECF = class {
1141
1312
  return (0, import_neverthrow.err)(token.error);
1142
1313
  }
1143
1314
  this.http.setToken(token.value.token);
1315
+ this.tokenExpiresAt = parseExpiry(token.value.expira);
1144
1316
  return (0, import_neverthrow.ok)(token.value);
1145
1317
  }
1146
1318
  getSeed(buyerHost) {
@@ -1151,20 +1323,23 @@ var ECF = class {
1151
1323
  );
1152
1324
  return this.http.get(url2, void 0, {
1153
1325
  absoluteUrl: true,
1154
- resource: "fe/autenticacion/api/semilla" /* Seed */
1326
+ resource: "fe/autenticacion/api/semilla" /* Seed */,
1327
+ skipAuthHooks: true
1155
1328
  });
1156
1329
  }
1157
1330
  const url = buildUrl(this.environment, "Autenticacion/api/Autenticacion/Semilla" /* Seed */);
1158
1331
  return this.http.get(url, void 0, {
1159
1332
  absoluteUrl: true,
1160
- resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */
1333
+ resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */,
1334
+ skipAuthHooks: true
1161
1335
  });
1162
1336
  }
1163
1337
  getAuthToken(signedSeed, buyerHost) {
1164
1338
  const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/autenticacion/api/validacioncertificado" /* ValidateSeed */) : buildUrl(this.environment, "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */);
1165
1339
  return this.http.postMultipart(url, "signed.xml", signedSeed, {
1166
1340
  absoluteUrl: true,
1167
- resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */
1341
+ resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */,
1342
+ skipAuthHooks: true
1168
1343
  });
1169
1344
  }
1170
1345
  /**
@@ -1196,6 +1371,32 @@ var ECF = class {
1196
1371
  )
1197
1372
  );
1198
1373
  }
1374
+ /**
1375
+ * Submit a signed document to the endpoint its own content belongs to.
1376
+ *
1377
+ * Reads the root element, `TipoeCF` and `MontoTotal` from the signed XML and
1378
+ * dispatches to {@link sendSummary} for an RFCE or to
1379
+ * {@link sendElectronicDocument} for an ECF. An E32 below RD$250,000 is
1380
+ * rejected because DGII only accepts the summary form for those.
1381
+ */
1382
+ async sendDocument(signedXml, fileName, buyerHost) {
1383
+ const route = resolveReceptionRoute(signedXml);
1384
+ if (route.isErr()) {
1385
+ return (0, import_neverthrow.err)(route.error);
1386
+ }
1387
+ if (route.value.kind === "summary") {
1388
+ if (buyerHost !== void 0) {
1389
+ return (0, import_neverthrow.err)(
1390
+ new DgiiError(
1391
+ "An RFCE summary cannot be delivered directly to a buyer host",
1392
+ { code: "VALIDATION" }
1393
+ )
1394
+ );
1395
+ }
1396
+ return this.sendSummary(signedXml, fileName);
1397
+ }
1398
+ return this.sendElectronicDocument(signedXml, fileName, buyerHost);
1399
+ }
1199
1400
  statusTrackId(trackId) {
1200
1401
  const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
1201
1402
  return this.http.get(
@@ -1290,8 +1491,10 @@ var ECF = class {
1290
1491
  // Annotate the CommonJS export names for ESM import in node:
1291
1492
  0 && (module.exports = {
1292
1493
  BaseUrl,
1494
+ CONSUMPTION_SUMMARY_THRESHOLD,
1293
1495
  DgiiError,
1294
1496
  ECF,
1497
+ ECF_TYPE_CODES,
1295
1498
  Endpoint,
1296
1499
  Environment,
1297
1500
  Err,
@@ -1306,6 +1509,7 @@ var ECF = class {
1306
1509
  Transformer,
1307
1510
  buildSenderReceiverUrl,
1308
1511
  buildUrl,
1512
+ compareDecimal,
1309
1513
  convertECF32ToRFCE,
1310
1514
  createResiliencePolicies,
1311
1515
  defaultResilienceOptions,
@@ -1321,10 +1525,12 @@ var ECF = class {
1321
1525
  getCurrentFormattedDate,
1322
1526
  getCurrentFormattedDateTime,
1323
1527
  getXmlFromBodyResponse,
1528
+ isEcfTypeCode,
1324
1529
  isRetryableError,
1325
1530
  isRetryableStatus,
1326
1531
  ok,
1327
1532
  okAsync,
1533
+ resolveReceptionRoute,
1328
1534
  roundHalfUpToCents,
1329
1535
  toDgiiError,
1330
1536
  validateXMLCertificate