@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 +212 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +92 -3
- package/dist/index.d.ts +92 -3
- package/dist/index.js +208 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -370,9 +370,13 @@ var HttpClient = class {
|
|
|
370
370
|
policies;
|
|
371
371
|
fetchImpl;
|
|
372
372
|
baseUrl;
|
|
373
|
+
beforeRequest;
|
|
374
|
+
onUnauthorized;
|
|
373
375
|
constructor(options) {
|
|
374
376
|
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
375
377
|
this.token = options.token;
|
|
378
|
+
this.beforeRequest = options.beforeRequest;
|
|
379
|
+
this.onUnauthorized = options.onUnauthorized;
|
|
376
380
|
this.policies = options.policies ?? createResiliencePolicies({
|
|
377
381
|
...options.resilience,
|
|
378
382
|
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
|
|
@@ -382,6 +386,9 @@ var HttpClient = class {
|
|
|
382
386
|
setToken(token) {
|
|
383
387
|
this.token = token;
|
|
384
388
|
}
|
|
389
|
+
getToken() {
|
|
390
|
+
return this.token;
|
|
391
|
+
}
|
|
385
392
|
clearToken() {
|
|
386
393
|
this.token = void 0;
|
|
387
394
|
}
|
|
@@ -441,6 +448,19 @@ var HttpClient = class {
|
|
|
441
448
|
return err(error);
|
|
442
449
|
}
|
|
443
450
|
async request(options) {
|
|
451
|
+
if (!options.skipAuthHooks) {
|
|
452
|
+
await this.beforeRequest?.();
|
|
453
|
+
}
|
|
454
|
+
const first = await this.execute(options);
|
|
455
|
+
if (!options.skipAuthHooks && first.isErr() && first.error.status === 401 && this.onUnauthorized !== void 0) {
|
|
456
|
+
const replayed = await this.onUnauthorized();
|
|
457
|
+
if (replayed) {
|
|
458
|
+
return this.execute(options);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return first;
|
|
462
|
+
}
|
|
463
|
+
async execute(options) {
|
|
444
464
|
const url = this.buildUrl(options.path, options.absoluteUrl, options.query);
|
|
445
465
|
const method = options.method ?? "GET";
|
|
446
466
|
const headers = this.authHeaders(options.headers);
|
|
@@ -469,7 +489,8 @@ var HttpClient = class {
|
|
|
469
489
|
path,
|
|
470
490
|
query,
|
|
471
491
|
absoluteUrl: opts?.absoluteUrl,
|
|
472
|
-
resource: opts?.resource
|
|
492
|
+
resource: opts?.resource,
|
|
493
|
+
skipAuthHooks: opts?.skipAuthHooks
|
|
473
494
|
});
|
|
474
495
|
}
|
|
475
496
|
/**
|
|
@@ -485,7 +506,8 @@ var HttpClient = class {
|
|
|
485
506
|
body: form,
|
|
486
507
|
headers: { Accept: "application/json" },
|
|
487
508
|
absoluteUrl: opts?.absoluteUrl,
|
|
488
|
-
resource: opts?.resource
|
|
509
|
+
resource: opts?.resource,
|
|
510
|
+
skipAuthHooks: opts?.skipAuthHooks
|
|
489
511
|
});
|
|
490
512
|
}
|
|
491
513
|
};
|
|
@@ -611,27 +633,11 @@ var P12Reader = class {
|
|
|
611
633
|
// src/signing.ts
|
|
612
634
|
import crypto from "crypto";
|
|
613
635
|
import { DOMParser } from "@xmldom/xmldom";
|
|
614
|
-
import {
|
|
636
|
+
import { SignedXml } from "xml-crypto";
|
|
615
637
|
var C14N_INCLUSIVE = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
|
|
616
638
|
var RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
|
|
617
639
|
var SHA256 = "http://www.w3.org/2001/04/xmlenc#sha256";
|
|
618
640
|
var ENVELOPED = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
|
|
619
|
-
var CUSTOM_DIGEST_URI = "http://dgii.gov.do/digest/sha256-c14n";
|
|
620
|
-
var DgiiDigest = class {
|
|
621
|
-
getHash(xml) {
|
|
622
|
-
const doc = new DOMParser().parseFromString(
|
|
623
|
-
xml
|
|
624
|
-
);
|
|
625
|
-
const canonical = new C14nCanonicalization().process(
|
|
626
|
-
doc,
|
|
627
|
-
{}
|
|
628
|
-
);
|
|
629
|
-
return crypto.createHash("sha256").update(canonical, "utf8").digest("base64");
|
|
630
|
-
}
|
|
631
|
-
getAlgorithmName() {
|
|
632
|
-
return SHA256;
|
|
633
|
-
}
|
|
634
|
-
};
|
|
635
641
|
var parseStrict = (xml) => {
|
|
636
642
|
const errors = [];
|
|
637
643
|
const doc = new DOMParser({
|
|
@@ -700,12 +706,10 @@ var Signature = class {
|
|
|
700
706
|
signatureAlgorithm: RSA_SHA256,
|
|
701
707
|
canonicalizationAlgorithm: C14N_INCLUSIVE
|
|
702
708
|
});
|
|
703
|
-
const hashAlgorithms = sig.HashAlgorithms;
|
|
704
|
-
hashAlgorithms[CUSTOM_DIGEST_URI] = DgiiDigest;
|
|
705
709
|
sig.addReference({
|
|
706
710
|
xpath: `//*[local-name(.)='${elementName}']`,
|
|
707
711
|
transforms: [ENVELOPED],
|
|
708
|
-
digestAlgorithm:
|
|
712
|
+
digestAlgorithm: SHA256,
|
|
709
713
|
isEmptyUri: true
|
|
710
714
|
});
|
|
711
715
|
cleanNodes(doc);
|
|
@@ -851,6 +855,98 @@ var extractElementText = (xml, tag) => {
|
|
|
851
855
|
const text = match?.[1]?.trim();
|
|
852
856
|
return text === void 0 || text === "" ? void 0 : text;
|
|
853
857
|
};
|
|
858
|
+
var ROOT_PATTERN = /<\s*(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)[\s/>]/;
|
|
859
|
+
var extractRootElementName = (xml) => {
|
|
860
|
+
const stripped = xml.replace(/<\?[\s\S]*?\?>/g, "").replace(/<!--[\s\S]*?-->/g, "").trimStart();
|
|
861
|
+
return ROOT_PATTERN.exec(stripped)?.[1];
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
// src/reception.ts
|
|
865
|
+
var ECF_TYPE_CODES = [
|
|
866
|
+
"31",
|
|
867
|
+
"32",
|
|
868
|
+
"33",
|
|
869
|
+
"34",
|
|
870
|
+
"41",
|
|
871
|
+
"43",
|
|
872
|
+
"44",
|
|
873
|
+
"45",
|
|
874
|
+
"46",
|
|
875
|
+
"47"
|
|
876
|
+
];
|
|
877
|
+
var CONSUMPTION_SUMMARY_THRESHOLD = "250000";
|
|
878
|
+
var CONSUMPTION_TYPE = "32";
|
|
879
|
+
var GENERAL = {
|
|
880
|
+
kind: "general",
|
|
881
|
+
endpoint: "recepcion/api/FacturasElectronicas" /* SendInvoice */
|
|
882
|
+
};
|
|
883
|
+
var SUMMARY = {
|
|
884
|
+
kind: "summary",
|
|
885
|
+
endpoint: "recepcionfc/api/recepcion/ecf" /* SendSummary */
|
|
886
|
+
};
|
|
887
|
+
var isEcfTypeCode = (value) => ECF_TYPE_CODES.includes(value);
|
|
888
|
+
var DECIMAL_PATTERN = /^\d+(?:\.\d+)?$/;
|
|
889
|
+
var compareDecimal = (a, b) => {
|
|
890
|
+
const [aInt = "", aFrac = ""] = a.split(".");
|
|
891
|
+
const [bInt = "", bFrac = ""] = b.split(".");
|
|
892
|
+
const aWhole = aInt.replace(/^0+(?=\d)/, "");
|
|
893
|
+
const bWhole = bInt.replace(/^0+(?=\d)/, "");
|
|
894
|
+
if (aWhole.length !== bWhole.length) {
|
|
895
|
+
return aWhole.length - bWhole.length;
|
|
896
|
+
}
|
|
897
|
+
if (aWhole !== bWhole) {
|
|
898
|
+
return aWhole < bWhole ? -1 : 1;
|
|
899
|
+
}
|
|
900
|
+
const width = Math.max(aFrac.length, bFrac.length);
|
|
901
|
+
const aPadded = aFrac.padEnd(width, "0");
|
|
902
|
+
const bPadded = bFrac.padEnd(width, "0");
|
|
903
|
+
if (aPadded === bPadded) {
|
|
904
|
+
return 0;
|
|
905
|
+
}
|
|
906
|
+
return aPadded < bPadded ? -1 : 1;
|
|
907
|
+
};
|
|
908
|
+
var validationError = (message) => new DgiiError(message, { code: "VALIDATION" });
|
|
909
|
+
var resolveReceptionRoute = (signedXml) => {
|
|
910
|
+
const root = extractRootElementName(signedXml);
|
|
911
|
+
if (root === "RFCE") {
|
|
912
|
+
return ok(SUMMARY);
|
|
913
|
+
}
|
|
914
|
+
if (root !== "ECF") {
|
|
915
|
+
return err(
|
|
916
|
+
validationError(
|
|
917
|
+
`Unsupported document root "${root ?? "unknown"}"; expected "ECF" or "RFCE"`
|
|
918
|
+
)
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
const tipo = extractElementText(signedXml, "TipoeCF");
|
|
922
|
+
if (tipo === void 0) {
|
|
923
|
+
return err(validationError("TipoeCF is missing from the document"));
|
|
924
|
+
}
|
|
925
|
+
if (!isEcfTypeCode(tipo)) {
|
|
926
|
+
return err(validationError(`Unknown TipoeCF "${tipo}"`));
|
|
927
|
+
}
|
|
928
|
+
if (tipo !== CONSUMPTION_TYPE) {
|
|
929
|
+
return ok(GENERAL);
|
|
930
|
+
}
|
|
931
|
+
const total = extractElementText(signedXml, "MontoTotal");
|
|
932
|
+
if (total === void 0) {
|
|
933
|
+
return err(
|
|
934
|
+
validationError(
|
|
935
|
+
"MontoTotal is required to route an E32 consumption invoice"
|
|
936
|
+
)
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
if (!DECIMAL_PATTERN.test(total)) {
|
|
940
|
+
return err(
|
|
941
|
+
validationError(`MontoTotal "${total}" is not a decimal amount`)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
return compareDecimal(total, CONSUMPTION_SUMMARY_THRESHOLD) < 0 ? err(
|
|
945
|
+
validationError(
|
|
946
|
+
"An E32 below RD$250,000 must be sent as an RFCE summary; convert it with convertECF32ToRFCE first"
|
|
947
|
+
)
|
|
948
|
+
) : ok(GENERAL);
|
|
949
|
+
};
|
|
854
950
|
|
|
855
951
|
// src/utils.ts
|
|
856
952
|
var getCodeSixDigitfromSignature = (signedXml) => {
|
|
@@ -1014,22 +1110,39 @@ import { createHash } from "crypto";
|
|
|
1014
1110
|
var PENDING_SUBMISSION = { pending: true };
|
|
1015
1111
|
var isPendingSubmission = (value) => typeof value === "object" && value !== null && value.pending === true;
|
|
1016
1112
|
var sha256Hex = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
1113
|
+
var parseExpiry = (value) => {
|
|
1114
|
+
if (value === void 0) {
|
|
1115
|
+
return void 0;
|
|
1116
|
+
}
|
|
1117
|
+
const parsed = Date.parse(value);
|
|
1118
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
1119
|
+
};
|
|
1017
1120
|
var ECF = class {
|
|
1018
1121
|
environment;
|
|
1019
1122
|
http;
|
|
1020
1123
|
idempotency;
|
|
1021
1124
|
credentials;
|
|
1022
1125
|
inFlight = /* @__PURE__ */ new Map();
|
|
1126
|
+
tokenRefreshSkewMs;
|
|
1127
|
+
now;
|
|
1128
|
+
tokenExpiresAt;
|
|
1129
|
+
refreshPromise;
|
|
1023
1130
|
constructor(credentials, environment = "TesteCF" /* Dev */, options = {}) {
|
|
1024
1131
|
this.credentials = credentials;
|
|
1025
1132
|
this.environment = environment;
|
|
1133
|
+
this.tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 5 * 60 * 1e3;
|
|
1134
|
+
this.now = options.now ?? Date.now;
|
|
1026
1135
|
this.idempotency = options.idempotencyStore ?? new InMemoryIdempotencyStore();
|
|
1027
1136
|
this.http = new HttpClient({
|
|
1028
1137
|
baseUrl: BaseUrl.ECF,
|
|
1029
1138
|
...options.token !== void 0 ? { token: options.token } : {},
|
|
1030
1139
|
...options.resilience !== void 0 ? { resilience: options.resilience } : {},
|
|
1031
1140
|
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
|
|
1032
|
-
...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
|
|
1141
|
+
...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
|
|
1142
|
+
beforeRequest: async () => {
|
|
1143
|
+
await this.ensureToken();
|
|
1144
|
+
},
|
|
1145
|
+
onUnauthorized: () => this.reauthenticate()
|
|
1033
1146
|
});
|
|
1034
1147
|
}
|
|
1035
1148
|
buildIdempotencyKey(signedXml) {
|
|
@@ -1081,6 +1194,40 @@ var ECF = class {
|
|
|
1081
1194
|
this.inFlight.delete(key);
|
|
1082
1195
|
}
|
|
1083
1196
|
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Refresh the token when it is missing or about to expire.
|
|
1199
|
+
*
|
|
1200
|
+
* A pre-supplied `token` has no known expiry, so it is never refreshed
|
|
1201
|
+
* proactively — only a 401 triggers a re-authentication for it.
|
|
1202
|
+
*/
|
|
1203
|
+
async ensureToken() {
|
|
1204
|
+
if (this.tokenExpiresAt === void 0) {
|
|
1205
|
+
return ok(void 0);
|
|
1206
|
+
}
|
|
1207
|
+
if (this.now() < this.tokenExpiresAt - this.tokenRefreshSkewMs) {
|
|
1208
|
+
return ok(void 0);
|
|
1209
|
+
}
|
|
1210
|
+
const refreshed = await this.refreshToken();
|
|
1211
|
+
return refreshed.isOk() ? ok(void 0) : err(refreshed.error);
|
|
1212
|
+
}
|
|
1213
|
+
/** Authenticate, coalescing concurrent refreshes into a single round-trip. */
|
|
1214
|
+
refreshToken() {
|
|
1215
|
+
if (this.refreshPromise !== void 0) {
|
|
1216
|
+
return this.refreshPromise;
|
|
1217
|
+
}
|
|
1218
|
+
const promise = this.authenticate().finally(() => {
|
|
1219
|
+
this.refreshPromise = void 0;
|
|
1220
|
+
});
|
|
1221
|
+
this.refreshPromise = promise;
|
|
1222
|
+
return promise;
|
|
1223
|
+
}
|
|
1224
|
+
/** Drop the rejected token and authenticate once. Returns `true` on success. */
|
|
1225
|
+
async reauthenticate() {
|
|
1226
|
+
this.http.clearToken();
|
|
1227
|
+
this.tokenExpiresAt = void 0;
|
|
1228
|
+
const refreshed = await this.refreshToken();
|
|
1229
|
+
return refreshed.isOk();
|
|
1230
|
+
}
|
|
1084
1231
|
/** Authenticate against DGII (or a buyer host) and store the bearer token. */
|
|
1085
1232
|
async authenticate(buyerHost) {
|
|
1086
1233
|
const seed = await this.getSeed(buyerHost);
|
|
@@ -1107,6 +1254,7 @@ var ECF = class {
|
|
|
1107
1254
|
return err(token.error);
|
|
1108
1255
|
}
|
|
1109
1256
|
this.http.setToken(token.value.token);
|
|
1257
|
+
this.tokenExpiresAt = parseExpiry(token.value.expira);
|
|
1110
1258
|
return ok(token.value);
|
|
1111
1259
|
}
|
|
1112
1260
|
getSeed(buyerHost) {
|
|
@@ -1117,20 +1265,23 @@ var ECF = class {
|
|
|
1117
1265
|
);
|
|
1118
1266
|
return this.http.get(url2, void 0, {
|
|
1119
1267
|
absoluteUrl: true,
|
|
1120
|
-
resource: "fe/autenticacion/api/semilla" /* Seed
|
|
1268
|
+
resource: "fe/autenticacion/api/semilla" /* Seed */,
|
|
1269
|
+
skipAuthHooks: true
|
|
1121
1270
|
});
|
|
1122
1271
|
}
|
|
1123
1272
|
const url = buildUrl(this.environment, "Autenticacion/api/Autenticacion/Semilla" /* Seed */);
|
|
1124
1273
|
return this.http.get(url, void 0, {
|
|
1125
1274
|
absoluteUrl: true,
|
|
1126
|
-
resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed
|
|
1275
|
+
resource: "Autenticacion/api/Autenticacion/Semilla" /* Seed */,
|
|
1276
|
+
skipAuthHooks: true
|
|
1127
1277
|
});
|
|
1128
1278
|
}
|
|
1129
1279
|
getAuthToken(signedSeed, buyerHost) {
|
|
1130
1280
|
const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/autenticacion/api/validacioncertificado" /* ValidateSeed */) : buildUrl(this.environment, "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */);
|
|
1131
1281
|
return this.http.postMultipart(url, "signed.xml", signedSeed, {
|
|
1132
1282
|
absoluteUrl: true,
|
|
1133
|
-
resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed
|
|
1283
|
+
resource: "autenticacion/api/Autenticacion/ValidarSemilla" /* ValidateSeed */,
|
|
1284
|
+
skipAuthHooks: true
|
|
1134
1285
|
});
|
|
1135
1286
|
}
|
|
1136
1287
|
/**
|
|
@@ -1162,6 +1313,32 @@ var ECF = class {
|
|
|
1162
1313
|
)
|
|
1163
1314
|
);
|
|
1164
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Submit a signed document to the endpoint its own content belongs to.
|
|
1318
|
+
*
|
|
1319
|
+
* Reads the root element, `TipoeCF` and `MontoTotal` from the signed XML and
|
|
1320
|
+
* dispatches to {@link sendSummary} for an RFCE or to
|
|
1321
|
+
* {@link sendElectronicDocument} for an ECF. An E32 below RD$250,000 is
|
|
1322
|
+
* rejected because DGII only accepts the summary form for those.
|
|
1323
|
+
*/
|
|
1324
|
+
async sendDocument(signedXml, fileName, buyerHost) {
|
|
1325
|
+
const route = resolveReceptionRoute(signedXml);
|
|
1326
|
+
if (route.isErr()) {
|
|
1327
|
+
return err(route.error);
|
|
1328
|
+
}
|
|
1329
|
+
if (route.value.kind === "summary") {
|
|
1330
|
+
if (buyerHost !== void 0) {
|
|
1331
|
+
return err(
|
|
1332
|
+
new DgiiError(
|
|
1333
|
+
"An RFCE summary cannot be delivered directly to a buyer host",
|
|
1334
|
+
{ code: "VALIDATION" }
|
|
1335
|
+
)
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
return this.sendSummary(signedXml, fileName);
|
|
1339
|
+
}
|
|
1340
|
+
return this.sendElectronicDocument(signedXml, fileName, buyerHost);
|
|
1341
|
+
}
|
|
1165
1342
|
statusTrackId(trackId) {
|
|
1166
1343
|
const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
|
|
1167
1344
|
return this.http.get(
|
|
@@ -1255,8 +1432,10 @@ var ECF = class {
|
|
|
1255
1432
|
};
|
|
1256
1433
|
export {
|
|
1257
1434
|
BaseUrl,
|
|
1435
|
+
CONSUMPTION_SUMMARY_THRESHOLD,
|
|
1258
1436
|
DgiiError,
|
|
1259
1437
|
ECF,
|
|
1438
|
+
ECF_TYPE_CODES,
|
|
1260
1439
|
Endpoint,
|
|
1261
1440
|
Environment,
|
|
1262
1441
|
Err,
|
|
@@ -1271,6 +1450,7 @@ export {
|
|
|
1271
1450
|
Transformer,
|
|
1272
1451
|
buildSenderReceiverUrl,
|
|
1273
1452
|
buildUrl,
|
|
1453
|
+
compareDecimal,
|
|
1274
1454
|
convertECF32ToRFCE,
|
|
1275
1455
|
createResiliencePolicies,
|
|
1276
1456
|
defaultResilienceOptions,
|
|
@@ -1286,10 +1466,12 @@ export {
|
|
|
1286
1466
|
getCurrentFormattedDate,
|
|
1287
1467
|
getCurrentFormattedDateTime,
|
|
1288
1468
|
getXmlFromBodyResponse,
|
|
1469
|
+
isEcfTypeCode,
|
|
1289
1470
|
isRetryableError,
|
|
1290
1471
|
isRetryableStatus,
|
|
1291
1472
|
ok,
|
|
1292
1473
|
okAsync,
|
|
1474
|
+
resolveReceptionRoute,
|
|
1293
1475
|
roundHalfUpToCents,
|
|
1294
1476
|
toDgiiError,
|
|
1295
1477
|
validateXMLCertificate
|