@deuriib/dgii-ecf 0.3.1 → 0.4.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
@@ -370,16 +370,51 @@ var createResiliencePolicies = (options = {}) => {
370
370
  };
371
371
 
372
372
  // src/http.ts
373
+ var DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1e3;
374
+ var IDEMPOTENCY_SWEEP_THRESHOLD = 1e3;
373
375
  var InMemoryIdempotencyStore = class {
374
- store = /* @__PURE__ */ new Map();
376
+ entries = /* @__PURE__ */ new Map();
377
+ ttlMs;
378
+ now;
379
+ constructor(options = {}) {
380
+ this.ttlMs = options.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS;
381
+ if (!Number.isFinite(this.ttlMs) || this.ttlMs <= 0) {
382
+ throw new RangeError("ttlMs must be a positive finite number");
383
+ }
384
+ this.now = options.now ?? Date.now;
385
+ }
386
+ /** Number of live entries; expired ones are swept first. */
387
+ get size() {
388
+ this.sweepExpired();
389
+ return this.entries.size;
390
+ }
375
391
  async get(key) {
376
- return this.store.get(key);
392
+ const entry = this.entries.get(key);
393
+ if (entry === void 0) {
394
+ return void 0;
395
+ }
396
+ if (entry.expiresAt <= this.now()) {
397
+ this.entries.delete(key);
398
+ return void 0;
399
+ }
400
+ return entry.value;
377
401
  }
378
402
  async set(key, value) {
379
- this.store.set(key, value);
403
+ this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
404
+ if (this.entries.size > IDEMPOTENCY_SWEEP_THRESHOLD) {
405
+ this.sweepExpired();
406
+ }
380
407
  }
381
408
  async delete(key) {
382
- this.store.delete(key);
409
+ this.entries.delete(key);
410
+ }
411
+ sweepExpired() {
412
+ const now = this.now();
413
+ for (const [key, entry] of this.entries) {
414
+ if (entry.expiresAt <= now) {
415
+ this.entries.delete(key);
416
+ }
417
+ }
383
418
  }
384
419
  };
385
420
  var HttpClient = class {
@@ -1051,8 +1086,39 @@ var ECF = class {
1051
1086
  }
1052
1087
  buildIdempotencyKey(signedXml) {
1053
1088
  const rnc = extractElementText(signedXml, "RNCEmisor") ?? "unknown-rnc";
1054
- const encf = extractElementText(signedXml, "eNCF") ?? "unknown-encf";
1055
- return `${rnc}:${encf}:${sha256Hex(signedXml)}`;
1089
+ const ncf = extractElementText(signedXml, "eNCF") ?? extractElementText(signedXml, "RangoNCF") ?? "unknown-ncf";
1090
+ return `${rnc}:${ncf}:${sha256Hex(signedXml)}`;
1091
+ }
1092
+ /**
1093
+ * Run a submission through the persist-before-transmit idempotency flow.
1094
+ *
1095
+ * The pending marker is written first so an interrupted send can be
1096
+ * reconciled instead of re-issued; a successful response replaces it, and
1097
+ * a failure clears it so the caller can retry.
1098
+ */
1099
+ async submitIdempotent(key, send) {
1100
+ const cached = await this.idempotency.get(key);
1101
+ if (cached !== void 0) {
1102
+ if (isPendingSubmission(cached)) {
1103
+ return (0, import_neverthrow.err)(
1104
+ new DgiiError(
1105
+ "A submission with this key is already pending; reconcile via a status lookup before retrying",
1106
+ { code: "IDEMPOTENCY" }
1107
+ )
1108
+ );
1109
+ }
1110
+ return (0, import_neverthrow.ok)(cached);
1111
+ }
1112
+ return this.singleFlight(key, async () => {
1113
+ await this.idempotency.set(key, PENDING_SUBMISSION);
1114
+ const result = await send();
1115
+ if (result.isOk()) {
1116
+ await this.idempotency.set(key, result.value);
1117
+ } else {
1118
+ await this.idempotency.delete(key);
1119
+ }
1120
+ return result;
1121
+ });
1056
1122
  }
1057
1123
  async singleFlight(key, task) {
1058
1124
  const existing = this.inFlight.get(key);
@@ -1125,66 +1191,28 @@ var ECF = class {
1125
1191
  */
1126
1192
  async sendElectronicDocument(signedXml, fileName, buyerHost) {
1127
1193
  const key = this.buildIdempotencyKey(signedXml);
1128
- const cached = await this.idempotency.get(key);
1129
- if (cached !== void 0) {
1130
- if (isPendingSubmission(cached)) {
1131
- return (0, import_neverthrow.err)(
1132
- new DgiiError(
1133
- "A submission with this key is already pending; reconcile via a status lookup before retrying",
1134
- { code: "IDEMPOTENCY" }
1135
- )
1136
- );
1137
- }
1138
- return (0, import_neverthrow.ok)(cached);
1139
- }
1140
- return this.singleFlight(key, async () => {
1141
- await this.idempotency.set(key, PENDING_SUBMISSION);
1194
+ return this.submitIdempotent(key, () => {
1142
1195
  const url = buyerHost ? buildSenderReceiverUrl(buyerHost, "fe/recepcion/api/ecf" /* SendInvoice */) : buildUrl(this.environment, "recepcion/api/FacturasElectronicas" /* SendInvoice */);
1143
- const result = await this.http.postMultipart(
1196
+ return this.http.postMultipart(
1144
1197
  url,
1145
1198
  fileName,
1146
1199
  signedXml,
1147
1200
  { absoluteUrl: true, resource: "recepcion/api/FacturasElectronicas" /* SendInvoice */ }
1148
1201
  );
1149
- if (result.isOk()) {
1150
- await this.idempotency.set(key, result.value);
1151
- } else {
1152
- await this.idempotency.delete(key);
1153
- }
1154
- return result;
1155
1202
  });
1156
1203
  }
1157
1204
  /** Submit a signed summary invoice (RFCE). Idempotent like submissions. */
1158
1205
  async sendSummary(signedXml, fileName) {
1159
1206
  const key = this.buildIdempotencyKey(signedXml);
1160
- const cached = await this.idempotency.get(key);
1161
- if (cached !== void 0) {
1162
- if (isPendingSubmission(cached)) {
1163
- return (0, import_neverthrow.err)(
1164
- new DgiiError(
1165
- "A submission with this key is already pending; reconcile via a status lookup before retrying",
1166
- { code: "IDEMPOTENCY" }
1167
- )
1168
- );
1169
- }
1170
- return (0, import_neverthrow.ok)(cached);
1171
- }
1172
- return this.singleFlight(key, async () => {
1173
- await this.idempotency.set(key, PENDING_SUBMISSION);
1174
- const url = buildUrl(this.environment, "recepcionfc/api/recepcion/ecf" /* SendSummary */, BaseUrl.CF);
1175
- const result = await this.http.postMultipart(
1176
- url,
1207
+ return this.submitIdempotent(
1208
+ key,
1209
+ () => this.http.postMultipart(
1210
+ buildUrl(this.environment, "recepcionfc/api/recepcion/ecf" /* SendSummary */, BaseUrl.CF),
1177
1211
  fileName,
1178
1212
  signedXml,
1179
1213
  { absoluteUrl: true, resource: "recepcionfc/api/recepcion/ecf" /* SendSummary */ }
1180
- );
1181
- if (result.isOk()) {
1182
- await this.idempotency.set(key, result.value);
1183
- } else {
1184
- await this.idempotency.delete(key);
1185
- }
1186
- return result;
1187
- });
1214
+ )
1215
+ );
1188
1216
  }
1189
1217
  statusTrackId(trackId) {
1190
1218
  const url = buildUrl(this.environment, "consultaresultado/api/Consultas/Estado" /* TrackResultStatus */);
@@ -1244,21 +1272,37 @@ var ECF = class {
1244
1272
  { absoluteUrl: true, resource: "consultarfce/api/Consultas/Consulta" /* InquiryInvoiceSummary */ }
1245
1273
  );
1246
1274
  }
1275
+ /**
1276
+ * Submit a signed commercial approval (ACECF). Idempotent like submissions,
1277
+ * keyed on `RNC + eNCF + SHA-256(signed XML)`.
1278
+ */
1247
1279
  sendCommercialApproval(signedXml, fileName) {
1248
- const url = buildUrl(this.environment, "aprobacionComercial/api/AprobacionComercial" /* CommercialApproval */);
1249
- return this.http.postMultipart(
1250
- url,
1251
- fileName,
1252
- signedXml,
1253
- { absoluteUrl: true, resource: "aprobacionComercial/api/AprobacionComercial" /* CommercialApproval */ }
1280
+ const key = this.buildIdempotencyKey(signedXml);
1281
+ return this.submitIdempotent(
1282
+ key,
1283
+ () => this.http.postMultipart(
1284
+ buildUrl(this.environment, "aprobacionComercial/api/AprobacionComercial" /* CommercialApproval */),
1285
+ fileName,
1286
+ signedXml,
1287
+ { absoluteUrl: true, resource: "aprobacionComercial/api/AprobacionComercial" /* CommercialApproval */ }
1288
+ )
1254
1289
  );
1255
1290
  }
1291
+ /**
1292
+ * Submit a signed range-voiding request (ANECF). Idempotent like
1293
+ * submissions, keyed on `RNC + RangoNCF + SHA-256(signed XML)`.
1294
+ */
1256
1295
  voidENCF(signedXml, fileName) {
1257
- const url = buildUrl(this.environment, "anulacionrangos/api/operaciones/anularrango" /* Void */);
1258
- return this.http.postMultipart(url, fileName, signedXml, {
1259
- absoluteUrl: true,
1260
- resource: "anulacionrangos/api/operaciones/anularrango" /* Void */
1261
- });
1296
+ const key = this.buildIdempotencyKey(signedXml);
1297
+ return this.submitIdempotent(
1298
+ key,
1299
+ () => this.http.postMultipart(
1300
+ buildUrl(this.environment, "anulacionrangos/api/operaciones/anularrango" /* Void */),
1301
+ fileName,
1302
+ signedXml,
1303
+ { absoluteUrl: true, resource: "anulacionrangos/api/operaciones/anularrango" /* Void */ }
1304
+ )
1305
+ );
1262
1306
  }
1263
1307
  };
1264
1308
  // Annotate the CommonJS export names for ESM import in node: