@openfactura/cli 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +67 -5
  2. package/dist/index.js +517 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -21,7 +21,7 @@ npx @openfactura/cli --help
21
21
 
22
22
  ## Inicio de sesión
23
23
 
24
- Cree una clave de API en el panel web (Ajustes → Claves de API). Las claves `of_test_…` trabajan contra
24
+ Cree una clave de API en el panel web (Desarrolladores → Nueva clave). Las claves `of_test_…` trabajan contra
25
25
  el ambiente de pruebas del MH y las `of_live_…` contra producción.
26
26
 
27
27
  ```bash
@@ -220,7 +220,45 @@ openfactura dte invalidar <codigo> --tipo 1 --reemplazo <codigo-del-dte-que-lo-c
220
220
  ```
221
221
 
222
222
  `--tipo`: `1` error en la información (requiere `--reemplazo`), `2` rescindir la operación, `3` otro
223
- (requiere `--motivo` y `--reemplazo`). La invalidación es irreversible.
223
+ (requiere `--motivo` y `--reemplazo`). La invalidación es irreversible. El mismo comando invalida un
224
+ evento de retorno u operaciones especiales si recibe su código. Un DTE con un retorno vigente no se
225
+ invalida hasta invalidar antes el retorno.
226
+
227
+ #### Retorno (evento 18)
228
+
229
+ Devolución de bienes, reembolso o envases sobre una Factura (01), Factura de Exportación (11) o Sujeto
230
+ Excluido (14) procesados, sin invalidar el documento. Plazo: 3 meses desde la fecha del documento.
231
+
232
+ ```bash
233
+ echo '{ "items": [{ "descripcion": "Silla", "cantidad": 1, "precioUni": 22.6 }] }' \
234
+ | openfactura dte retorno 0862382B-0BB8-42A0-BB6B-B42DCCDF1EA4 -
235
+ ```
236
+
237
+ `precioUni` va como en el documento original: en Factura con IVA, en 11 y 14 sin IVA. Como `dte emitir`,
238
+ lleva `Idempotency-Key` y pide confirmación con una clave `of_live_…`.
239
+
240
+ ### Eventos
241
+
242
+ ```bash
243
+ openfactura eventos operaciones-especiales simplificadas.json # evento 17
244
+ openfactura eventos list --tipo retorno
245
+ openfactura eventos get <codigo>
246
+ openfactura eventos json <codigo> -o retorno.json
247
+ openfactura eventos pdf <codigo> -o retorno.pdf
248
+ ```
249
+
250
+ El archivo de operaciones especiales informa facturas simplificadas (`"tipoDocumento": "02"`, por rangos)
251
+ o comprobantes de control interno (`"97"`):
252
+
253
+ ```json
254
+ {
255
+ "tipoDocumento": "02",
256
+ "fecha": "2026-10-31",
257
+ "items": [
258
+ { "descripcion": "Facturas simplificadas", "docDel": "1", "docAl": "25", "precioUni": 10 }
259
+ ]
260
+ }
261
+ ```
224
262
 
225
263
  ### Contingencia
226
264
 
@@ -228,9 +266,30 @@ openfactura dte invalidar <codigo> --tipo 1 --reemplazo <codigo-del-dte-que-lo-c
228
266
  openfactura contingencia transmitir
229
267
  ```
230
268
 
269
+ ### Webhooks
270
+
271
+ Su sistema recibe un `POST` firmado cuando un documento cambia de estado. La clave necesita el permiso
272
+ `webhooks:manage`.
273
+
274
+ ```bash
275
+ openfactura webhooks add https://erp.su-empresa.sv/hooks/openfactura \
276
+ --eventos dte.procesado,dte.rechazado --descripcion "ERP" # sin --eventos: todos
277
+ openfactura webhooks list
278
+ openfactura webhooks test <id> # envía webhook.prueba; salida 1 si no se entrega
279
+ openfactura webhooks deliveries <id> # últimas 50 entregas, con intentos y errores
280
+ openfactura webhooks rotate <id> # secreto nuevo; el anterior deja de firmar
281
+ openfactura webhooks remove <id>
282
+ ```
283
+
284
+ `add` y `rotate` muestran el secreto de firma (`whsec_…`) una sola vez. Eventos: `dte.procesado`,
285
+ `dte.rechazado`, `dte.contingencia`, `dte.invalidado`, `retorno.procesado`,
286
+ `operaciones_especiales.procesado`. Cómo verificar la cabecera `OpenFactura-Signature`: sección
287
+ «Webhooks» de `docs/api.md` en el repositorio del servidor.
288
+
231
289
  ### Confirmación en producción
232
290
 
233
- Con una clave `of_live_…`, `dte emitir`, `dte invalidar` y `contingencia transmitir` piden confirmación
291
+ Con una clave `of_live_…`, `dte emitir`, `dte retorno`, `eventos operaciones-especiales`,
292
+ `dte invalidar` y `contingencia transmitir` piden confirmación
234
293
  porque tienen efecto fiscal. Use `--yes` para omitirla. Si no hay una terminal interactiva (un script,
235
294
  una tubería, `dte emitir -`), la CLI no pregunta: exige `--yes` y termina con salida `2` si falta.
236
295
 
@@ -274,10 +333,13 @@ clientes que no pueden enviar un encabezado HTTP fijo al servidor MCP remoto (`/
274
333
  herramientas que el remoto y las ejecuta llamando a la API REST con su clave:
275
334
 
276
335
  `emisor_obtener`, `catalogo_buscar`, `clientes_buscar`, `cliente_crear`, `productos_buscar`,
277
- `producto_crear`, `dte_validar`, `dte_emitir`, `dte_listar`, `dte_obtener`, `dte_invalidar`.
336
+ `producto_crear`, `dte_validar`, `dte_emitir`, `dte_listar`, `dte_obtener`, `dte_invalidar`,
337
+ `dte_retorno`, `operaciones_especiales_informar`, `eventos_listar`, `evento_obtener`,
338
+ `webhooks_listar`, `webhook_crear`, `webhook_eliminar`, `webhook_probar`.
278
339
 
279
340
  Cada herramienta exige el mismo permiso que su ruta REST, así que la clave decide qué puede hacer el
280
- asistente. Empiece con una clave `of_test_…`; `dte_emitir` y `dte_invalidar` tienen efecto fiscal con una
341
+ asistente. Empiece con una clave `of_test_…`; `dte_emitir`, `dte_retorno`, `operaciones_especiales_informar` y
342
+ `dte_invalidar` tienen efecto fiscal con una
281
343
  clave `of_live_…`, y aquí no hay confirmación de la CLI: la da usted en su cliente MCP al aprobar la
282
344
  llamada.
283
345
 
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { parseArgs } from "util";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "@openfactura/cli",
9
- version: "0.1.0",
9
+ version: "0.2.0",
10
10
  description: "Cliente de l\xEDnea de comandos y servidor MCP (stdio) de Open Factura: facturaci\xF3n electr\xF3nica de El Salvador (DTE).",
11
11
  license: "MIT",
12
12
  repository: {
@@ -257,6 +257,56 @@ var invalidacion = z.object({
257
257
  solicitante: persona.optional(),
258
258
  responsable: persona.optional()
259
259
  });
260
+ var fecha = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use el formato AAAA-MM-DD");
261
+ var retorno = z.object({
262
+ items: z.array(
263
+ z.object({
264
+ descripcion: texto(1e3),
265
+ cantidad: z.number().positive(),
266
+ precioUni: z.number().nonnegative(),
267
+ tipoItem: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(),
268
+ codigo: opcional(25),
269
+ uniMedida: z.number().int().min(1).max(99).optional(),
270
+ descuento: z.number().nonnegative().optional(),
271
+ tipoVenta: z.enum(["gravada", "exenta", "noSujeta"]).optional(),
272
+ seguro: z.number().nonnegative().optional(),
273
+ flete: z.number().nonnegative().optional()
274
+ })
275
+ ).min(1).max(2e3).describe(
276
+ "Bienes que se retornan. precioUni: en Factura (01) CON IVA, como en la factura; en Exportaci\xF3n (11) y Sujeto Excluido (14) SIN IVA."
277
+ )
278
+ });
279
+ var operacionesEspeciales = z.object({
280
+ tipoDocumento: z.enum(["02", "97"]).describe("02 factura de venta simplificada (por rangos), 97 comprobante de control interno"),
281
+ fecha: fecha.optional().describe("Fecha del evento; define el periodo informado"),
282
+ items: z.array(
283
+ z.object({
284
+ descripcion: texto(1500),
285
+ precioUni: z.number().nonnegative().describe("SIN IVA"),
286
+ cantidad: z.number().int().positive().optional(),
287
+ numDocumento: opcional(36),
288
+ docDel: z.string().trim().min(1).max(36).optional(),
289
+ docAl: z.string().trim().min(1).max(36).optional(),
290
+ fechaEmision: fecha.optional(),
291
+ tipoVenta: z.enum(["gravada", "exenta", "noSujeta"]).optional(),
292
+ codigoGeneracionRef: z.string().length(36).optional()
293
+ })
294
+ ).min(1).max(2e3)
295
+ });
296
+ var EVENTOS_WEBHOOK = [
297
+ "dte.procesado",
298
+ "dte.rechazado",
299
+ "dte.contingencia",
300
+ "dte.invalidado",
301
+ "retorno.procesado",
302
+ "operaciones_especiales.procesado"
303
+ ];
304
+ var webhook = z.object({
305
+ url: z.string().max(2e3).describe("URL https:// p\xFAblica que recibir\xE1 los POST firmados"),
306
+ descripcion: opcional(200),
307
+ eventos: z.array(z.enum(EVENTOS_WEBHOOK)).min(1).describe("Eventos a los que se suscribe"),
308
+ activo: z.boolean().optional()
309
+ });
260
310
 
261
311
  // src/http.ts
262
312
  var TIEMPO_LIMITE_MS = 6e4;
@@ -522,7 +572,7 @@ function crearServidorMcp(api, version) {
522
572
  "dte_invalidar",
523
573
  {
524
574
  title: "Invalidar un DTE",
525
- description: "Transmite el evento de invalidaci\xF3n de un DTE procesado. Es irreversible y tiene efecto fiscal: confirme con el usuario. tipoAnulacion: 1 error en la informaci\xF3n (requiere codigoGeneracionReemplazo), 2 rescindir la operaci\xF3n, 3 otro (requiere motivo y reemplazo).",
575
+ description: "Transmite el evento de invalidaci\xF3n de un DTE procesado, o de un evento de retorno u operaciones especiales (use su c\xF3digo de generaci\xF3n). Un DTE con retorno vigente exige invalidar antes el retorno. Es irreversible y tiene efecto fiscal: confirme con el usuario. tipoAnulacion: 1 error en la informaci\xF3n (requiere codigoGeneracionReemplazo), 2 rescindir la operaci\xF3n, 3 otro (requiere motivo y reemplazo).",
526
576
  inputSchema: z2.object({ codigoGeneracion: z2.string(), ...invalidacion.shape }),
527
577
  annotations: {
528
578
  readOnlyHint: false,
@@ -535,6 +585,122 @@ function crearServidorMcp(api, version) {
535
585
  () => api.pedir("POST", `/dte/${ruta(codigoGeneracion)}/invalidar`, { cuerpo: entrada })
536
586
  )
537
587
  );
588
+ servidor.registerTool(
589
+ "dte_retorno",
590
+ {
591
+ title: "Registrar un retorno (evento 18)",
592
+ description: "Transmite un Evento de Retorno sobre una Factura (01), Factura de Exportaci\xF3n (11) o Sujeto Excluido (14) procesados: devoluci\xF3n de bienes, reembolso o envases, sin invalidar el documento. Plazo: 3 meses desde la fecha del DTE. La suma de retornos no puede superar el DTE. Tiene efecto fiscal: confirme con el usuario. Para un Cr\xE9dito Fiscal use una Nota de Cr\xE9dito (05).",
593
+ inputSchema: z2.object({
594
+ codigoGeneracion: z2.string().describe("C\xF3digo de generaci\xF3n del DTE original"),
595
+ ...retorno.shape,
596
+ idempotencyKey: z2.string().min(8).max(200).describe(
597
+ "Identificador \xFAnico de este intento. Repetirlo devuelve el mismo resultado sin transmitir dos veces."
598
+ )
599
+ }),
600
+ annotations: {
601
+ readOnlyHint: false,
602
+ destructiveHint: false,
603
+ idempotentHint: true,
604
+ openWorldHint: true
605
+ }
606
+ },
607
+ ({ codigoGeneracion, idempotencyKey, ...entrada }) => responder(
608
+ () => api.pedir("POST", `/dte/${ruta(codigoGeneracion)}/retorno`, {
609
+ cuerpo: entrada,
610
+ headers: { "Idempotency-Key": idempotencyKey }
611
+ })
612
+ )
613
+ );
614
+ servidor.registerTool(
615
+ "operaciones_especiales_informar",
616
+ {
617
+ title: "Informar operaciones especiales (evento 17)",
618
+ description: "Transmite un Evento de Operaciones Especiales: informa al MH las facturas simplificadas (02, siempre por rangos docDel\u2013docAl) o los comprobantes de control interno (97, uno a uno con numDocumento o por rangos, sin mezclar) de un periodo. precioUni SIN IVA. Los documentos anulados van en un evento aparte con codigoGeneracionRef. Tiene efecto fiscal: confirme con el usuario.",
619
+ inputSchema: z2.object({
620
+ ...operacionesEspeciales.shape,
621
+ idempotencyKey: z2.string().min(8).max(200).describe(
622
+ "Identificador \xFAnico de este intento. Repetirlo devuelve el mismo resultado sin transmitir dos veces."
623
+ )
624
+ }),
625
+ annotations: {
626
+ readOnlyHint: false,
627
+ destructiveHint: false,
628
+ idempotentHint: true,
629
+ openWorldHint: true
630
+ }
631
+ },
632
+ ({ idempotencyKey, ...entrada }) => responder(
633
+ () => api.pedir("POST", "/eventos/operaciones-especiales", {
634
+ cuerpo: entrada,
635
+ headers: { "Idempotency-Key": idempotencyKey }
636
+ })
637
+ )
638
+ );
639
+ servidor.registerTool(
640
+ "eventos_listar",
641
+ {
642
+ title: "Listar eventos",
643
+ description: "Eventos de retorno y de operaciones especiales del ambiente activo (los 50 m\xE1s recientes).",
644
+ inputSchema: z2.object({ tipo: z2.enum(["retorno", "operaciones_especiales"]).optional() }),
645
+ annotations: lectura
646
+ },
647
+ ({ tipo }) => responder(() => api.pedir("GET", "/eventos", { query: { tipo } }))
648
+ );
649
+ servidor.registerTool(
650
+ "evento_obtener",
651
+ {
652
+ title: "Ver un evento",
653
+ description: "Detalle de un evento por c\xF3digo de generaci\xF3n: estado, sello, JSON oficial y respuesta del MH.",
654
+ inputSchema: z2.object({ codigoGeneracion: z2.string() }),
655
+ annotations: lectura
656
+ },
657
+ ({ codigoGeneracion }) => responder(() => api.pedir("GET", `/eventos/${ruta(codigoGeneracion)}`))
658
+ );
659
+ servidor.registerTool(
660
+ "webhooks_listar",
661
+ {
662
+ title: "Listar webhooks",
663
+ description: "Webhooks de la organizaci\xF3n: URL, eventos suscritos y si est\xE1n activos. Nunca devuelve el secreto de firma.",
664
+ inputSchema: z2.object({}),
665
+ annotations: lectura
666
+ },
667
+ () => responder(() => api.pedir("GET", "/webhooks"))
668
+ );
669
+ servidor.registerTool(
670
+ "webhook_crear",
671
+ {
672
+ title: "Crear webhook",
673
+ description: `Registra una URL https:// que recibir\xE1 un POST firmado cuando cambie el estado de un documento. Eventos: ${EVENTOS_WEBHOOK.join(", ")}. Devuelve el secreto de firma (whsec_\u2026) UNA sola vez: entr\xE9gueselo al usuario para que verifique la firma OpenFactura-Signature.`,
674
+ inputSchema: webhook,
675
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
676
+ },
677
+ (datos) => responder(() => api.pedir("POST", "/webhooks", { cuerpo: datos }))
678
+ );
679
+ servidor.registerTool(
680
+ "webhook_eliminar",
681
+ {
682
+ title: "Eliminar webhook",
683
+ description: "Elimina un webhook y su historial de entregas. Deja de recibir notificaciones de inmediato. Confirme con el usuario.",
684
+ inputSchema: z2.object({ id: z2.string() }),
685
+ annotations: {
686
+ readOnlyHint: false,
687
+ destructiveHint: true,
688
+ idempotentHint: true,
689
+ openWorldHint: false
690
+ }
691
+ },
692
+ ({ id }) => responder(() => api.pedir("DELETE", `/webhooks/${ruta(id)}`))
693
+ );
694
+ servidor.registerTool(
695
+ "webhook_probar",
696
+ {
697
+ title: "Probar webhook",
698
+ description: "Env\xEDa ya un evento webhook.prueba firmado a la URL del webhook y devuelve si respondi\xF3 2xx, su estado HTTP o el error.",
699
+ inputSchema: z2.object({ id: z2.string() }),
700
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
701
+ },
702
+ ({ id }) => responder(() => api.pedir("POST", `/webhooks/${ruta(id)}/probar`))
703
+ );
538
704
  return servidor;
539
705
  }
540
706
  function servirMcp(api, version) {
@@ -579,7 +745,7 @@ var siNo = (valor) => valor ? "s\xED" : "no";
579
745
  // src/comandos.ts
580
746
  var nombreTipo = (codigo) => `${codigo} ${TIPOS_DTE[String(codigo)] ?? ""}`.trim();
581
747
  var nombreAmbiente = (codigo) => `${codigo} ${NOMBRE_AMBIENTE[String(codigo)] ?? ""}`.trim();
582
- var fecha = (ms) => typeof ms === "number" ? new Date(ms).toISOString().replace("T", " ").slice(0, 19) : ms;
748
+ var fecha2 = (ms) => typeof ms === "number" ? new Date(ms).toISOString().replace("T", " ").slice(0, 19) : ms;
583
749
  var segmento = (texto2) => encodeURIComponent(texto2);
584
750
  function mostrar(ctx, datos, legible) {
585
751
  ctx.ent.stdout(ctx.json ? JSON.stringify(datos, null, 2) : legible());
@@ -959,36 +1125,45 @@ async function dteValidar(ctx) {
959
1125
  });
960
1126
  return problemas.length ? SALIDA.error : SALIDA.ok;
961
1127
  }
962
- async function dteEmitir(ctx) {
963
- const origen = ctx.args[0];
964
- const cuerpo = await leerEmision(ctx, origen);
1128
+ async function enviarUnaVez(ctx, ruta2, cuerpo, qu\u00E9, reintento, stdin = false) {
965
1129
  const api = ctx.api();
966
1130
  const clave = ctx.op("idempotency-key") ?? randomUUID();
967
1131
  if (clave.length < 1 || clave.length > 200)
968
1132
  throw new ErrorUso("--idempotency-key debe tener entre 1 y 200 caracteres.");
969
- await confirmar(ctx, `emitir un DTE (${nombreTipo(cuerpo.tipoDte)})`, origen === "-");
1133
+ await confirmar(ctx, qu\u00E9, stdin);
970
1134
  ctx.ent.stderr(`Idempotency-Key: ${clave}`);
971
- let respuesta;
972
1135
  try {
973
- respuesta = await api.pedir("POST", "/dte", {
1136
+ const respuesta = await api.pedir("POST", ruta2, {
974
1137
  cuerpo,
975
1138
  headers: { "Idempotency-Key": clave }
976
1139
  });
1140
+ if (respuesta.headers.get("Idempotent-Replayed") === "true") {
1141
+ ctx.ent.stderr(
1142
+ "Respuesta repetida: esta Idempotency-Key ya se hab\xEDa usado; no se transmiti\xF3 de nuevo."
1143
+ );
1144
+ }
1145
+ return respuesta.datos;
977
1146
  } catch (error) {
978
1147
  if (error instanceof ErrorCli && (error.code === "sin_conexion" || error.code === "tiempo_agotado")) {
979
1148
  ctx.ent.stderr(
980
- `No se sabe si el DTE se emiti\xF3. Reintente con la misma clave para no emitir dos veces:
981
- openfactura dte emitir ${origen} --idempotency-key ${clave}`
1149
+ `No se sabe si se transmiti\xF3. Reintente con la misma clave para no duplicarlo:
1150
+ openfactura ${reintento} --idempotency-key ${clave}`
982
1151
  );
983
1152
  }
984
1153
  throw error;
985
1154
  }
986
- const doc = respuesta.datos;
987
- if (respuesta.headers.get("Idempotent-Replayed") === "true") {
988
- ctx.ent.stderr(
989
- "Respuesta repetida: esta Idempotency-Key ya se hab\xEDa usado; no se emiti\xF3 de nuevo."
990
- );
991
- }
1155
+ }
1156
+ async function dteEmitir(ctx) {
1157
+ const origen = ctx.args[0];
1158
+ const cuerpo = await leerEmision(ctx, origen);
1159
+ const doc = await enviarUnaVez(
1160
+ ctx,
1161
+ "/dte",
1162
+ cuerpo,
1163
+ `emitir un DTE (${nombreTipo(cuerpo.tipoDte)})`,
1164
+ `dte emitir ${origen}`,
1165
+ origen === "-"
1166
+ );
992
1167
  if (doc.estado === "contingencia") {
993
1168
  ctx.ent.stderr(
994
1169
  "El MH no respondi\xF3: el DTE qued\xF3 firmado en contingencia. Transm\xEDtalo con `openfactura contingencia transmitir`."
@@ -1073,7 +1248,7 @@ async function dteGet(ctx) {
1073
1248
  eventos.map((e) => [
1074
1249
  e.tipo,
1075
1250
  e.estado,
1076
- fecha(e.creado),
1251
+ fecha2(e.creado),
1077
1252
  e.codigoGeneracion,
1078
1253
  e.selloRecibido
1079
1254
  ])
@@ -1129,6 +1304,117 @@ async function dteInvalidar(ctx) {
1129
1304
  ${fichaDocumento(datos)}`);
1130
1305
  return void 0;
1131
1306
  }
1307
+ var TIPOS_EVENTO = ["retorno", "operaciones_especiales"];
1308
+ function fichaEvento(e) {
1309
+ return fichas([
1310
+ [
1311
+ "Evento",
1312
+ e.tipoEvento === "18" ? "18 Retorno" : e.tipoEvento === "17" ? "17 Operaciones especiales" : e.tipo
1313
+ ],
1314
+ ["Estado", e.estado],
1315
+ ["C\xF3digo de generaci\xF3n", e.codigoGeneracion],
1316
+ ["Ambiente", nombreAmbiente(e.ambiente)],
1317
+ ["Sello recibido", e.selloRecibido]
1318
+ ]);
1319
+ }
1320
+ async function leerObjeto(ctx, origen, forma) {
1321
+ const cuerpo = await leerJson(ctx, origen);
1322
+ if (!cuerpo || typeof cuerpo !== "object" || Array.isArray(cuerpo) || !("items" in cuerpo)) {
1323
+ throw new ErrorUso(`El archivo debe ser un objeto ${forma}`);
1324
+ }
1325
+ return cuerpo;
1326
+ }
1327
+ async function dteRetorno(ctx) {
1328
+ const [codigo, origen] = ctx.args;
1329
+ const cuerpo = await leerObjeto(
1330
+ ctx,
1331
+ origen,
1332
+ '{ "items": [{ "descripcion", "cantidad", "precioUni" }] }'
1333
+ );
1334
+ const evento = await enviarUnaVez(
1335
+ ctx,
1336
+ `/dte/${segmento(codigo)}/retorno`,
1337
+ cuerpo,
1338
+ `registrar un retorno sobre el DTE ${codigo}`,
1339
+ `dte retorno ${codigo} ${origen}`,
1340
+ origen === "-"
1341
+ );
1342
+ mostrar(ctx, evento, () => `Retorno registrado.
1343
+ ${fichaEvento(evento)}`);
1344
+ return void 0;
1345
+ }
1346
+ async function eventosOperacionesEspeciales(ctx) {
1347
+ const origen = ctx.args[0];
1348
+ const cuerpo = await leerObjeto(
1349
+ ctx,
1350
+ origen,
1351
+ '{ "tipoDocumento": "02" | "97", "fecha"?, "items": [{ "descripcion", "precioUni", "docDel", "docAl" }] }'
1352
+ );
1353
+ const evento = await enviarUnaVez(
1354
+ ctx,
1355
+ "/eventos/operaciones-especiales",
1356
+ cuerpo,
1357
+ "informar operaciones especiales",
1358
+ `eventos operaciones-especiales ${origen}`,
1359
+ origen === "-"
1360
+ );
1361
+ mostrar(ctx, evento, () => `Operaciones especiales informadas.
1362
+ ${fichaEvento(evento)}`);
1363
+ return void 0;
1364
+ }
1365
+ async function eventosList(ctx) {
1366
+ const tipo = ctx.op("tipo");
1367
+ if (tipo !== void 0 && !TIPOS_EVENTO.includes(tipo)) {
1368
+ throw new ErrorUso(`--tipo debe ser uno de: ${TIPOS_EVENTO.join(", ")}`);
1369
+ }
1370
+ const { datos } = await ctx.api().pedir("GET", "/eventos", { query: { tipo } });
1371
+ mostrar(
1372
+ ctx,
1373
+ datos,
1374
+ () => datos.length ? tabla(
1375
+ ["creado", "evento", "estado", "c\xF3digo de generaci\xF3n"],
1376
+ datos.map((e) => [
1377
+ new Date(e.creado).toISOString().slice(0, 10),
1378
+ e.tipoEvento,
1379
+ e.estado,
1380
+ e.codigoGeneracion
1381
+ ])
1382
+ ) : "No hay eventos."
1383
+ );
1384
+ return void 0;
1385
+ }
1386
+ async function eventosGet(ctx) {
1387
+ const { datos } = await ctx.api().pedir("GET", `/eventos/${segmento(ctx.args[0])}`);
1388
+ mostrar(ctx, datos, () => fichaEvento(datos));
1389
+ return void 0;
1390
+ }
1391
+ async function eventosJson(ctx) {
1392
+ const codigo = ctx.args[0];
1393
+ const { datos } = await ctx.api().pedir("GET", `/eventos/${segmento(codigo)}/json`);
1394
+ const texto2 = JSON.stringify(datos, null, 2);
1395
+ const salida = ctx.op("output");
1396
+ if (salida === void 0) {
1397
+ ctx.ent.stdout(texto2);
1398
+ return void 0;
1399
+ }
1400
+ const destino = resolve(ctx.ent.cwd, salida);
1401
+ await writeFile(destino, `${texto2}
1402
+ `);
1403
+ mostrar(ctx, { archivo: destino }, () => `JSON guardado en ${destino}`);
1404
+ return void 0;
1405
+ }
1406
+ async function eventosPdf(ctx) {
1407
+ const codigo = ctx.args[0];
1408
+ const bytes = await ctx.api().descargar(`/eventos/${segmento(codigo)}/pdf`);
1409
+ const destino = resolve(ctx.ent.cwd, ctx.op("output") ?? `${basename(codigo)}.pdf`);
1410
+ await writeFile(destino, bytes);
1411
+ mostrar(
1412
+ ctx,
1413
+ { archivo: destino, bytes: bytes.length },
1414
+ () => `PDF guardado en ${destino} (${bytes.length} bytes)`
1415
+ );
1416
+ return void 0;
1417
+ }
1132
1418
  async function contingenciaTransmitir(ctx) {
1133
1419
  const api = ctx.api();
1134
1420
  await confirmar(ctx, "transmitir los DTE en contingencia");
@@ -1150,6 +1436,113 @@ async function contingenciaTransmitir(ctx) {
1150
1436
  });
1151
1437
  return void 0;
1152
1438
  }
1439
+ function fichaWebhook(w) {
1440
+ return fichas([
1441
+ ["Id", w.id],
1442
+ ["URL", w.url],
1443
+ ["Descripci\xF3n", w.descripcion],
1444
+ ["Eventos", Array.isArray(w.eventos) ? w.eventos.join(", ") : null],
1445
+ ["Activo", siNo(w.activo)]
1446
+ ]);
1447
+ }
1448
+ function mostrarSecreto(ctx, w, titulo) {
1449
+ mostrar(
1450
+ ctx,
1451
+ w,
1452
+ () => [
1453
+ titulo,
1454
+ fichaWebhook(w),
1455
+ "",
1456
+ `Secreto de firma: ${w.secreto}`,
1457
+ "Gu\xE1rdelo ahora: no se vuelve a mostrar. Verifique con \xE9l la cabecera OpenFactura-Signature."
1458
+ ].join("\n")
1459
+ );
1460
+ }
1461
+ async function webhooksList(ctx) {
1462
+ const { datos } = await ctx.api().pedir("GET", "/webhooks");
1463
+ mostrar(
1464
+ ctx,
1465
+ datos,
1466
+ () => datos.length ? tabla(
1467
+ ["id", "url", "eventos", "activo", "descripci\xF3n"],
1468
+ datos.map((w) => [
1469
+ w.id,
1470
+ w.url,
1471
+ w.eventos.join(", "),
1472
+ siNo(w.activo),
1473
+ w.descripcion
1474
+ ])
1475
+ ) : "No hay webhooks."
1476
+ );
1477
+ return void 0;
1478
+ }
1479
+ async function webhooksAdd(ctx) {
1480
+ const texto2 = ctx.op("eventos");
1481
+ const eventos = texto2 ? texto2.split(",").map((e) => e.trim()).filter(Boolean) : [...EVENTOS_WEBHOOK];
1482
+ const desconocido = eventos.find((e) => !EVENTOS_WEBHOOK.includes(e));
1483
+ if (desconocido || !eventos.length) {
1484
+ throw new ErrorUso(`--eventos acepta, separados por coma: ${EVENTOS_WEBHOOK.join(", ")}`);
1485
+ }
1486
+ const cuerpo = { url: ctx.args[0], eventos };
1487
+ if (ctx.op("descripcion") !== void 0) cuerpo.descripcion = ctx.op("descripcion");
1488
+ const { datos } = await ctx.api().pedir("POST", "/webhooks", { cuerpo });
1489
+ mostrarSecreto(ctx, datos, "Webhook creado.");
1490
+ return void 0;
1491
+ }
1492
+ async function webhooksRemove(ctx) {
1493
+ const id = ctx.args[0];
1494
+ const { datos } = await ctx.api().pedir("DELETE", `/webhooks/${segmento(id)}`);
1495
+ mostrar(ctx, datos, () => `Webhook ${id} eliminado.`);
1496
+ return void 0;
1497
+ }
1498
+ async function webhooksTest(ctx) {
1499
+ const id = ctx.args[0];
1500
+ const { datos } = await ctx.api().pedir("POST", `/webhooks/${segmento(id)}/probar`);
1501
+ mostrar(
1502
+ ctx,
1503
+ datos,
1504
+ () => datos.entregado ? `Entregado: el destino respondi\xF3 HTTP ${datos.status}.` : `No se entreg\xF3: ${datos.error ?? "error desconocido"}.`
1505
+ );
1506
+ return datos.entregado ? SALIDA.ok : SALIDA.error;
1507
+ }
1508
+ async function webhooksDeliveries(ctx) {
1509
+ const id = ctx.args[0];
1510
+ const { datos } = await ctx.api().pedir("GET", `/webhooks/${segmento(id)}/entregas`);
1511
+ mostrar(
1512
+ ctx,
1513
+ datos,
1514
+ () => datos.length ? tabla(
1515
+ [
1516
+ "creado (UTC)",
1517
+ "evento",
1518
+ "estado",
1519
+ "intentos",
1520
+ "http",
1521
+ "error",
1522
+ "pr\xF3ximo intento",
1523
+ "id"
1524
+ ],
1525
+ datos.map((e) => [
1526
+ fecha2(e.creado),
1527
+ e.evento,
1528
+ e.estado,
1529
+ e.intentos,
1530
+ e.ultimoStatus,
1531
+ e.ultimoError,
1532
+ fecha2(e.proximoIntento),
1533
+ e.id
1534
+ ]),
1535
+ [3]
1536
+ ) : "Este webhook todav\xEDa no tiene entregas."
1537
+ );
1538
+ return void 0;
1539
+ }
1540
+ async function webhooksRotate(ctx) {
1541
+ const id = ctx.args[0];
1542
+ const { datos } = await ctx.api().pedir("POST", `/webhooks/${segmento(id)}/rotar-secreto`);
1543
+ mostrarSecreto(ctx, datos, "Secreto rotado: el anterior ya no firma.");
1544
+ return void 0;
1545
+ }
1153
1546
  async function mcp(ctx) {
1154
1547
  const api = ctx.api();
1155
1548
  ctx.ent.stderr(
@@ -1162,7 +1555,9 @@ var GRUPOS = {
1162
1555
  clientes: "Clientes guardados",
1163
1556
  productos: "Productos y servicios guardados",
1164
1557
  dte: "Documentos tributarios electr\xF3nicos",
1165
- contingencia: "Documentos emitidos en contingencia"
1558
+ eventos: "Eventos de retorno (18) y de operaciones especiales (17)",
1559
+ contingencia: "Documentos emitidos en contingencia",
1560
+ webhooks: "Notificaciones firmadas a su sistema cuando cambia un documento"
1166
1561
  };
1167
1562
  var COMANDOS = [
1168
1563
  {
@@ -1288,11 +1683,59 @@ var COMANDOS = [
1288
1683
  {
1289
1684
  nombre: "dte invalidar",
1290
1685
  uso: "dte invalidar <codigoGeneracion> --tipo <1|2|3> [--motivo <texto>] [--reemplazo <codigo>] [--yes]",
1291
- resumen: "Invalida un DTE procesado (irreversible)",
1686
+ resumen: "Invalida un DTE procesado, o un evento de retorno u operaciones especiales (irreversible)",
1292
1687
  opciones: ["tipo", "motivo", "reemplazo", "yes"],
1293
1688
  args: [1, 1],
1294
1689
  accion: dteInvalidar
1295
1690
  },
1691
+ {
1692
+ nombre: "dte retorno",
1693
+ uso: "dte retorno <codigoGeneracion> <archivo.json | -> [--idempotency-key <k>] [--yes]",
1694
+ resumen: "Registra un retorno (evento 18) sobre una Factura, Exportaci\xF3n o Sujeto Excluido",
1695
+ opciones: ["idempotency-key", "yes"],
1696
+ args: [2, 2],
1697
+ accion: dteRetorno
1698
+ },
1699
+ {
1700
+ nombre: "eventos operaciones-especiales",
1701
+ uso: "eventos operaciones-especiales <archivo.json | -> [--idempotency-key <k>] [--yes]",
1702
+ resumen: "Informa facturas simplificadas o comprobantes de control interno (evento 17)",
1703
+ opciones: ["idempotency-key", "yes"],
1704
+ args: [1, 1],
1705
+ accion: eventosOperacionesEspeciales
1706
+ },
1707
+ {
1708
+ nombre: "eventos list",
1709
+ uso: "eventos list [--tipo retorno|operaciones_especiales]",
1710
+ resumen: "Lista los eventos de retorno y operaciones especiales",
1711
+ opciones: ["tipo"],
1712
+ args: [0, 0],
1713
+ accion: eventosList
1714
+ },
1715
+ {
1716
+ nombre: "eventos get",
1717
+ uso: "eventos get <codigoGeneracion>",
1718
+ resumen: "Muestra un evento",
1719
+ opciones: [],
1720
+ args: [1, 1],
1721
+ accion: eventosGet
1722
+ },
1723
+ {
1724
+ nombre: "eventos json",
1725
+ uso: "eventos json <codigoGeneracion> [-o <archivo.json>]",
1726
+ resumen: "Descarga el archivo JSON oficial del evento",
1727
+ opciones: ["output"],
1728
+ args: [1, 1],
1729
+ accion: eventosJson
1730
+ },
1731
+ {
1732
+ nombre: "eventos pdf",
1733
+ uso: "eventos pdf <codigoGeneracion> [-o <archivo.pdf>]",
1734
+ resumen: "Descarga la representaci\xF3n gr\xE1fica del evento",
1735
+ opciones: ["output"],
1736
+ args: [1, 1],
1737
+ accion: eventosPdf
1738
+ },
1296
1739
  {
1297
1740
  nombre: "contingencia transmitir",
1298
1741
  uso: "contingencia transmitir [--yes]",
@@ -1301,6 +1744,54 @@ var COMANDOS = [
1301
1744
  args: [0, 0],
1302
1745
  accion: contingenciaTransmitir
1303
1746
  },
1747
+ {
1748
+ nombre: "webhooks list",
1749
+ uso: "webhooks list",
1750
+ resumen: "Lista los webhooks (sin su secreto)",
1751
+ opciones: [],
1752
+ args: [0, 0],
1753
+ accion: webhooksList
1754
+ },
1755
+ {
1756
+ nombre: "webhooks add",
1757
+ uso: "webhooks add <https://url> [--eventos <e1,e2>] [--descripcion <texto>]",
1758
+ resumen: "Crea un webhook y muestra su secreto de firma una sola vez",
1759
+ opciones: ["eventos", "descripcion"],
1760
+ args: [1, 1],
1761
+ accion: webhooksAdd
1762
+ },
1763
+ {
1764
+ nombre: "webhooks remove",
1765
+ uso: "webhooks remove <id>",
1766
+ resumen: "Elimina un webhook y su historial de entregas",
1767
+ opciones: [],
1768
+ args: [1, 1],
1769
+ accion: webhooksRemove
1770
+ },
1771
+ {
1772
+ nombre: "webhooks test",
1773
+ uso: "webhooks test <id>",
1774
+ resumen: "Env\xEDa un evento webhook.prueba (salida 1 si no se entrega)",
1775
+ opciones: [],
1776
+ args: [1, 1],
1777
+ accion: webhooksTest
1778
+ },
1779
+ {
1780
+ nombre: "webhooks deliveries",
1781
+ uso: "webhooks deliveries <id>",
1782
+ resumen: "\xDAltimas 50 entregas de un webhook",
1783
+ opciones: [],
1784
+ args: [1, 1],
1785
+ accion: webhooksDeliveries
1786
+ },
1787
+ {
1788
+ nombre: "webhooks rotate",
1789
+ uso: "webhooks rotate <id>",
1790
+ resumen: "Genera un secreto de firma nuevo y lo muestra una vez",
1791
+ opciones: [],
1792
+ args: [1, 1],
1793
+ accion: webhooksRotate
1794
+ },
1304
1795
  {
1305
1796
  nombre: "mcp",
1306
1797
  uso: "mcp",
@@ -1337,7 +1828,7 @@ var OPCIONES = {
1337
1828
  },
1338
1829
  tipo: {
1339
1830
  type: "string",
1340
- ayuda: "Tipo de DTE (01, 03\u2026) o, al invalidar, tipo de anulaci\xF3n (1, 2, 3)"
1831
+ ayuda: "Tipo de DTE (01, 03\u2026); al invalidar, tipo de anulaci\xF3n (1, 2, 3); en eventos, retorno | operaciones_especiales"
1341
1832
  },
1342
1833
  estado: {
1343
1834
  type: "string",
@@ -1365,7 +1856,11 @@ var OPCIONES = {
1365
1856
  telefono: { type: "string", ayuda: "Tel\xE9fono" },
1366
1857
  correo: { type: "string", ayuda: "Correo electr\xF3nico" },
1367
1858
  codigo: { type: "string", ayuda: "C\xF3digo del producto" },
1368
- descripcion: { type: "string", ayuda: "Descripci\xF3n del producto" },
1859
+ descripcion: { type: "string", ayuda: "Descripci\xF3n (del producto o del webhook)" },
1860
+ eventos: {
1861
+ type: "string",
1862
+ ayuda: "Eventos del webhook separados por coma (por defecto todos): dte.procesado, dte.rechazado, dte.contingencia, dte.invalidado, retorno.procesado, operaciones_especiales.procesado"
1863
+ },
1369
1864
  precio: { type: "string", ayuda: "Precio unitario SIN IVA" },
1370
1865
  "tipo-item": {
1371
1866
  type: "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfactura/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Cliente de línea de comandos y servidor MCP (stdio) de Open Factura: facturación electrónica de El Salvador (DTE).",
5
5
  "license": "MIT",
6
6
  "repository": {