From d05f1dbf9ab08dc8f648e245c47de3c1e7ed05b4 Mon Sep 17 00:00:00 2001 From: Mijhael Samaniego <67170467+mijhaels@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:14:21 -0300 Subject: [PATCH 1/2] feat(sdk): webhooks, XML, KuDE en cinta y ciudades por departamento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenera los tipos desde el OpenAPI actual y agrega los endpoints que el SDK no cubría: - webhooks: listar, crear, consultar, actualizar, eliminar, rotar el secreto y listar entregas. crear y rotarSecreto no admiten Idempotency-Key, así que un corte de conexión no los repite. - documentos.descargarXml, descargarKudeTicket y descargarKudeTicketImagen. - catalogos.ciudadesDeDepartamento. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- src/cliente.ts | 3 + src/generado/types.gen.ts | 588 ++++++++++++++++++++++++++++++++++++- src/http.ts | 11 +- src/index.ts | 3 +- src/recursos/catalogos.ts | 9 + src/recursos/documentos.ts | 76 ++++- src/recursos/webhooks.ts | 121 ++++++++ test/cliente.test.ts | 159 ++++++++++ 9 files changed, 970 insertions(+), 18 deletions(-) create mode 100644 src/recursos/webhooks.ts diff --git a/README.md b/README.md index 3aa42e0..aaf4f13 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ redondeo oficial de la SET: en guaraníes, al múltiplo de 50 inferior. | `documentos.emitir(documento, { idempotencyKey })` | `POST /api/v1/documento-electronico` | | `documentos.consultarEstado(cdc)` | `GET /api/v1/documento-electronico/status/{cdc}` | | `documentos.descargarKude(cdc)` | `GET /api/v1/documento-electronico/{cdc}/kude` | +| `documentos.descargarKudeTicket(cdc, { ancho, resumen })` | `GET /api/v1/documento-electronico/{cdc}/kude/ticket` | +| `documentos.descargarKudeTicketImagen(cdc, { puntos, resumen })` | `GET /api/v1/documento-electronico/{cdc}/kude/ticket/imagen` | +| `documentos.descargarXml(cdc)` | `GET /api/v1/documento-electronico/{cdc}/xml` | | `documentos.cancelar(cdc, { motivo })` | `POST /api/v1/documento-electronico/{cdc}/cancelar` | | `documentos.nominar(cdc, { motivo, receptor })` | `POST /api/v1/documento-electronico/{cdc}/nominar` | | `documentos.inutilizar(rango)` | `POST /api/v1/documento-electronico/inutilizar` | @@ -90,13 +93,22 @@ redondeo oficial de la SET: en guaraníes, al múltiplo de 50 inferior. | `contribuyente.consultarConsumoPlan()` | `GET /api/v1/contribuyente/plan-consumo` | | `padron.consultar(rucOCedula)` | `GET /api/v1/padron/{documento}` | | `catalogos.enumeraciones()` | `GET /api/v1/public/enums` | -| `catalogos.departamentos()`, `distritos(id)`, `ciudades(id)` | `GET /api/v1/geografia/…` | +| `catalogos.departamentos()`, `distritos(id)`, `ciudades(id)`, `ciudadesDeDepartamento(id)` | `GET /api/v1/geografia/…` | +| `webhooks.listar()`, `crear(webhook)` | `GET`, `POST /api/v1/contribuyente/webhooks` | +| `webhooks.consultar(id)`, `actualizar(id, webhook)`, `eliminar(id)` | `GET`, `PUT`, `DELETE /api/v1/contribuyente/webhooks/{id}` | +| `webhooks.rotarSecreto(id)` | `POST /api/v1/contribuyente/webhooks/{id}/rotar` | +| `webhooks.listarEntregas(id, filtro)` | `GET /api/v1/contribuyente/webhooks/{id}/entregas` | Los catálogos son públicos y no envían la API key. `descargarKude` espera hasta 3 minutos a que el PDF esté listo; si no lo está, lanza `KudePendienteError`. Cambiá la espera con `{ esperarMs }`. +`descargarKudeTicket` devuelve el KuDE en cinta de papel para impresoras térmicas (`ancho` 80 o 58 mm) y +`descargarKudeTicketImagen` el mismo ticket como PNG de 1 bit para ESC/POS (`puntos` 576, 512 o 384). Con +`resumen: true` omiten el detalle de ítems e impuestos. Sólo existen para facturas y notas de crédito o débito. +`descargarXml` devuelve el XML firmado tal como se envió a SIFEN. + Para poder retomar una operación después de un corte, generá vos la `idempotencyKey` y guardala antes de enviar. Si no la pasás, el SDK genera un UUID que solo reusa en sus propios reintentos. @@ -155,6 +167,10 @@ export async function action({ request }: { request: Request }) { Los tipos de evento son `documento.aprobado`, `documento.rechazado`, `documento.cancelado` y `lote.procesado`. +`webhooks` registra y administra los endpoints. El secreto `whsec_…` llega sólo en la respuesta de `crear` y de +`rotarSecreto`, y ninguno de los dos admite `Idempotency-Key`: el SDK no los repite ante un corte. Si se corta un +`crear`, revisá `listar` antes de volver a intentar. + Una entrega puede repetirse: deduplicá por `evento.id`. `verificarWebhook` rechaza una entrega si su `X-Sifende-Timestamp` difiere más de 5 minutos de tu reloj; ajustalo con `toleranciaSegundos`. Durante una rotación de secreto, pasá `secreto: [nuevo, anterior]`. diff --git a/src/cliente.ts b/src/cliente.ts index 1f363df..d5d7521 100644 --- a/src/cliente.ts +++ b/src/cliente.ts @@ -3,12 +3,14 @@ import { Catalogos } from './recursos/catalogos.js'; import { Contribuyente } from './recursos/contribuyente.js'; import { Documentos } from './recursos/documentos.js'; import { Padron } from './recursos/padron.js'; +import { Webhooks } from './recursos/webhooks.js'; export class Sifende { readonly documentos: Documentos; readonly contribuyente: Contribuyente; readonly padron: Padron; readonly catalogos: Catalogos; + readonly webhooks: Webhooks; /** Ambiente que fija la API key (`sk_live_` → PROD, `sk_test_` → DEV, `sk_sandbox_` → SANDBOX). */ readonly ambiente: Ambiente | undefined; @@ -19,5 +21,6 @@ export class Sifende { this.contribuyente = new Contribuyente(http); this.padron = new Padron(http); this.catalogos = new Catalogos(http); + this.webhooks = new Webhooks(http); } } diff --git a/src/generado/types.gen.ts b/src/generado/types.gen.ts index 8abc8da..80b8974 100644 --- a/src/generado/types.gen.ts +++ b/src/generado/types.gen.ts @@ -4,6 +4,32 @@ export type ClientOptions = { baseUrl: 'https://api.sifende.com.py' | (string & {}); }; +export type ActualizarWebhookIntegracionRequest = { + /** + * URL HTTPS pública que recibe los eventos. No se siguen redirects. + */ + url: string; + /** + * Eventos a los que se suscribe el webhook. + */ + eventos: Array<'documento.aprobado' | 'documento.rechazado' | 'documento.cancelado' | 'lote.procesado'>; + /** + * `false` pausa el webhook y cancela sus entregas pendientes. + */ + activo: boolean; +}; + +export type WebhookEndpointDto = { + id: number; + url: string; + ambiente: 'DEV' | 'PROD' | 'SANDBOX'; + eventos: Array; + activo: boolean; + fechaCreacion: string; + fechaEdicion: string; + fechaRotacionSecreto: string; +}; + /** * Documento referenciado por la factura (H001): una nota de remisión o una factura de anticipo, * electrónica (`cdc`) o impresa (timbrado, establecimiento, punto, número, tipo y fecha). @@ -67,9 +93,9 @@ export type AutofacturaElectronicaRequest = Omit; emailEntrega?: string; } & { - condicionOperacion?: 'CONTADO'; - tipoCambio?: null; descuentoGlobalPorcentaje?: null; + tipoCambio?: null; + condicionOperacion?: 'CONTADO'; monedaOperacion?: 'PYG'; } & unknown & { condicionPago: { @@ -360,8 +386,9 @@ export type DocumentoAsociadoRemisionDto = { * en el schema correspondiente (`FacturaElectronicaRequest`, * `NotaCreditoElectronicaRequest`, …). * - * El emisor no se envía: sale de la API key. El timbrado y el correlativo tampoco, - * los asigna Sifende. + * El emisor no se envía: sale de la API key. El timbrado lo toma Sifende del ambiente + * y el correlativo también, salvo que el sistema del integrador numere por su cuenta + * con `numeroDocumento`. * */ export type DocumentoElectronicoRequest = { @@ -385,6 +412,18 @@ export type DocumentoElectronicoRequest = { * Punto de expedición dentro del establecimiento (1-999) */ puntoExpedicion: number; + /** + * Número de documento (dNumDoc) asignado por tu sistema, de 1 a 9.999.999. Si se omite, Sifende asigna el siguiente correlativo del punto de expedición + */ + numeroDocumento?: number; + /** + * Código de seguridad (dCodSeg) de 9 dígitos. Con el mismo número y la misma fecha de emisión produce siempre el mismo CDC. Si se omite, Sifende genera uno al azar + */ + codigoSeguridad?: string; + /** + * Timbrado con el que tu sistema numera. Si se informa y no coincide con el timbrado cargado en Sifende para el ambiente, el documento se rechaza con `timbrado-mismatch` + */ + numeroTimbrado?: number; /** * Naturaleza de la operación. Obligatorio para factura y autofactura; no se informa en notas de crédito, débito ni remisión */ @@ -725,10 +764,10 @@ export type ItemDto = { }; export type ItemDocumento = { + unidadMedida?: 'kWh' | 'AA' | 'BG' | '_4A' | 'AB' | 'BX' | 'CM' | 'CM2' | 'CM3' | 'BK' | 'CPM' | 'Ci' | 'DET' | 'Di' | 'DOC' | 'DPC' | 'BL' | 'GLL' | 'g' | 'GRO' | 'ha' | 'Hs' | 'SET' | 'E4' | 'kg' | 'kg_m2' | 'Km' | 'KT' | 'LT' | 'U_JGO' | 'ME' | 'ml' | 'm' | 'MT' | 'M2' | 'M3' | 'M5' | 'MCU' | 'MG' | 'ML' | 'MIL' | 'MM' | 'MM2' | 'Mi' | 'PK' | 'PAR' | 'BW' | 'FOT' | 'FTK' | 'PCE' | 'pm' | 'JR' | 'PUL' | 'KLT' | 'racion' | 'RM' | 'RO' | 'Se' | 'DR' | 'TN' | 'UNI' | 'UI' | 'GL' | 'Ya'; descripcion?: string; - codigo?: string; cantidad?: number; - unidadMedida?: 'kWh' | 'AA' | 'BG' | '_4A' | 'AB' | 'BX' | 'CM' | 'CM2' | 'CM3' | 'BK' | 'CPM' | 'Ci' | 'DET' | 'Di' | 'DOC' | 'DPC' | 'BL' | 'GLL' | 'g' | 'GRO' | 'ha' | 'Hs' | 'SET' | 'E4' | 'kg' | 'kg_m2' | 'Km' | 'KT' | 'LT' | 'U_JGO' | 'ME' | 'ml' | 'm' | 'MT' | 'M2' | 'M3' | 'M5' | 'MCU' | 'MG' | 'ML' | 'MIL' | 'MM' | 'MM2' | 'Mi' | 'PK' | 'PAR' | 'BW' | 'FOT' | 'FTK' | 'PCE' | 'pm' | 'JR' | 'PUL' | 'KLT' | 'racion' | 'RM' | 'RO' | 'Se' | 'DR' | 'TN' | 'UNI' | 'UI' | 'GL' | 'Ya'; + codigo?: string; }; export type ItemRemisionDto = { @@ -976,7 +1015,7 @@ export type ReceptorDto = unknown & { */ nombreFantasia?: string; /** - * Domicilio del receptor (D213). Obligatorio para `tipoOperacion = B2F` y para la nota de remisión, que documenta un traslado físico; opcional en el resto + * Domicilio del receptor (D213). Obligatorio para `tipoOperacion = B2F` y para la nota de remisión, que documenta un traslado físico; opcional en el resto. En una FE nacional exige `departamento` y `ciudad` */ direccion?: string; /** @@ -1380,6 +1419,25 @@ export type EventoInutilizacionRequest = { serie?: string; }; +export type CrearWebhookIntegracionRequest = { + /** + * URL HTTPS pública que recibe los eventos. No se siguen redirects. + */ + url: string; + /** + * Eventos a los que se suscribe el webhook. + */ + eventos: Array<'documento.aprobado' | 'documento.rechazado' | 'documento.cancelado' | 'lote.procesado'>; +}; + +export type WebhookSecretResponse = { + endpoint: WebhookEndpointDto; + /** + * Secreto de firma. Sólo se devuelve al crear el webhook y al rotar el secreto. + */ + secreto: string; +}; + export type EnumValueDto = { name?: string; label?: string; @@ -1599,6 +1657,34 @@ export type Ubicacion = { descripcion: string; }; +export type WebhookDeliveryDto = { + id: string; + eventId: string; + tipoEvento: string; + endpointId: number; + endpointUrl: string; + estado: 'PENDIENTE' | 'ENCOLADA' | 'ENVIANDO' | 'REINTENTANDO' | 'ENTREGADA' | 'FALLIDA' | 'CANCELADA'; + cantidadIntentos: number; + ultimoHttpStatus: number | null; + ultimoErrorCodigo: string | null; + ultimoErrorMensaje: string | null; + fechaCreacion: string; + fechaUltimoIntento: string | null; + fechaEntrega: string | null; + fechaFallo: string | null; +}; + +export type WebhookEntregaPaginaDto = { + content: Array; + /** + * Página actual, base 0 + */ + page: number; + size: number; + totalElements: number; + totalPages: number; +}; + /** * Error en formato RFC 9457. Se sirve como `application/problem+json`. */ @@ -1647,6 +1733,130 @@ export type ProblemDetail = { valoresAceptados?: Array; }; +export type EliminarWebhookData = { + body?: never; + path: { + /** + * Identificador del webhook, tal como lo devuelve el listado + */ + webhookId: number; + }; + query?: never; + url: '/api/v1/contribuyente/webhooks/{webhookId}'; +}; + +export type EliminarWebhookErrors = { + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type EliminarWebhookError = EliminarWebhookErrors[keyof EliminarWebhookErrors]; + +export type EliminarWebhookResponses = { + /** + * Webhook eliminado + */ + 204: void; +}; + +export type EliminarWebhookResponse = EliminarWebhookResponses[keyof EliminarWebhookResponses]; + +export type ConsultarWebhookData = { + body?: never; + path: { + /** + * Identificador del webhook, tal como lo devuelve el listado + */ + webhookId: number; + }; + query?: never; + url: '/api/v1/contribuyente/webhooks/{webhookId}'; +}; + +export type ConsultarWebhookErrors = { + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type ConsultarWebhookError = ConsultarWebhookErrors[keyof ConsultarWebhookErrors]; + +export type ConsultarWebhookResponses = { + /** + * Webhook + */ + 200: WebhookEndpointDto; +}; + +export type ConsultarWebhookResponse = ConsultarWebhookResponses[keyof ConsultarWebhookResponses]; + +export type ActualizarWebhookData = { + body: ActualizarWebhookIntegracionRequest; + path: { + /** + * Identificador del webhook, tal como lo devuelve el listado + */ + webhookId: number; + }; + query?: never; + url: '/api/v1/contribuyente/webhooks/{webhookId}'; +}; + +export type ActualizarWebhookErrors = { + /** + * El request no pasó la validación. `errores` trae un mensaje por campo + */ + 400: ProblemDetail; + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type ActualizarWebhookError = ActualizarWebhookErrors[keyof ActualizarWebhookErrors]; + +export type ActualizarWebhookResponses = { + /** + * Webhook actualizado + */ + 200: WebhookEndpointDto; +}; + +export type ActualizarWebhookResponse = ActualizarWebhookResponses[keyof ActualizarWebhookResponses]; + export type EmitirDocumentoData = { body: FacturaElectronicaRequest | NotaCreditoElectronicaRequest | NotaDebitoElectronicaRequest | NotaRemisionElectronicaRequest | AutofacturaElectronicaRequest; headers?: { @@ -1736,11 +1946,11 @@ export type EmitirDocumentoErrors = { */ 404: ProblemDetail; /** - * La operación sigue en curso, su resultado es indeterminado o el replay expiró + * El número de documento no está disponible, o la operación sigue en curso, su resultado es indeterminado o el replay expiró */ 409: ProblemDetail; /** - * El documento incumple una regla de SIFEN o la clave corresponde a otro tipo de operación + * El documento incumple una regla de SIFEN, el timbrado o el número no se pueden usar, o la clave corresponde a otro tipo de operación */ 422: ProblemDetail; /** @@ -1764,7 +1974,7 @@ export type EmitirDocumentoResponses = { export type EmitirDocumentoResponse = EmitirDocumentoResponses[keyof EmitirDocumentoResponses]; -export type NominarByCdcData = { +export type NominarDocumentoData = { body: EventoNominacionRequest; headers?: { 'Idempotency-Key'?: string; @@ -1776,14 +1986,14 @@ export type NominarByCdcData = { url: '/api/v1/documento-electronico/{cdc}/nominar'; }; -export type NominarByCdcResponses = { +export type NominarDocumentoResponses = { /** * OK */ 200: EventoSifenDto; }; -export type NominarByCdcResponse = NominarByCdcResponses[keyof NominarByCdcResponses]; +export type NominarDocumentoResponse = NominarDocumentoResponses[keyof NominarDocumentoResponses]; export type CancelarDocumentoData = { body: CancelacionRequest; @@ -1929,6 +2139,112 @@ export type InutilizarNumeracionResponses = { export type InutilizarNumeracionResponse = InutilizarNumeracionResponses[keyof InutilizarNumeracionResponses]; +export type ListarWebhooksData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/contribuyente/webhooks'; +}; + +export type ListarWebhooksErrors = { + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type ListarWebhooksError = ListarWebhooksErrors[keyof ListarWebhooksErrors]; + +export type ListarWebhooksResponses = { + /** + * Webhooks del ambiente de la clave + */ + 200: Array; +}; + +export type ListarWebhooksResponse = ListarWebhooksResponses[keyof ListarWebhooksResponses]; + +export type CrearWebhookData = { + body: CrearWebhookIntegracionRequest; + path?: never; + query?: never; + url: '/api/v1/contribuyente/webhooks'; +}; + +export type CrearWebhookErrors = { + /** + * El request no pasó la validación. `errores` trae un mensaje por campo + */ + 400: ProblemDetail; + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type CrearWebhookError = CrearWebhookErrors[keyof CrearWebhookErrors]; + +export type CrearWebhookResponses = { + /** + * Webhook creado. `Location` apunta a su consulta + */ + 201: WebhookSecretResponse; +}; + +export type CrearWebhookResponse = CrearWebhookResponses[keyof CrearWebhookResponses]; + +export type RotarSecretoWebhookData = { + body?: never; + path: { + /** + * Identificador del webhook, tal como lo devuelve el listado + */ + webhookId: number; + }; + query?: never; + url: '/api/v1/contribuyente/webhooks/{webhookId}/rotar'; +}; + +export type RotarSecretoWebhookErrors = { + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type RotarSecretoWebhookError = RotarSecretoWebhookErrors[keyof RotarSecretoWebhookErrors]; + +export type RotarSecretoWebhookResponses = { + /** + * Secreto rotado + */ + 200: WebhookSecretResponse; +}; + +export type RotarSecretoWebhookResponse = RotarSecretoWebhookResponses[keyof RotarSecretoWebhookResponses]; + export type ListarEnumeracionesData = { body?: never; path?: never; @@ -2069,6 +2385,59 @@ export type ListarCiudadesDepartamentoResponses = { export type ListarCiudadesDepartamentoResponse = ListarCiudadesDepartamentoResponses[keyof ListarCiudadesDepartamentoResponses]; +export type DescargarXmlData = { + body?: never; + path: { + /** + * CDC del documento, 44 dígitos + */ + cdc: string; + }; + query?: never; + url: '/api/v1/documento-electronico/{cdc}/xml'; +}; + +export type DescargarXmlErrors = { + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El documento está conservado, pero fuera del período de acceso del plan actual + */ + 403: { + type: 'https://api.sifende.com.py/problems/document-archived'; + title: 'Documento archivado'; + status: 403; + detail: 'El período de acceso incluido en el plan actual ha finalizado.'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * El documento existe pero todavía no tiene XML firmado + */ + 409: ProblemDetail; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type DescargarXmlError = DescargarXmlErrors[keyof DescargarXmlErrors]; + +export type DescargarXmlResponses = { + /** + * XML firmado del documento + */ + 200: Blob | File; +}; + +export type DescargarXmlResponse = DescargarXmlResponses[keyof DescargarXmlResponses]; + export type DescargarKudeData = { body?: never; path: { @@ -2131,13 +2500,145 @@ export type DescargarKudeResponses = { * Preparación pendiente; esta respuesta no es un PDF. Seguir Location, que sólo consulta. Si sigue pendiente después de 2 minutos, repetir una vez la descarga sin soloConsulta. */ 202: { - estado: 'PENDIENTE'; url: null; + estado: 'PENDIENTE'; }; }; export type DescargarKudeResponse = DescargarKudeResponses[keyof DescargarKudeResponses]; +export type DescargarKudeTicketData = { + body?: never; + path: { + /** + * CDC del documento, 44 dígitos + */ + cdc: string; + }; + query?: { + /** + * Ancho del papel en mm: 80 o 58 + */ + ancho?: number; + /** + * Ticket resumen, sin detalle de ítems ni de impuestos + */ + resumen?: boolean; + }; + url: '/api/v1/documento-electronico/{cdc}/kude/ticket'; +}; + +export type DescargarKudeTicketErrors = { + /** + * El request no pasó la validación. `errores` trae un mensaje por campo + */ + 400: ProblemDetail; + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El documento está conservado, pero fuera del período de acceso del plan actual + */ + 403: { + type: 'https://api.sifende.com.py/problems/document-archived'; + title: 'Documento archivado'; + status: 403; + detail: 'El período de acceso incluido en el plan actual ha finalizado.'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * No se pudo obtener el KuDE por un error interno. La extensión estado=FALLIDO sólo aparece cuando existe cierre terminal; no depende del HTTP. + */ + 500: ProblemDetail; + /** + * KuDE no implementado para este tipo de documento. + */ + 501: ProblemDetail; +}; + +export type DescargarKudeTicketError = DescargarKudeTicketErrors[keyof DescargarKudeTicketErrors]; + +export type DescargarKudeTicketResponses = { + /** + * PDF de una sola página del ancho del papel + */ + 200: Blob | File; +}; + +export type DescargarKudeTicketResponse = DescargarKudeTicketResponses[keyof DescargarKudeTicketResponses]; + +export type DescargarKudeTicketImagenData = { + body?: never; + path: { + /** + * CDC del documento, 44 dígitos + */ + cdc: string; + }; + query?: { + /** + * Puntos por línea de la impresora: 576, 512 o 384 + */ + puntos?: number; + /** + * Ticket resumen, sin detalle de ítems ni de impuestos + */ + resumen?: boolean; + }; + url: '/api/v1/documento-electronico/{cdc}/kude/ticket/imagen'; +}; + +export type DescargarKudeTicketImagenErrors = { + /** + * El request no pasó la validación. `errores` trae un mensaje por campo + */ + 400: ProblemDetail; + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El documento está conservado, pero fuera del período de acceso del plan actual + */ + 403: { + type: 'https://api.sifende.com.py/problems/document-archived'; + title: 'Documento archivado'; + status: 403; + detail: 'El período de acceso incluido en el plan actual ha finalizado.'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * No se pudo obtener el KuDE por un error interno. La extensión estado=FALLIDO sólo aparece cuando existe cierre terminal; no depende del HTTP. + */ + 500: ProblemDetail; + /** + * KuDE no implementado para este tipo de documento. + */ + 501: ProblemDetail; +}; + +export type DescargarKudeTicketImagenError = DescargarKudeTicketImagenErrors[keyof DescargarKudeTicketImagenErrors]; + +export type DescargarKudeTicketImagenResponses = { + /** + * PNG de 1 bit + */ + 200: Blob | File; +}; + +export type DescargarKudeTicketImagenResponse = DescargarKudeTicketImagenResponses[keyof DescargarKudeTicketImagenResponses]; + export type ConsultarEstadoData = { body?: never; path: { @@ -2330,6 +2831,67 @@ export type ConsultarContribuyenteResponses = { export type ConsultarContribuyenteResponse = ConsultarContribuyenteResponses[keyof ConsultarContribuyenteResponses]; +export type ListarEntregasWebhookData = { + body?: never; + path: { + /** + * Identificador del webhook, tal como lo devuelve el listado + */ + webhookId: number; + }; + query?: { + /** + * Página, base 0 + */ + page?: number; + /** + * Tamaño de página, de 1 a 100 + */ + size?: number; + /** + * Filtro por estado de la entrega + */ + estado?: 'PENDIENTE' | 'ENCOLADA' | 'ENVIANDO' | 'REINTENTANDO' | 'ENTREGADA' | 'FALLIDA' | 'CANCELADA'; + /** + * Filtro por tipo de evento + */ + tipoEvento?: 'documento.aprobado' | 'documento.rechazado' | 'documento.cancelado' | 'lote.procesado'; + }; + url: '/api/v1/contribuyente/webhooks/{webhookId}/entregas'; +}; + +export type ListarEntregasWebhookErrors = { + /** + * El request no pasó la validación. `errores` trae un mensaje por campo + */ + 400: ProblemDetail; + /** + * Falta la API key, está revocada o no corresponde al ambiente. El filtro conserva su error JSON. + */ + 401: { + error: 'Invalid or expired API key'; + }; + /** + * El recurso no existe, o le falta al contribuyente una precondición de emisión + */ + 404: ProblemDetail; + /** + * Error inesperado. En métodos que modifican estado el resultado queda indeterminado: consultá el estado antes de reintentar, porque un reintento a ciegas puede duplicar el documento + */ + 500: ProblemDetail; +}; + +export type ListarEntregasWebhookError = ListarEntregasWebhookErrors[keyof ListarEntregasWebhookErrors]; + +export type ListarEntregasWebhookResponses = { + /** + * Página de entregas + */ + 200: WebhookEntregaPaginaDto; +}; + +export type ListarEntregasWebhookResponse = ListarEntregasWebhookResponses[keyof ListarEntregasWebhookResponses]; + export type ConsultarPlanConsumoData = { body?: never; path?: never; diff --git a/src/http.ts b/src/http.ts index 6c6d811..a23d33b 100644 --- a/src/http.ts +++ b/src/http.ts @@ -24,12 +24,14 @@ export interface OpcionesIdempotentes extends OpcionesSolicitud { } export interface Solicitud { - metodo: 'GET' | 'POST'; + metodo: 'GET' | 'POST' | 'PUT' | 'DELETE'; ruta: string; query?: Record | undefined; cuerpo?: unknown; idempotencyKey?: string | undefined; autenticar?: boolean | undefined; + /** Accept de una descarga (PDF, XML, PNG). Sin `binario`, el éxito se lee como texto. */ + acepta?: string | undefined; binario?: boolean | undefined; signal?: AbortSignal | undefined; } @@ -42,6 +44,11 @@ export interface Respuesta { export const URL_BASE = 'https://api.sifende.com.py'; +// Accept de cada descarga. Incluyen JSON porque los errores, y el 202 del KuDE, vienen en JSON. +export const ACEPTA_PDF = 'application/pdf, application/json, application/problem+json'; +export const ACEPTA_XML = 'application/xml, application/json, application/problem+json'; +export const ACEPTA_PNG = 'image/png, application/json, application/problem+json'; + const PREFIJOS: Record = { sk_live_: 'PROD', sk_test_: 'DEV', @@ -129,7 +136,7 @@ export class ClienteHttp { private preparar(solicitud: Solicitud): RequestInit { const headers = new Headers({ - Accept: solicitud.binario ? 'application/pdf, application/json, application/problem+json' : 'application/json', + Accept: solicitud.acepta ?? 'application/json', 'User-Agent': `sifende-sdk-ts/${VERSION}`, }); if (solicitud.autenticar !== false) headers.set('Authorization', `Bearer ${this.apiKey}`); diff --git a/src/index.ts b/src/index.ts index 8e9d1ab..00d1e91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,8 @@ export { } from './errores.js'; export type * from './generado/types.gen.js'; export type { Ambiente, OpcionesCliente, OpcionesIdempotentes, OpcionesSolicitud } from './http.js'; -export type { OpcionesKude, SolicitudEmision } from './recursos/documentos.js'; +export type { OpcionesKude, OpcionesTicket, OpcionesTicketImagen, SolicitudEmision } from './recursos/documentos.js'; +export type { FiltroEntregas } from './recursos/webhooks.js'; export { calcularTotales, type DocumentoParaTotales, diff --git a/src/recursos/catalogos.ts b/src/recursos/catalogos.ts index 8d9ba24..da30bfa 100644 --- a/src/recursos/catalogos.ts +++ b/src/recursos/catalogos.ts @@ -38,6 +38,15 @@ export class Catalogos { return datos.data ?? []; } + /** Todas las ciudades de un departamento, sin pasar por sus distritos, con el código que SIFEN espera. */ + async ciudadesDeDepartamento(departamentoId: number, opciones: OpcionesSolicitud = {}): Promise { + const datos = await this.obtener( + `/api/v1/geografia/departamentos/${encodeURIComponent(departamentoId)}/ciudades`, + opciones, + ); + return datos.data ?? []; + } + private async obtener(ruta: string, opciones: OpcionesSolicitud): Promise { const respuesta = await this.http.solicitar({ metodo: 'GET', ruta, autenticar: false, signal: opciones.signal }); return respuesta.datos; diff --git a/src/recursos/documentos.ts b/src/recursos/documentos.ts index c5c2133..abc38b0 100644 --- a/src/recursos/documentos.ts +++ b/src/recursos/documentos.ts @@ -10,7 +10,15 @@ import type { ListarEventosData, PageDtoEventoSifenDto, } from '../generado/types.gen.js'; -import { type ClienteHttp, esperar, type OpcionesIdempotentes, type OpcionesSolicitud } from '../http.js'; +import { + ACEPTA_PDF, + ACEPTA_PNG, + ACEPTA_XML, + type ClienteHttp, + esperar, + type OpcionesIdempotentes, + type OpcionesSolicitud, +} from '../http.js'; const RUTA = '/api/v1/documento-electronico'; @@ -21,6 +29,20 @@ export interface OpcionesKude extends OpcionesSolicitud { esperarMs?: number; } +export interface OpcionesTicket extends OpcionesSolicitud { + /** Ancho del papel en mm: 80 o 58. Por defecto 80. */ + ancho?: 80 | 58; + /** Omite el detalle de ítems y de impuestos (Manual Técnico V150, cap. 13.7). */ + resumen?: boolean; +} + +export interface OpcionesTicketImagen extends OpcionesSolicitud { + /** Puntos por línea de la impresora a 203 dpi: 576 o 512 para papel de 80 mm, 384 para 58 mm. Por defecto 576. */ + puntos?: 576 | 512 | 384; + /** Omite el detalle de ítems y de impuestos (Manual Técnico V150, cap. 13.7). */ + resumen?: boolean; +} + export class Documentos { constructor(private readonly http: ClienteHttp) {} @@ -73,6 +95,7 @@ export class Documentos { metodo: 'GET', ruta, query: soloConsulta ? { soloConsulta: true } : undefined, + acepta: ACEPTA_PDF, binario: true, signal, }); @@ -88,6 +111,57 @@ export class Documentos { } } + /** + * Descarga el XML firmado tal como se envió a SIFEN, con el código QR incluido. Está disponible desde que el + * documento se registra; si todavía no tiene XML, la API responde 409 `documento-xml-not-available`. + */ + async descargarXml(cdc: string, opciones: OpcionesSolicitud = {}): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'GET', + ruta: `${RUTA}/${encodeURIComponent(cdc)}/xml`, + acepta: ACEPTA_XML, + signal: opciones.signal, + }); + return respuesta.datos ?? ''; + } + + /** + * Descarga el KuDE en cinta de papel (PDF) para impresoras térmicas de 80 o 58 mm. Sólo existe para facturas y + * notas de crédito o débito: otros tipos responden 501 `kude-not-supported`. + */ + async descargarKudeTicket(cdc: string, opciones: OpcionesTicket = {}): Promise { + return this.descargarTicket(`${RUTA}/${encodeURIComponent(cdc)}/kude/ticket`, ACEPTA_PDF, opciones, { + ancho: opciones.ancho, + }); + } + + /** + * Descarga el ticket como PNG de 1 bit con el ancho exacto de una línea de la impresora, para imprimirlo con el + * comando de imagen ráster de ESC/POS (GS v 0). + */ + async descargarKudeTicketImagen(cdc: string, opciones: OpcionesTicketImagen = {}): Promise { + return this.descargarTicket(`${RUTA}/${encodeURIComponent(cdc)}/kude/ticket/imagen`, ACEPTA_PNG, opciones, { + puntos: opciones.puntos, + }); + } + + private async descargarTicket( + ruta: string, + acepta: string, + opciones: OpcionesSolicitud & { resumen?: boolean }, + medida: Record, + ): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'GET', + ruta, + query: { ...medida, resumen: opciones.resumen || undefined }, + acepta, + binario: true, + signal: opciones.signal, + }); + return respuesta.datos; + } + async cancelar( cdc: string, solicitud: CancelacionRequest, diff --git a/src/recursos/webhooks.ts b/src/recursos/webhooks.ts new file mode 100644 index 0000000..3559ddf --- /dev/null +++ b/src/recursos/webhooks.ts @@ -0,0 +1,121 @@ +import type { + ActualizarWebhookIntegracionRequest, + CrearWebhookIntegracionRequest, + ListarEntregasWebhookData, + WebhookEndpointDto, + WebhookEntregaPaginaDto, + WebhookSecretResponse, +} from '../generado/types.gen.js'; +import type { ClienteHttp, OpcionesSolicitud } from '../http.js'; + +const RUTA = '/api/v1/contribuyente/webhooks'; + +export type FiltroEntregas = NonNullable; + +/** + * Administra los webhooks del contribuyente en el ambiente de la API key. Para verificar las entregas que llegan a tu + * endpoint, usá `verificarWebhook`. + * + * `crear` y `rotarSecreto` no admiten `Idempotency-Key`, así que el SDK no los repite ante un corte de conexión: el + * secreto sólo viaja en esa respuesta. + */ +export class Webhooks { + constructor(private readonly http: ClienteHttp) {} + + /** Los webhooks del ambiente de la API key, los más recientes primero, sin el secreto. */ + async listar(opciones: OpcionesSolicitud = {}): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'GET', + ruta: RUTA, + signal: opciones.signal, + }); + return respuesta.datos; + } + + /** + * Registra una URL HTTPS pública para recibir eventos. Hay hasta cinco webhooks activos por ambiente. + * + * El secreto `whsec_…` llega sólo en esta respuesta: guardalo. Si se corta la conexión, consultá `listar` antes de + * repetir: una URL ya registrada responde 400 `validation-error`. + */ + async crear( + webhook: CrearWebhookIntegracionRequest, + opciones: OpcionesSolicitud = {}, + ): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'POST', + ruta: RUTA, + cuerpo: webhook, + signal: opciones.signal, + }); + return respuesta.datos; + } + + /** Un webhook, sin el secreto. Uno de otro ambiente responde 404. */ + async consultar(webhookId: number, opciones: OpcionesSolicitud = {}): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'GET', + ruta: ruta(webhookId), + signal: opciones.signal, + }); + return respuesta.datos; + } + + /** + * Reemplaza la URL, los eventos y el estado. `activo: false` pausa el webhook y cancela sus entregas pendientes. El + * secreto no cambia: para eso, `rotarSecreto`. + */ + async actualizar( + webhookId: number, + webhook: ActualizarWebhookIntegracionRequest, + opciones: OpcionesSolicitud = {}, + ): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'PUT', + ruta: ruta(webhookId), + cuerpo: webhook, + signal: opciones.signal, + }); + return respuesta.datos; + } + + /** Borra el webhook y cancela sus entregas pendientes. No se puede deshacer. */ + async eliminar(webhookId: number, opciones: OpcionesSolicitud = {}): Promise { + await this.http.solicitar({ metodo: 'DELETE', ruta: ruta(webhookId), signal: opciones.signal }); + } + + /** + * Genera un secreto nuevo y deja de firmar con el anterior, también las entregas pendientes. El secreto llega sólo + * en esta respuesta; si no la recibiste, volvé a rotar. + * + * Para no perder entregas durante el cambio, verificá con los dos secretos hasta actualizar tu endpoint: + * `verificarWebhook({ secreto: [nuevo, anterior], … })`. + */ + async rotarSecreto(webhookId: number, opciones: OpcionesSolicitud = {}): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'POST', + ruta: `${ruta(webhookId)}/rotar`, + signal: opciones.signal, + }); + return respuesta.datos; + } + + /** Historial de entregas del webhook, las más recientes primero: estado, intentos y último error. Se conserva 90 días. */ + async listarEntregas( + webhookId: number, + filtro: FiltroEntregas = {}, + opciones: OpcionesSolicitud = {}, + ): Promise { + const respuesta = await this.http.solicitar({ + metodo: 'GET', + ruta: `${ruta(webhookId)}/entregas`, + query: filtro, + signal: opciones.signal, + }); + return respuesta.datos; + } +} + +function ruta(webhookId: number): string { + return `${RUTA}/${encodeURIComponent(webhookId)}`; +} diff --git a/test/cliente.test.ts b/test/cliente.test.ts index 0c21ef0..8568dd3 100644 --- a/test/cliente.test.ts +++ b/test/cliente.test.ts @@ -272,3 +272,162 @@ describe('eventos', () => { expect(llamadas[0]?.headers.get('idempotency-key')).toBe(llamadas[1]?.headers.get('idempotency-key')); }); }); + +describe('descargas', () => { + const bytes = (tipo: string, contenido: Uint8Array | string) => () => + new Response(contenido, { headers: { 'Content-Type': tipo } }); + + it('devuelve el XML firmado como texto y lo pide como XML', async () => { + const xml = ''; + const { fetch, llamadas } = falsoFetch(bytes('application/xml', xml)); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + await expect(sifende.documentos.descargarXml('0180')).resolves.toBe(xml); + expect(llamadas[0]?.url.pathname).toBe('/api/v1/documento-electronico/0180/xml'); + expect(llamadas[0]?.headers.get('accept')).toMatch(/^application\/xml/); + }); + + it('informa el 409 de un documento que todavía no tiene XML', async () => { + const { fetch } = falsoFetch(problema(409, 'documento-xml-not-available')); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + const error = await sifende.documentos.descargarXml('0180').catch((e: unknown) => e); + + expect((error as SifendeApiError).codigo).toBe('documento-xml-not-available'); + }); + + it('descarga el ticket con ancho y resumen sólo si se indican', async () => { + const { fetch, llamadas } = falsoFetch(bytes('application/pdf', '%PDF')); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + await sifende.documentos.descargarKudeTicket('0180'); + const contenido = await sifende.documentos.descargarKudeTicket('0180', { ancho: 58, resumen: true }); + + expect(new TextDecoder().decode(contenido)).toBe('%PDF'); + expect(llamadas[0]?.url.pathname).toBe('/api/v1/documento-electronico/0180/kude/ticket'); + expect(llamadas[0]?.url.search).toBe(''); + expect(llamadas[1]?.url.search).toBe('?ancho=58&resumen=true'); + }); + + it('no reintenta el 501 de un tipo de documento sin ticket', async () => { + const { fetch, llamadas } = falsoFetch(problema(501, 'kude-not-supported')); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + await expect(sifende.documentos.descargarKudeTicket('0180')).rejects.toBeInstanceOf(SifendeApiError); + expect(llamadas).toHaveLength(1); + }); + + it('descarga el ticket como PNG para ESC/POS', async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { fetch, llamadas } = falsoFetch(bytes('image/png', png)); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + const contenido = await sifende.documentos.descargarKudeTicketImagen('0180', { puntos: 384 }); + + expect(Array.from(contenido)).toEqual(Array.from(png)); + expect(llamadas[0]?.url.pathname).toBe('/api/v1/documento-electronico/0180/kude/ticket/imagen'); + expect(llamadas[0]?.url.search).toBe('?puntos=384'); + expect(llamadas[0]?.headers.get('accept')).toMatch(/^image\/png/); + }); + + it('lista las ciudades de un departamento sin API key', async () => { + const { fetch, llamadas } = falsoFetch(json({ data: [{ ciudadId: 1, nombre: 'ASUNCION (DISTRITO)' }] })); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + await expect(sifende.catalogos.ciudadesDeDepartamento(1)).resolves.toEqual([ + { ciudadId: 1, nombre: 'ASUNCION (DISTRITO)' }, + ]); + expect(llamadas[0]?.url.pathname).toBe('/api/v1/geografia/departamentos/1/ciudades'); + expect(llamadas[0]?.headers.has('authorization')).toBe(false); + }); +}); + +describe('webhooks', () => { + const cortar = () => { + throw new TypeError('fetch failed'); + }; + + it('crea un webhook sin Idempotency-Key y devuelve el secreto', async () => { + const { fetch, llamadas } = falsoFetch(json({ endpoint: { id: 7 }, secreto: 'whsec_nuevo' }, 201)); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + const creado = await sifende.webhooks.crear({ + url: 'https://tienda.com.py/webhooks/sifende', + eventos: ['documento.aprobado', 'documento.rechazado'], + }); + + expect(creado.secreto).toBe('whsec_nuevo'); + expect(llamadas[0]?.init.method).toBe('POST'); + expect(llamadas[0]?.url.pathname).toBe('/api/v1/contribuyente/webhooks'); + expect(llamadas[0]?.headers.has('idempotency-key')).toBe(false); + }); + + it('no repite la creación ni la rotación tras un corte de conexión', async () => { + const { fetch, llamadas } = falsoFetch(cortar, json({ secreto: 'whsec_duplicado' }, 201)); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + await expect(sifende.webhooks.crear({ url: 'https://tienda.com.py/w', eventos: [] })).rejects.toBeInstanceOf( + SifendeConexionError, + ); + expect(llamadas).toHaveLength(1); + + const rotacion = falsoFetch(cortar, json({ secreto: 'whsec_otro' })); + const otro = new Sifende({ apiKey: 'sk_test_abc', fetch: rotacion.fetch }); + await expect(otro.webhooks.rotarSecreto(7)).rejects.toBeInstanceOf(SifendeConexionError); + expect(rotacion.llamadas).toHaveLength(1); + expect(rotacion.llamadas[0]?.url.pathname).toBe('/api/v1/contribuyente/webhooks/7/rotar'); + }); + + it('lista, consulta, actualiza y elimina', async () => { + const { fetch, llamadas } = falsoFetch( + json([{ id: 7, ambiente: 'DEV' }]), + json({ id: 7 }), + json({ id: 7, activo: false }), + () => new Response(null, { status: 204 }), + ); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + await expect(sifende.webhooks.listar()).resolves.toEqual([{ id: 7, ambiente: 'DEV' }]); + await sifende.webhooks.consultar(7); + await sifende.webhooks.actualizar(7, { + url: 'https://tienda.com.py/w', + eventos: ['lote.procesado'], + activo: false, + }); + await expect(sifende.webhooks.eliminar(7)).resolves.toBeUndefined(); + + expect(llamadas.map((l) => `${l.init.method} ${l.url.pathname}`)).toEqual([ + 'GET /api/v1/contribuyente/webhooks', + 'GET /api/v1/contribuyente/webhooks/7', + 'PUT /api/v1/contribuyente/webhooks/7', + 'DELETE /api/v1/contribuyente/webhooks/7', + ]); + expect(JSON.parse(String(llamadas[2]?.init.body))).toMatchObject({ activo: false }); + }); + + it('informa el 404 de un webhook de otro ambiente', async () => { + const { fetch } = falsoFetch(problema(404, 'webhook-not-found')); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + const error = await sifende.webhooks.consultar(8).catch((e: unknown) => e); + + expect((error as SifendeApiError).status).toBe(404); + expect((error as SifendeApiError).codigo).toBe('webhook-not-found'); + }); + + it('pasa los filtros de entregas como query', async () => { + const { fetch, llamadas } = falsoFetch(json({ content: [{ id: 'e1', estado: 'FALLIDA' }], page: 2 })); + const sifende = new Sifende({ apiKey: 'sk_test_abc', fetch }); + + const pagina = await sifende.webhooks.listarEntregas(7, { + page: 2, + size: 50, + estado: 'FALLIDA', + tipoEvento: 'documento.aprobado', + }); + + expect(pagina.content?.[0]?.estado).toBe('FALLIDA'); + expect(llamadas[0]?.url.pathname).toBe('/api/v1/contribuyente/webhooks/7/entregas'); + expect(llamadas[0]?.url.search).toBe('?page=2&size=50&estado=FALLIDA&tipoEvento=documento.aprobado'); + }); +}); From be8b8af82a5a6256b24fe9ad1f3ee894025080f9 Mon Sep 17 00:00:00 2001 From: Mijhael Samaniego <67170467+mijhaels@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:17:38 -0300 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20versi=C3=B3n=200.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8efd5b3..248f97a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sifende/sdk", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sifende/sdk", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "^2.5.15", diff --git a/package.json b/package.json index 16258d0..4fccae3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sifende/sdk", - "version": "0.1.0", + "version": "0.2.0", "description": "SDK oficial de Sifende para emitir documentos electrónicos de SIFEN desde TypeScript y JavaScript.", "license": "Apache-2.0", "type": "module", diff --git a/src/version.ts b/src/version.ts index 37025ab..8e1492f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.1.0'; +export const VERSION = '0.2.0';