@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 +213 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +88 -2
- package/dist/index.d.ts +88 -2
- package/dist/index.js +208 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|